You're upgraded. Your key's new monthly limit is live right
now — the same key, no redeploy, no new credential. The next request will see
it; GET /v1/usage confirms the tier and the allowance.
Manage or cancel any time in the
billing portal — an authenticated
POST /v1/billing/portal returns a one-time link, and the receipt
Paddle has just emailed you carries one too. Plan changes there are prorated
and your key follows the new plan automatically.
Checkout cancelled — nothing was charged. Your key still works on its current plan and your usage is untouched. Start again from Upgrading whenever you are ready, or email hello@attestwire.com if something about the flow got in the way.
Hosted EN 16931 API
Build and check EN 16931 invoices over HTTP
Generate, read and check UBL or CII for XRechnung and Peppol BIS. Send JSON or XML from any backend language and get clear errors with the rule, field and fix.
Make your first request ↓ View plans
Try it now, with the demo key
No signup: the public key demo works on
POST /v1/validate, POST /v1/generate and POST /v1/recipient. Paste this:
curl -X POST https://api.attestwire.com/v1/validate \
-H "authorization: Bearer demo" \
-H 'content-type: application/json' \
-d '{"profile": "en16931", "invoiceNumber": "DEMO-1", "issueDate": "2026-09-26", "currency": "EUR",
"seller": {"name":"Demo GmbH","vatId":"DE123456789","address":{"city":"Berlin","postalCode":"10115","countryCode":"DE"}},
"buyer": {"name":"Kunde AG","address":{"city":"Koeln","postalCode":"50667","countryCode":"DE"}},
"lines": [{"id":"1","description":"Consulting","quantity":2,"unitCode":"HUR","unitPrice":100,"vatCategory":"S","vatRate":19}]}'
It answers "valid": true. Change "en16931" to
"xrechnung-ubl" and send it again: the German rules answer with three
teaching errors, each with its fix. /v1/generate takes the same key,
and ?format=pdf with "facturx-en16931" returns the
Factur-X / ZUGFeRD PDF, always watermarked.
/v1/recipient takes it too: it is what the keyless
Peppol ID check on
attestwire.com calls.
The demo key belongs to nobody and is limited per IP address: 6
requests a minute (3 back to back) and 30
documents a UTC day, across all three routes. Nothing else takes it —
/v1/parse, the other live checks, validation records, usage, rotation,
billing, /v1/bundle, /v1/send and the MCP tools answer
403 demo_key_not_allowed. For your own traffic, get a free key:
Quickstart
1. Get a key
Free tier, no card, 100 documents a month. One key per email address.
Create a free API key with your email address. It appears on the page immediately, with no inbox confirmation. Or create the same key from your terminal:
curl -X POST https://api.attestwire.com/v1/keys \
-H 'content-type: application/json' \
-d '{"email":"you@example.com"}'
{
"key": "aw_live_qh4t2m…",
"tier": "free",
"monthly_limit": 100,
"created": "2026-08-09T10:04:11.482Z"
}
Save your API key now. We show it only once. Keep it in a password manager or your application’s secret store. See how to replace a key or request help with a lost key.
Put the key in a shell variable. Every step below reads it from there.
export ATTESTWIRE_API_KEY=aw_live_... # paste your own key here
2. Validate an invoice
First write the invoice to a file. Steps 3 and 4 use the same file, so keep it. This is a complete, valid XRechnung invoice — see the invoice model for what each field means.
cat > invoice.json <<'JSON'
{
"profile": "xrechnung-ubl",
"invoiceNumber": "INV-2026-0042",
"issueDate": "2026-08-09",
"dueDate": "2026-09-08",
"deliveryDate": "2026-07-31",
"currency": "EUR",
"buyerReference": "04011000-1234512345-06",
"seller": {
"name": "Nordwind Software GmbH",
"vatId": "DE123456789",
"address": {
"line1": "Hafenstraße 12",
"city": "Hamburg",
"postalCode": "20095",
"countryCode": "DE"
},
"electronicAddress": {
"schemeId": "9930",
"value": "DE123456789"
},
"contact": {
"name": "Buchhaltung",
"phone": "+49 40 1234567",
"email": "rechnungen@nordwind.example"
}
},
"buyer": {
"name": "Stadt Musterstadt",
"address": {
"line1": "Rathausplatz 1",
"city": "Musterstadt",
"postalCode": "80331",
"countryCode": "DE"
},
"electronicAddress": {
"schemeId": "0204",
"value": "04011000-1234512345-06"
}
},
"payment": {
"meansCode": "58",
"iban": "DE02120300000000202051",
"accountName": "Nordwind Software GmbH"
},
"paymentTerms": "Net 30 days",
"lines": [
{
"id": "1",
"description": "Implementation services, July 2026",
"quantity": 12,
"unitCode": "HUR",
"unitPrice": 145,
"vatCategory": "S",
"vatRate": 19
}
]
}
JSON
Not in a Unix shell? Save the JSON between the two JSON lines as
invoice.json.
Then post the file, from the command line or from your own code. Every example
reads the key from ATTESTWIRE_API_KEY — or, when it is not set,
sends the public demo key — and sends the file as the
request body. curl prints the JSON response; the programs print
valid and then each finding with its fix:
curl
curl -X POST https://api.attestwire.com/v1/validate \
-H "authorization: Bearer ${ATTESTWIRE_API_KEY:-demo}" \
-H 'content-type: application/json' \
--data-binary @invoice.json
Node.js
// Node 18+, no dependencies. Save as validate.mjs, then: node validate.mjs
import { readFile } from 'node:fs/promises';
const res = await fetch('https://api.attestwire.com/v1/validate', {
method: 'POST',
headers: {
authorization: 'Bearer ' + (process.env.ATTESTWIRE_API_KEY || 'demo'),
'content-type': 'application/json',
},
body: await readFile('invoice.json'),
});
const result = await res.json();
if (!res.ok) throw new Error(res.status + ' ' + result.error + ': ' + result.message);
console.log('valid:', result.valid);
for (const f of [...result.errors, ...result.warnings, ...result.information]) {
console.log(f.severity, f.rule, f.message);
console.log(' fix:', f.fix);
}
Python
# Python 3.8+ with requests (pip install requests)
import os
import requests
with open("invoice.json", "rb") as body:
res = requests.post(
"https://api.attestwire.com/v1/validate",
headers={
"Authorization": "Bearer " + (os.environ.get("ATTESTWIRE_API_KEY") or "demo"),
"Content-Type": "application/json",
},
data=body.read(),
timeout=30,
)
result = res.json()
if res.status_code != 200:
raise SystemExit(f"{res.status_code} {result['error']}: {result['message']}")
print("valid:", result["valid"])
for finding in result["errors"] + result["warnings"] + result["information"]:
print(finding["severity"], finding["rule"], finding["message"])
print(" fix:", finding["fix"])
PHP
<?php
// PHP 7.4+ with ext-curl, no framework. Run: php validate.php
$ch = curl_init('https://api.attestwire.com/v1/validate');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . (getenv('ATTESTWIRE_API_KEY') ?: 'demo'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => file_get_contents('invoice.json'),
CURLOPT_RETURNTRANSFER => true,
// Any User-Agent works; this one lets our usage counts tell PHP apart.
CURLOPT_USERAGENT => 'attestwire-example-php/1',
]);
$body = curl_exec($ch);
if ($body === false) {
fwrite(STDERR, curl_error($ch) . "\n");
exit(1);
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$result = json_decode($body, true);
if ($status !== 200) {
fwrite(STDERR, "$status {$result['error']}: {$result['message']}\n");
exit(1);
}
echo 'valid: ', $result['valid'] ? 'true' : 'false', "\n";
foreach (array_merge($result['errors'], $result['warnings'], $result['information']) as $f) {
echo "{$f['severity']} {$f['rule']} {$f['message']}\n";
echo " fix: {$f['fix']}\n";
}
Java
// JDK 11+ and Gson (com.google.code.gson:gson), since the JDK has no JSON parser.
// Run: java -cp gson.jar Validate.java
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Path;
public class Validate {
public static void main(String[] args) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.attestwire.com/v1/validate"))
.header("Authorization", "Bearer " + System.getenv().getOrDefault("ATTESTWIRE_API_KEY", "demo"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofFile(Path.of("invoice.json")))
.build();
HttpResponse<String> response =
HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
JsonObject result = JsonParser.parseString(response.body()).getAsJsonObject();
if (response.statusCode() != 200) {
System.err.println(response.statusCode() + " " + result.get("error").getAsString()
+ ": " + result.get("message").getAsString());
System.exit(1);
}
System.out.println("valid: " + result.get("valid").getAsBoolean());
for (String severity : new String[] {"errors", "warnings", "information"}) {
for (JsonElement e : result.getAsJsonArray(severity)) {
JsonObject f = e.getAsJsonObject();
System.out.println(f.get("severity").getAsString() + " "
+ f.get("rule").getAsString() + " " + f.get("message").getAsString());
System.out.println(" fix: " + f.get("fix").getAsString());
}
}
}
}
C#
// .NET 6+. In a console project (dotnet new console) this is the whole Program.cs.
using System.Net.Http.Headers;
using System.Text.Json;
using var http = new HttpClient();
// Any User-Agent works; HttpClient sends none unless one is set.
http.DefaultRequestHeaders.UserAgent.ParseAdd("attestwire-example-csharp/1");
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.attestwire.com/v1/validate");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ATTESTWIRE_API_KEY") ?? "demo");
request.Content = new ByteArrayContent(File.ReadAllBytes("invoice.json"));
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
using var response = await http.SendAsync(request);
using var result = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var root = result.RootElement;
if (!response.IsSuccessStatusCode)
{
Console.Error.WriteLine((int)response.StatusCode + " " + root.GetProperty("error").GetString()
+ ": " + root.GetProperty("message").GetString());
return 1;
}
Console.WriteLine("valid: " + root.GetProperty("valid").GetBoolean());
foreach (var severity in new[] { "errors", "warnings", "information" })
{
foreach (var f in root.GetProperty(severity).EnumerateArray())
{
Console.WriteLine(f.GetProperty("severity").GetString() + " "
+ f.GetProperty("rule").GetString() + " " + f.GetProperty("message").GetString());
Console.WriteLine(" fix: " + f.GetProperty("fix").GetString());
}
}
return 0;
Go
// Go 1.16+, standard library only. Run: go run validate.go
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
)
type finding struct {
Severity, Rule, Message, Fix string
}
// apiKey is your key, or the public demo key when ATTESTWIRE_API_KEY is not
// set: /v1/validate and /v1/generate only, a few calls a minute.
func apiKey() string {
if key := os.Getenv("ATTESTWIRE_API_KEY"); key != "" {
return key
}
return "demo"
}
func main() {
invoice, err := os.ReadFile("invoice.json")
if err != nil {
log.Fatal(err)
}
req, err := http.NewRequest("POST", "https://api.attestwire.com/v1/validate", bytes.NewReader(invoice))
if err != nil {
log.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+apiKey())
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
var result struct {
Valid bool
Errors, Warnings, Information []finding
Error, Message string
}
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
log.Fatal(err)
}
if res.StatusCode != http.StatusOK {
log.Fatalf("%d %s: %s", res.StatusCode, result.Error, result.Message)
}
fmt.Println("valid:", result.Valid)
for _, f := range append(append(result.Errors, result.Warnings...), result.Information...) {
fmt.Println(f.Severity, f.Rule, f.Message)
fmt.Println(" fix:", f.Fix)
}
}
The response. There are no findings, so the programs print only valid:
{
"valid": true,
"profile": "xrechnung-ubl",
"errors": [],
"warnings": [],
"information": []
}
3. See a teaching error
Now delete the buyerReference line from invoice.json
and send it again. The German XRechnung rules reject it, and the response says
why in full:
{
"valid": false,
"profile": "xrechnung-ubl",
"errors": [
{
"rule": "BR-DE-15",
"field": "BT-10",
"severity": "fatal",
"message": "XRechnung requires a buyer reference (BT-10). For German public-sector buyers this is the Leitweg-ID; business buyers may supply any reference, but the field must be present.",
"fix": "Ask your client for their Leitweg-ID (public sector) or an order/customer reference, and set buyerReference.",
"example": "\"buyerReference\": \"04011000-1234512345-06\"",
"xpath": "/ubl:Invoice/cbc:BuyerReference",
"docsUrl": "https://attestwire.com/rules/BR-DE-15"
}
],
"warnings": [],
"information": []
}
That is the whole payload, not a shortened one. A person and an agent can both
act on it. fix is an instruction, not a restatement of
message. For a JSON invoice, xpath is the field's place in the UBL this API would generate. For an XML document it is the element's path in your file, in its own syntax (CII paths for a CII file), and each rule finding also carries location: the line and column of that element. When the element is missing, or several could be meant, location.exact is false and the location is the nearest enclosing element that is there.
rule is the same identifier the KoSIT validator prints, so you can
compare an official run against ours.
4. Generate the XML
Put the buyerReference line back, then ask for the document.
?format=xml returns the file itself instead of a JSON envelope.
Each example saves it as invoice.xml, or prints the findings
and leaves no file behind when the invoice cannot be generated:
curl
curl -X POST "https://api.attestwire.com/v1/generate?format=xml" \
-H "authorization: Bearer ${ATTESTWIRE_API_KEY:-demo}" \
-H 'content-type: application/json' \
--data-binary @invoice.json \
--fail-with-body -o invoice.xml || { cat invoice.xml; rm -f invoice.xml; }
Node.js
// Node 18+, no dependencies. Save as generate.mjs, then: node generate.mjs
import { readFile, writeFile } from 'node:fs/promises';
const res = await fetch('https://api.attestwire.com/v1/generate?format=xml', {
method: 'POST',
headers: {
authorization: 'Bearer ' + (process.env.ATTESTWIRE_API_KEY || 'demo'),
'content-type': 'application/json',
},
body: await readFile('invoice.json'),
});
if (res.status !== 200) {
// 422: fatal findings, the same ones /v1/validate returns. Nothing was
// generated or charged. Any other status carries {error, message}.
const body = await res.json();
if (!body.errors) throw new Error(res.status + ' ' + body.error + ': ' + body.message);
for (const f of body.errors) {
console.error(f.severity, f.rule, f.message);
console.error(' fix:', f.fix);
}
process.exit(1);
}
await writeFile('invoice.xml', Buffer.from(await res.arrayBuffer()));
console.log('wrote invoice.xml');
Python
# Python 3.8+ with requests (pip install requests)
import os
import requests
with open("invoice.json", "rb") as body:
res = requests.post(
"https://api.attestwire.com/v1/generate?format=xml",
headers={
"Authorization": "Bearer " + (os.environ.get("ATTESTWIRE_API_KEY") or "demo"),
"Content-Type": "application/json",
},
data=body.read(),
timeout=30,
)
if res.status_code != 200:
# 422: fatal findings, the same ones /v1/validate returns. Nothing was
# generated or charged. Any other status carries {error, message}.
result = res.json()
if "errors" not in result:
raise SystemExit(f"{res.status_code} {result['error']}: {result['message']}")
for finding in result["errors"]:
print(finding["severity"], finding["rule"], finding["message"])
print(" fix:", finding["fix"])
raise SystemExit(1)
with open("invoice.xml", "wb") as out:
out.write(res.content)
print("wrote invoice.xml")
PHP
<?php
// PHP 7.4+ with ext-curl, no framework. Run: php generate.php
$ch = curl_init('https://api.attestwire.com/v1/generate?format=xml');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . (getenv('ATTESTWIRE_API_KEY') ?: 'demo'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => file_get_contents('invoice.json'),
CURLOPT_RETURNTRANSFER => true,
// Any User-Agent works; this one lets our usage counts tell PHP apart.
CURLOPT_USERAGENT => 'attestwire-example-php/1',
]);
$body = curl_exec($ch);
if ($body === false) {
fwrite(STDERR, curl_error($ch) . "\n");
exit(1);
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status !== 200) {
// 422: fatal findings, the same ones /v1/validate returns. Nothing was
// generated or charged. Any other status carries {error, message}.
$result = json_decode($body, true);
if (!isset($result['errors'])) {
fwrite(STDERR, "$status {$result['error']}: {$result['message']}\n");
exit(1);
}
foreach ($result['errors'] as $f) {
fwrite(STDERR, "{$f['severity']} {$f['rule']} {$f['message']}\n fix: {$f['fix']}\n");
}
exit(1);
}
file_put_contents('invoice.xml', $body);
echo "wrote invoice.xml\n";
Java
// JDK 11+ and Gson (com.google.code.gson:gson), since the JDK has no JSON parser.
// Run: java -cp gson.jar Generate.java
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public class Generate {
public static void main(String[] args) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.attestwire.com/v1/generate?format=xml"))
.header("Authorization", "Bearer " + System.getenv().getOrDefault("ATTESTWIRE_API_KEY", "demo"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofFile(Path.of("invoice.json")))
.build();
HttpResponse<byte[]> response =
HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
// 422: fatal findings, the same ones /v1/validate returns. Nothing was
// generated or charged. Any other status carries {error, message}.
JsonObject result = JsonParser.parseString(
new String(response.body(), StandardCharsets.UTF_8)).getAsJsonObject();
if (!result.has("errors")) {
System.err.println(response.statusCode() + " " + result.get("error").getAsString()
+ ": " + result.get("message").getAsString());
System.exit(1);
}
for (JsonElement e : result.getAsJsonArray("errors")) {
JsonObject f = e.getAsJsonObject();
System.err.println(f.get("severity").getAsString() + " "
+ f.get("rule").getAsString() + " " + f.get("message").getAsString());
System.err.println(" fix: " + f.get("fix").getAsString());
}
System.exit(1);
}
Files.write(Path.of("invoice.xml"), response.body());
System.out.println("wrote invoice.xml");
}
}
C#
// .NET 6+. In a console project (dotnet new console) this is the whole Program.cs.
using System.Net.Http.Headers;
using System.Text.Json;
using var http = new HttpClient();
// Any User-Agent works; HttpClient sends none unless one is set.
http.DefaultRequestHeaders.UserAgent.ParseAdd("attestwire-example-csharp/1");
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.attestwire.com/v1/generate?format=xml");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ATTESTWIRE_API_KEY") ?? "demo");
request.Content = new ByteArrayContent(File.ReadAllBytes("invoice.json"));
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
using var response = await http.SendAsync(request);
if ((int)response.StatusCode != 200)
{
// 422: fatal findings, the same ones /v1/validate returns. Nothing was
// generated or charged. Any other status carries {error, message}.
using var result = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var root = result.RootElement;
if (!root.TryGetProperty("errors", out var errors))
{
Console.Error.WriteLine((int)response.StatusCode + " " + root.GetProperty("error").GetString()
+ ": " + root.GetProperty("message").GetString());
return 1;
}
foreach (var f in errors.EnumerateArray())
{
Console.Error.WriteLine(f.GetProperty("severity").GetString() + " "
+ f.GetProperty("rule").GetString() + " " + f.GetProperty("message").GetString());
Console.Error.WriteLine(" fix: " + f.GetProperty("fix").GetString());
}
return 1;
}
File.WriteAllBytes("invoice.xml", await response.Content.ReadAsByteArrayAsync());
Console.WriteLine("wrote invoice.xml");
return 0;
Go
// Go 1.16+, standard library only. Run: go run generate.go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
)
// apiKey is your key, or the public demo key when ATTESTWIRE_API_KEY is not
// set: /v1/validate and /v1/generate only, a few calls a minute.
func apiKey() string {
if key := os.Getenv("ATTESTWIRE_API_KEY"); key != "" {
return key
}
return "demo"
}
func main() {
invoice, err := os.ReadFile("invoice.json")
if err != nil {
log.Fatal(err)
}
req, err := http.NewRequest("POST", "https://api.attestwire.com/v1/generate?format=xml", bytes.NewReader(invoice))
if err != nil {
log.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+apiKey())
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
log.Fatal(err)
}
if res.StatusCode != http.StatusOK {
// 422: fatal findings, the same ones /v1/validate returns. Nothing was
// generated or charged. Any other status carries {error, message}.
var result struct {
Errors []struct{ Severity, Rule, Message, Fix string }
Error, Message string
}
if err := json.Unmarshal(body, &result); err != nil {
log.Fatal(err)
}
if len(result.Errors) == 0 {
log.Fatalf("%d %s: %s", res.StatusCode, result.Error, result.Message)
}
for _, f := range result.Errors {
fmt.Fprintln(os.Stderr, f.Severity, f.Rule, f.Message)
fmt.Fprintln(os.Stderr, " fix:", f.Fix)
}
os.Exit(1)
}
if err := os.WriteFile("invoice.xml", body, 0o644); err != nil {
log.Fatal(err)
}
fmt.Println("wrote invoice.xml")
}
invoice.xml now starts like this:
<?xml version="1.0" encoding="UTF-8"?>
<ubl:Invoice xmlns:ubl="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" …>
<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0</cbc:CustomizationID>
<cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>
<cbc:ID>INV-2026-0042</cbc:ID>
<cbc:IssueDate>2026-08-09</cbc:IssueDate>
…
Generation always validates first. An invoice with a fatal error returns
422 with the same teaching errors and no XML, and it costs you
nothing — so fix the errors and retry for free.
Generation writes both syntaxes, and the profile chooses.
Ask for xrechnung-cii or facturx-en16931
and you get CII (Cross Industry Invoice), the second syntax EN 16931 is
written in. Ask for anything else and you get UBL 2.1, the Universal
Business Language, the syntax behind XRechnung UBL and Peppol BIS 3. The
response tells you which in its syntax field. See the
profile matrix.
CII output is XML, not a PDF. Factur-X and ZUGFeRD files
are CII XML inside a PDF/A-3 container. The command above gives you the XML:
no container, no factur-x.xml attachment, no
/AFRelationship, so a facturx-en16931
XML response is not a Factur-X document and saving it as one will not make
it work. ?format=pdf returns the
Factur-X / ZUGFeRD PDF instead (a watermarked
preview on the free plan).
And the two CII profiles are not equally evidenced. The
generator's xrechnung-cii fixture documents are run
through the official KoSIT validator on release and accepted. The
facturx-en16931 ones are not: that profile's BT-24 is
the core EN 16931 one, which matches no XRechnung scenario, so KoSIT returns
“no scenario matched” instead of a verdict — and no verdict is
not a pass. Neither case is a check on your document: this endpoint
sends nothing to KoSIT.
5. Validate an XML file
To check a file rather than the JSON model — the invoice.xml step 4 wrote, or one
a customer's platform rejected — send its bytes as the request body with
content-type: application/xml. Not wrapped in JSON and not base64:
the file as it is. UBL and CII both work, and the response says which it read.
curl
curl -X POST https://api.attestwire.com/v1/validate \
-H "authorization: Bearer ${ATTESTWIRE_API_KEY:-demo}" \
-H 'content-type: application/xml' \
--data-binary @invoice.xml
Node.js
// Node 18+, no dependencies. Save as validate-xml.mjs, then: node validate-xml.mjs
import { readFile } from 'node:fs/promises';
const res = await fetch('https://api.attestwire.com/v1/validate', {
method: 'POST',
headers: {
authorization: 'Bearer ' + (process.env.ATTESTWIRE_API_KEY || 'demo'),
'content-type': 'application/xml',
},
body: await readFile('invoice.xml'), // the file's own bytes: not JSON, not base64
});
const result = await res.json();
if (!res.ok) throw new Error(res.status + ' ' + result.error + ': ' + result.message);
console.log('valid:', result.valid, 'syntax:', result.syntax);
for (const f of [...result.errors, ...result.warnings, ...result.information]) {
// A finding about the whole file (its rule starts AW-) has no location.
const at = f.location ? 'line ' + f.location.line : 'whole file';
console.log(f.severity, f.rule, at + ':', f.message);
console.log(' fix:', f.fix);
}
Python
# Python 3.8+ with requests (pip install requests)
import os
import requests
with open("invoice.xml", "rb") as body:
res = requests.post(
"https://api.attestwire.com/v1/validate",
headers={
"Authorization": "Bearer " + (os.environ.get("ATTESTWIRE_API_KEY") or "demo"),
"Content-Type": "application/xml",
},
data=body.read(), # the file's own bytes: not JSON, not base64
timeout=30,
)
result = res.json()
if res.status_code != 200:
raise SystemExit(f"{res.status_code} {result['error']}: {result['message']}")
print("valid:", result["valid"], "syntax:", result["syntax"])
for finding in result["errors"] + result["warnings"] + result["information"]:
# A finding about the whole file (its rule starts AW-) has no location.
location = finding.get("location")
at = f"line {location['line']}" if location else "whole file"
print(finding["severity"], finding["rule"], f"{at}:", finding["message"])
print(" fix:", finding["fix"])
PHP
<?php
// PHP 7.4+ with ext-curl, no framework. Run: php validate-xml.php
$ch = curl_init('https://api.attestwire.com/v1/validate');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . (getenv('ATTESTWIRE_API_KEY') ?: 'demo'),
'Content-Type: application/xml',
],
// The file's own bytes: not JSON, not base64.
CURLOPT_POSTFIELDS => file_get_contents('invoice.xml'),
CURLOPT_RETURNTRANSFER => true,
// Any User-Agent works; this one lets our usage counts tell PHP apart.
CURLOPT_USERAGENT => 'attestwire-example-php/1',
]);
$body = curl_exec($ch);
if ($body === false) {
fwrite(STDERR, curl_error($ch) . "\n");
exit(1);
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$result = json_decode($body, true);
if ($status !== 200) {
fwrite(STDERR, "$status {$result['error']}: {$result['message']}\n");
exit(1);
}
echo 'valid: ', $result['valid'] ? 'true' : 'false', ', syntax: ', $result['syntax'], "\n";
foreach (array_merge($result['errors'], $result['warnings'], $result['information']) as $f) {
// A finding about the whole file (its rule starts AW-) has no location.
$at = isset($f['location']) ? "line {$f['location']['line']}" : 'whole file';
echo "{$f['severity']} {$f['rule']} {$at}: {$f['message']}\n";
echo " fix: {$f['fix']}\n";
}
Java
// JDK 11+ and Gson (com.google.code.gson:gson), since the JDK has no JSON parser.
// Run: java -cp gson.jar ValidateXml.java
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Path;
public class ValidateXml {
public static void main(String[] args) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.attestwire.com/v1/validate"))
.header("Authorization", "Bearer " + System.getenv().getOrDefault("ATTESTWIRE_API_KEY", "demo"))
.header("Content-Type", "application/xml")
// The file's own bytes: not JSON, not base64.
.POST(HttpRequest.BodyPublishers.ofFile(Path.of("invoice.xml")))
.build();
HttpResponse<String> response =
HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
JsonObject result = JsonParser.parseString(response.body()).getAsJsonObject();
if (response.statusCode() != 200) {
System.err.println(response.statusCode() + " " + result.get("error").getAsString()
+ ": " + result.get("message").getAsString());
System.exit(1);
}
System.out.println("valid: " + result.get("valid").getAsBoolean()
+ ", syntax: " + result.get("syntax").getAsString());
for (String severity : new String[] {"errors", "warnings", "information"}) {
for (JsonElement e : result.getAsJsonArray(severity)) {
JsonObject f = e.getAsJsonObject();
// A finding about the whole file (its rule starts AW-) has no location.
String at = f.has("location")
? "line " + f.getAsJsonObject("location").get("line").getAsInt()
: "whole file";
System.out.println(f.get("severity").getAsString() + " " + f.get("rule").getAsString()
+ " " + at + ": " + f.get("message").getAsString());
System.out.println(" fix: " + f.get("fix").getAsString());
}
}
}
}
C#
// .NET 6+. In a console project (dotnet new console) this is the whole Program.cs.
using System.Net.Http.Headers;
using System.Text.Json;
using var http = new HttpClient();
// Any User-Agent works; HttpClient sends none unless one is set.
http.DefaultRequestHeaders.UserAgent.ParseAdd("attestwire-example-csharp/1");
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.attestwire.com/v1/validate");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ATTESTWIRE_API_KEY") ?? "demo");
// The file's own bytes: not JSON, not base64.
request.Content = new ByteArrayContent(File.ReadAllBytes("invoice.xml"));
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/xml");
using var response = await http.SendAsync(request);
using var result = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var root = result.RootElement;
if (!response.IsSuccessStatusCode)
{
Console.Error.WriteLine((int)response.StatusCode + " " + root.GetProperty("error").GetString()
+ ": " + root.GetProperty("message").GetString());
return 1;
}
Console.WriteLine("valid: " + root.GetProperty("valid").GetBoolean()
+ ", syntax: " + root.GetProperty("syntax").GetString());
foreach (var severity in new[] { "errors", "warnings", "information" })
{
foreach (var f in root.GetProperty(severity).EnumerateArray())
{
// A finding about the whole file (its rule starts AW-) has no location.
var at = f.TryGetProperty("location", out var location)
? "line " + location.GetProperty("line").GetInt32()
: "whole file";
Console.WriteLine(f.GetProperty("severity").GetString() + " " + f.GetProperty("rule").GetString()
+ " " + at + ": " + f.GetProperty("message").GetString());
Console.WriteLine(" fix: " + f.GetProperty("fix").GetString());
}
}
return 0;
Go
// Go 1.16+, standard library only. Run: go run validate_xml.go
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
)
type finding struct {
Severity, Rule, Message, Fix string
Location *struct{ Line int }
}
// apiKey is your key, or the public demo key when ATTESTWIRE_API_KEY is not
// set: /v1/validate and /v1/generate only, a few calls a minute.
func apiKey() string {
if key := os.Getenv("ATTESTWIRE_API_KEY"); key != "" {
return key
}
return "demo"
}
func main() {
// The file's own bytes: not JSON, not base64.
document, err := os.ReadFile("invoice.xml")
if err != nil {
log.Fatal(err)
}
req, err := http.NewRequest("POST", "https://api.attestwire.com/v1/validate", bytes.NewReader(document))
if err != nil {
log.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+apiKey())
req.Header.Set("Content-Type", "application/xml")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
var result struct {
Valid bool
Syntax, Error, Message string
Errors, Warnings, Information []finding
}
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
log.Fatal(err)
}
if res.StatusCode != http.StatusOK {
log.Fatalf("%d %s: %s", res.StatusCode, result.Error, result.Message)
}
fmt.Println("valid:", result.Valid, "syntax:", result.Syntax)
for _, f := range append(append(result.Errors, result.Warnings...), result.Information...) {
// A finding about the whole file (its rule starts AW-) has no location.
at := "whole file"
if f.Location != nil {
at = fmt.Sprintf("line %d", f.Location.Line)
}
fmt.Printf("%s %s %s: %s\n", f.Severity, f.Rule, at, f.Message)
fmt.Println(" fix:", f.Fix)
}
}
The answer carries the same valid and the same three lists of
findings, plus syntax, and every rule finding has the
location of its element in your file: a line and a column.
Validating a file you already have covers the rest of the
response and which files are refused.
Authentication
A bearer token on every metered endpoint. Keys look like
aw_live_ followed by 40 characters.
authorization: Bearer aw_live_qh4t2m…
The one exception is the public demo key,
demo, for trying /v1/validate and
/v1/generate before you have a key of your own.
Missing or unknown keys get 401 with a link back to this page.
Send the key in the header only — never in a query string, where it would land
in logs and browser history. Keys are secrets: they belong on your server, not
in a browser or a mobile app.
CORS is off almost everywhere, deliberately. No metered
endpoint sends Access-Control-Allow-Origin to your origin, so
front-end code that ships a key to the browser to validate invoices with it
fails in the browser — which is the point. Five routes are exempt, and none of
them lets a page use a key it was not given:
GET /v1/healthandGET /openapi.json— public and unauthenticated, so they answer any origin (*).POST /v1/keysandPOST /v1/billing/checkout— restricted tohttps://attestwire.comalone, for the signup and upgrade forms on that site.content-typeis the only permitted request header; every other origin gets no CORS headers at all and is blocked by the browser.POST /v1/recipient— restricted tohttps://attestwire.comalone, for the keyless Peppol ID check on that site, which sends the public demo key.authorizationandcontent-typeare the permitted request headers; every other origin gets no CORS headers at all.
Browser signup is bounded by exactly the limits a script faces: five keys per IP per UTC day and one free key per email address, unchanged — see rate limits. Opening the form to the browser widens who can reach the endpoint conveniently, not what the endpoint will do.
Rotating a key
Leaked a key into a git commit, a CI log, a screenshot? Replace it yourself, immediately, without opening a support ticket:
curl -X POST https://api.attestwire.com/v1/keys/rotate \
-H "authorization: Bearer aw_live_YOUR_CURRENT_KEY"
The call is authenticated by the key you are replacing — that is the proof of ownership — and it is free: rotating consumes no documents, because a security action you might put off to save quota is a security action you will not take.
Everything follows the new key, atomically:
| Carries over | Detail |
|---|---|
| Your plan | Tier, limit and any past_due flag are unchanged. |
| This month's usage | The counter follows you. Rotating does not hand you a fresh allowance, and it does not lose the documents you have already used. |
| Your subscription | The Paddle link is repointed at the new key, so renewals, plan switches and cancellations keep landing on the right account. Billing events already in flight are re-routed too. |
| Your signup date | created stays the original one; rotated_at is new. |
{
"key": "aw_live_…",
"tier": "starter",
"monthly_limit": 2500,
"period": "2026-08",
"used": 3,
"remaining": 2497,
"rotated_at": "2026-08-09T12:00:00.000Z",
"warning": "Store this key now. It is shown once and cannot be recovered. …"
}
The new key is shown exactly once, the same as at signup, and
the old key stops working the instant that response is generated — it answers
410 with error: "key_rotated" from then on. Deploy
the new key before you rotate, or rotate during a window you can watch.
Rotations are limited to 3 per key per UTC day,
and the limit travels with the key, so chaining rotations does not reset it.
Two rotate calls sent at the same time cannot both succeed: one gets the new
key, the other gets 409 rotation_already_done.
If you have lost the key entirely there is no self-serve rotation, by design — a rotation triggered by anything other than the key itself (an email link, say) would let anyone who knows your address take your account over. What is on offer instead depends on the tier, and on a free key the answer is still self-serve, just not a rotation:
- If you are on a paid plan and the key is gone, email hello@attestwire.com from the address on your Paddle billing receipt. We check the request against Paddle's record of your subscription rather than against the receipt itself — a receipt can be forwarded, so attaching one proves only that somebody has a copy of it — and the replacement is delivered one way only: as a reply to the billing address on file, whoever did the asking. Whoever handles it retires the old key and issues the new one.
- We cannot recover a lost free key — not as a matter of policy, but because we never collected anything that could prove it was yours: a free signup address is never verified, and the key itself only ever existed in one HTTP response. The practical answer is faster than support anyway. The limit is one free key per email address, so a different address gets you a working free key on the spot, and a plus-alias such as you+attestwire@example.com counts. That limit keeps casual signups to one key each; it is housekeeping rather than a security control, and it was never meant to trap you. If you would rather we were able to rescue you, that is part of what a paid plan buys: Paddle holds a billing identity we can check.
The lost key itself is never recoverable: we store its SHA-256 hash and nothing else, so there is nothing on our side to show you again and every route above ends in a replacement rather than the old key returned. That is the same property that means a breach of our key store hands over a list of hashes, and that no support conversation can leak your credential, because the support side of it has never held one.
Endpoints
POST/v1/validate 1 document
Validates an InvoiceInput against EN 16931 and the CIUS named by
profile. Returns 200 whether or not the invoice is
valid — a rejected invoice is a successful call, and costs one document.
valid is true when there are no fatal errors;
neither warnings nor information findings make it false. A body the engine
cannot read at all returns 400 and consumes nothing: we charge
for work done, not for requests received. A field of the wrong type is
usually not that: the invoice gets a verdict, charged like any other, with
an ATW-INPUT-TYPE finding naming the problem.
It also accepts a file. Send content-type: application/xml for
XML, or application/pdf for a Factur-X or ZUGFeRD PDF, and the
body is read as an invoice document — UBL 2.1 or CII, in the encoding it
declares — instead of as JSON. See
Validating a file you already have.
POST/v1/parse 1 document
Reads an invoice document — XML, or the XML attached to a Factur-X or
ZUGFeRD PDF — into the JSON model and stops there. Same reader as the
document half of /v1/validate, and the invoice it
returns is exactly the InvoiceInput this API accepts everywhere
else — so a file from a supplier becomes something you can edit, store,
validate or re-emit in the other syntax without writing a line of mapping
code. No verdict is given. See
Reading a file into the model.
POST/v1/recipient 1 document
Before you invoice a buyer: from its country and one identifier, how to reach
it and what to put on the invoice — channel, format, BT-24, the BT-49
electronic address, whether BT-10 is required and, given your own country, the
vatScenario with the VIES result as evidence. See
Profiling a recipient.
The Factur-X / ZUGFeRD PDF
POST /v1/generate?format=pdf with "profile": "facturx-en16931"
returns a Factur-X / ZUGFeRD PDF (EN 16931 profile) as application/pdf: an A4
invoice we draw from your JSON — parties, lines, VAT breakdown, totals,
payment details — written as PDF/A-3B, with the CII XML embedded as
factur-x.xml under /AFRelationship /Alternative
and the Factur-X XMP metadata (fx:ConformanceLevel EN 16931).
Every amount on the page comes from the same computation as the XML, so the two
cannot disagree by a cent.
From the quickstart's invoice.json, with the profile the PDF carries and
three of the page options — German labels, the payment
QR code and a payment link. Each example asks for ?format=pdf, saves
invoice.pdf, and says when it is the watermarked preview:
curl
# The PDF carries the facturx-en16931 profile; the quickstart invoice says xrechnung-ubl.
sed 's/"xrechnung-ubl"/"facturx-en16931"/' invoice.json > invoice-pdf.json
curl -X POST "https://api.attestwire.com/v1/generate?format=pdf&language=de&payment_qr=true" \
-H "authorization: Bearer ${ATTESTWIRE_API_KEY:-demo}" \
-H 'content-type: application/json' \
--data-binary @invoice-pdf.json \
--fail-with-body -o invoice.pdf || { cat invoice.pdf; rm -f invoice.pdf; }
Node.js
// Node 18+, no dependencies. Save as generate-pdf.mjs, then: node generate-pdf.mjs
import { readFile, writeFile } from 'node:fs/promises';
const invoice = JSON.parse(await readFile('invoice.json', 'utf8'));
invoice.profile = 'facturx-en16931'; // the one profile the PDF carries
const res = await fetch('https://api.attestwire.com/v1/generate?format=pdf', {
method: 'POST',
headers: {
authorization: 'Bearer ' + (process.env.ATTESTWIRE_API_KEY || 'demo'),
'content-type': 'application/json',
},
body: JSON.stringify({
invoice,
// The page's options. A logo goes here too, as a PNG or JPEG data URL.
pdf: { language: 'de', paymentQr: true, paymentLink: 'https://example.com/pay/INV-2026-0042' },
}),
});
if (res.status !== 200) {
// 422: fatal findings, the same ones /v1/validate returns. Nothing was
// generated or charged. Any other status carries {error, message}.
const body = await res.json();
if (!body.errors) throw new Error(res.status + ' ' + body.error + ': ' + body.message);
for (const f of body.errors) {
console.error(f.severity, f.rule, f.message);
console.error(' fix:', f.fix);
}
process.exit(1);
}
await writeFile('invoice.pdf', Buffer.from(await res.arrayBuffer()));
// The free plan and the demo key get a watermarked preview, and say so here.
console.log('wrote invoice.pdf' + (res.headers.get('attestwire-preview') ? ' (a watermarked preview)' : ''));
Python
# Python 3.8+ with requests (pip install requests)
import json
import os
import requests
with open("invoice.json", "rb") as f:
invoice = json.load(f)
invoice["profile"] = "facturx-en16931" # the one profile the PDF carries
res = requests.post(
"https://api.attestwire.com/v1/generate?format=pdf",
headers={
"Authorization": "Bearer " + (os.environ.get("ATTESTWIRE_API_KEY") or "demo"),
"Content-Type": "application/json",
},
data=json.dumps({
"invoice": invoice,
# The page's options. A logo goes here too, as a PNG or JPEG data URL.
"pdf": {"language": "de", "paymentQr": True, "paymentLink": "https://example.com/pay/INV-2026-0042"},
}),
timeout=30,
)
if res.status_code != 200:
# 422: fatal findings, the same ones /v1/validate returns. Nothing was
# generated or charged. Any other status carries {error, message}.
result = res.json()
if "errors" not in result:
raise SystemExit(f"{res.status_code} {result['error']}: {result['message']}")
for finding in result["errors"]:
print(finding["severity"], finding["rule"], finding["message"])
print(" fix:", finding["fix"])
raise SystemExit(1)
with open("invoice.pdf", "wb") as out:
out.write(res.content)
# The free plan and the demo key get a watermarked preview, and say so here.
preview = " (a watermarked preview)" if res.headers.get("Attestwire-Preview") else ""
print("wrote invoice.pdf" + preview)
PHP
<?php
// PHP 7.4+ with ext-curl, no framework. Run: php generate-pdf.php
$invoice = json_decode(file_get_contents('invoice.json'), true);
$invoice['profile'] = 'facturx-en16931'; // the one profile the PDF carries
$body = json_encode([
'invoice' => $invoice,
// The page's options. A logo goes here too, as a PNG or JPEG data URL.
'pdf' => ['language' => 'de', 'paymentQr' => true, 'paymentLink' => 'https://example.com/pay/INV-2026-0042'],
]);
$ch = curl_init('https://api.attestwire.com/v1/generate?format=pdf');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . (getenv('ATTESTWIRE_API_KEY') ?: 'demo'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
// Any User-Agent works; this one lets our usage counts tell PHP apart.
CURLOPT_USERAGENT => 'attestwire-example-php/1',
]);
$pdf = curl_exec($ch);
if ($pdf === false) {
fwrite(STDERR, curl_error($ch) . "\n");
exit(1);
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status !== 200) {
// 422: fatal findings, the same ones /v1/validate returns. Nothing was
// generated or charged. Any other status carries {error, message}.
$result = json_decode($pdf, true);
if (!isset($result['errors'])) {
fwrite(STDERR, "$status {$result['error']}: {$result['message']}\n");
exit(1);
}
foreach ($result['errors'] as $f) {
fwrite(STDERR, "{$f['severity']} {$f['rule']} {$f['message']}\n fix: {$f['fix']}\n");
}
exit(1);
}
file_put_contents('invoice.pdf', $pdf);
echo "wrote invoice.pdf\n";
Java
// JDK 11+ and Gson (com.google.code.gson:gson), since the JDK has no JSON parser.
// Run: java -cp gson.jar GeneratePdf.java
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public class GeneratePdf {
public static void main(String[] args) throws Exception {
JsonObject invoice = JsonParser.parseString(Files.readString(Path.of("invoice.json"))).getAsJsonObject();
invoice.addProperty("profile", "facturx-en16931"); // the one profile the PDF carries
// The page's options. A logo goes here too, as a PNG or JPEG data URL.
JsonObject pdf = new JsonObject();
pdf.addProperty("language", "de");
pdf.addProperty("paymentQr", true);
pdf.addProperty("paymentLink", "https://example.com/pay/INV-2026-0042");
JsonObject body = new JsonObject();
body.add("invoice", invoice);
body.add("pdf", pdf);
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.attestwire.com/v1/generate?format=pdf"))
.header("Authorization", "Bearer " + System.getenv().getOrDefault("ATTESTWIRE_API_KEY", "demo"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toString()))
.build();
HttpResponse<byte[]> response =
HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
// 422: fatal findings, the same ones /v1/validate returns. Nothing was
// generated or charged. Any other status carries {error, message}.
JsonObject result = JsonParser.parseString(
new String(response.body(), StandardCharsets.UTF_8)).getAsJsonObject();
if (!result.has("errors")) {
System.err.println(response.statusCode() + " " + result.get("error").getAsString()
+ ": " + result.get("message").getAsString());
System.exit(1);
}
for (JsonElement e : result.getAsJsonArray("errors")) {
JsonObject f = e.getAsJsonObject();
System.err.println(f.get("severity").getAsString() + " "
+ f.get("rule").getAsString() + " " + f.get("message").getAsString());
System.err.println(" fix: " + f.get("fix").getAsString());
}
System.exit(1);
}
Files.write(Path.of("invoice.pdf"), response.body());
// The free plan and the demo key get a watermarked preview, and say so here.
boolean preview = response.headers().firstValue("Attestwire-Preview").isPresent();
System.out.println("wrote invoice.pdf" + (preview ? " (a watermarked preview)" : ""));
}
}
C#
// .NET 6+. In a console project (dotnet new console) this is the whole Program.cs.
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
var invoice = JsonNode.Parse(File.ReadAllText("invoice.json"))!;
invoice["profile"] = "facturx-en16931"; // the one profile the PDF carries
var body = new JsonObject
{
["invoice"] = invoice,
// The page's options. A logo goes here too, as a PNG or JPEG data URL.
["pdf"] = new JsonObject
{
["language"] = "de",
["paymentQr"] = true,
["paymentLink"] = "https://example.com/pay/INV-2026-0042",
},
};
using var http = new HttpClient();
// Any User-Agent works; HttpClient sends none unless one is set.
http.DefaultRequestHeaders.UserAgent.ParseAdd("attestwire-example-csharp/1");
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.attestwire.com/v1/generate?format=pdf");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ATTESTWIRE_API_KEY") ?? "demo");
request.Content = new StringContent(body.ToJsonString(), Encoding.UTF8);
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
using var response = await http.SendAsync(request);
if ((int)response.StatusCode != 200)
{
// 422: fatal findings, the same ones /v1/validate returns. Nothing was
// generated or charged. Any other status carries {error, message}.
using var result = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var root = result.RootElement;
if (!root.TryGetProperty("errors", out var errors))
{
Console.Error.WriteLine((int)response.StatusCode + " " + root.GetProperty("error").GetString()
+ ": " + root.GetProperty("message").GetString());
return 1;
}
foreach (var f in errors.EnumerateArray())
{
Console.Error.WriteLine(f.GetProperty("severity").GetString() + " "
+ f.GetProperty("rule").GetString() + " " + f.GetProperty("message").GetString());
Console.Error.WriteLine(" fix: " + f.GetProperty("fix").GetString());
}
return 1;
}
File.WriteAllBytes("invoice.pdf", await response.Content.ReadAsByteArrayAsync());
// The free plan and the demo key get a watermarked preview, and say so here.
var preview = response.Headers.Contains("Attestwire-Preview") ? " (a watermarked preview)" : "";
Console.WriteLine("wrote invoice.pdf" + preview);
return 0;
Go
// Go 1.16+, standard library only. Run: go run generate_pdf.go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
)
// apiKey is your key, or the public demo key when ATTESTWIRE_API_KEY is not
// set: /v1/validate and /v1/generate only, a few calls a minute.
func apiKey() string {
if key := os.Getenv("ATTESTWIRE_API_KEY"); key != "" {
return key
}
return "demo"
}
func main() {
raw, err := os.ReadFile("invoice.json")
if err != nil {
log.Fatal(err)
}
var invoice map[string]interface{}
if err := json.Unmarshal(raw, &invoice); err != nil {
log.Fatal(err)
}
invoice["profile"] = "facturx-en16931" // the one profile the PDF carries
body, err := json.Marshal(map[string]interface{}{
"invoice": invoice,
// The page's options. A logo goes here too, as a PNG or JPEG data URL.
"pdf": map[string]interface{}{
"language": "de",
"paymentQr": true,
"paymentLink": "https://example.com/pay/INV-2026-0042",
},
})
if err != nil {
log.Fatal(err)
}
req, err := http.NewRequest("POST", "https://api.attestwire.com/v1/generate?format=pdf", bytes.NewReader(body))
if err != nil {
log.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+apiKey())
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
pdf, err := io.ReadAll(res.Body)
if err != nil {
log.Fatal(err)
}
if res.StatusCode != http.StatusOK {
// 422: fatal findings, the same ones /v1/validate returns. Nothing was
// generated or charged. Any other status carries {error, message}.
var result struct {
Errors []struct{ Severity, Rule, Message, Fix string }
Error, Message string
}
if err := json.Unmarshal(pdf, &result); err != nil {
log.Fatal(err)
}
if len(result.Errors) == 0 {
log.Fatalf("%d %s: %s", res.StatusCode, result.Error, result.Message)
}
for _, f := range result.Errors {
fmt.Fprintln(os.Stderr, f.Severity, f.Rule, f.Message)
fmt.Fprintln(os.Stderr, " fix:", f.Fix)
}
os.Exit(1)
}
if err := os.WriteFile("invoice.pdf", pdf, 0o644); err != nil {
log.Fatal(err)
}
// The free plan and the demo key get a watermarked preview, and say so here.
preview := ""
if res.Header.Get("Attestwire-Preview") != "" {
preview = " (a watermarked preview)"
}
fmt.Println("wrote invoice.pdf" + preview)
}
- Same rules as the XML. Validated first: a fatal finding is a
422with the usualValidationResult, and no PDF. One document is charged when the PDF comes back — the PDF and the XML inside it are one invoice. - One profile. Any
profileother thanfacturx-en16931is400 unsupported_profile, free. An XRechnung CII document inside a PDF is a different Factur-X profile, and this does not write it. - A watermarked preview on the free plan. A free key gets the
same file, with “PREVIEW – not for sending” across every page
and the response header
Attestwire-Preview: watermarked, so you can see and test the real output before paying; it costs one document like any other. To remove the watermark, move the key to a paid plan (Solo, Starter and Scale; see pricing): the next PDF comes back without it and without the header. The plan is the one your key holds now: the Fix Report's 30 days of Starter count as paid, and the watermark returns when they end. - English, German or French. The page's labels, and how it writes
numbers and dates, follow the seller's country — DE and AT German, FR French,
anything else English — unless you name a language;
Content-Languagesays which was used. The tax terms are the ones each country's invoices use: a German credit note (381) is a Rechnungskorrektur, and Gutschrift is kept for the self-billed invoice (389) it means in German VAT law; in French, 381 is an Avoir and 389 an Autofacture. Text that comes from your invoice — names, descriptions, notes — is printed as you wrote it. - What the page can print. The embedded font
covers Latin (including Latin Extended-A and the Romanian comma-below letters), Greek
and Cyrillic; any other character is drawn as “?” and counted in the
X-Unrendered-Charactersresponse header. The embedded XML carries every character unchanged. - What it is not. You cannot send your own PDF to be wrapped. The generator's output passed veraPDF's PDF/A-3B validation on sample invoices at release; that is a check of the generator, not of each response, and no dated Factur-X or ZUGFeRD release number is claimed. Checking a Factur-X file someone sent you is a different feature, on every plan: see Validating a file you already have.
Options for the page
The body can stay the invoice alone. To set options for the page, wrap it: the
invoice goes under invoice, unchanged, and the options under
pdf. Any body with an invoice key is the wrapper;
any other body is the invoice, exactly as before.
{
"invoice": { "profile": "facturx-en16931", "invoiceNumber": "INV-2026-0042", … },
"pdf": { "language": "de" }
}
| Option | Takes | What it does |
|---|---|---|
language | en, de, fr | The language of the page: its labels, and the way it formats numbers and dates. Default: the seller country's — DE and AT German, FR French, anything else English. The invoice's own text (names, descriptions, notes) and the embedded XML are the same in every language. Query parameter: language. |
logo | string | A PNG or baseline JPEG as a data URL (data:image/png;base64,… or data:image/jpeg;base64,…), at most 256 KB and 2000 pixels on either side, in RGB or greyscale. It is printed in the top-left corner of the first page, scaled to fit 150 × 50 points; a PNG keeps its transparency. About 625 × 210 pixels is already 300 dpi there, and a larger PNG only costs time to decode. Refused with 400: a CMYK or progressive JPEG, an interlaced PNG, or any other kind of file. Body only. |
paymentQr | boolean | The EPC payment QR code ("GiroCode"), printed beside the payment details so a banking app can fill in the transfer: payee, IBAN, amount due and reference. Default: printed whenever the invoice can carry one — currency EUR, payment means 30, 42 or 58 (credit transfer), a valid IBAN, an amount due from 0.01 EUR, and not a credit note — and left out otherwise. false leaves it out; true requires it, and an invoice that cannot carry it is then a 400 that says why. The reference in the code is the payment reference (BT-83), or else the invoice number. Query parameter: payment_qr=true|false. |
paymentLink | string | An https:// address where the invoice can be paid online, such as a payment page from your payment provider. It is printed under the payment details and is clickable in the PDF (a link annotation, which PDF/A permits; no script). At most 500 characters, no user name or password. It is on the page only: EN 16931 has no field for it, so the embedded XML does not carry it. Query parameter: payment_link (URL-encoded). |
The simple options are also query parameters
(?language=, ?payment_qr=, ?payment_link=),
for a caller that cannot change the body it sends. Naming one option in both places
with different values is a 400 bad_request, as is a pdf
object without an invoice, a wrapper with any other key, an unknown
option, or a value an option does not take; the message names the field, and nothing
is charged. The options are read only with ?format=pdf: the JSON and XML
responses have no page, so there the wrapper's pdf object is accepted and
ignored, and you can switch format without rebuilding the body.
Letting the API choose the profile
Send "profile": "auto" to /v1/generate or
/v1/validate and the API picks the profile — and with it the
syntax, BT-24 and the CIUS rules — from what the invoice says about its
buyer. No network call is made, and the first rule that matches decides:
| When the invoice says | Profile | rule |
|---|---|---|
| the buyer's electronic address (BT-49) has scheme 0204, or a German buyer's reference (BT-10) is a Leitweg-ID, check digits and all | XRechnung UBL, xrechnung-ubl | leitweg-id |
| the buyer is in Belgium | Peppol BIS, peppol-bis-3 | belgium |
| the buyer's electronic address is a Peppol participant identifier (any Peppol scheme but 0225, the French platforms' address) | Peppol BIS, peppol-bis-3 | peppol-address |
| the buyer is in France | Factur-X, facturx-en16931 | france |
| the buyer is a business in Germany or Austria (a VAT, tax or legal registration identifier) | XRechnung CII, xrechnung-cii; with ?format=pdf, the hybrid PDF, facturx-en16931 | germany-austria-business |
| none of these | core en16931; with ?format=pdf, facturx-en16931 | default |
The answer names the choice: profile_choice
(requested, profile, rule and a
reason in a sentence) in the JSON body, the 422 included, and the
Attestwire-Profile and Attestwire-Profile-Rule headers on
every response, the XML and the PDF included. When the rules choose XRechnung or
Peppol and the request asked for ?format=pdf, the answer is
400 unsupported_profile with the reason, and nothing is charged: that
buyer does not take the PDF. Naming a profile yourself always overrides
auto. The recipient profile answers the same
question from the buyer's identifier and its live registrations.
Validating a file you already have
You do not always have a JSON invoice. Often you have a file, and a customer telling you their platform rejected it. Post the file itself to the same endpoint, with the content type that says what it is — an XML file:
curl -X POST https://api.attestwire.com/v1/validate \
-H "authorization: Bearer $ATTESTWIRE_API_KEY" \
-H 'content-type: application/xml' \
--data-binary @invoice.xml
or a Factur-X or ZUGFeRD PDF:
curl -X POST https://api.attestwire.com/v1/validate \
-H "authorization: Bearer $ATTESTWIRE_API_KEY" \
-H 'content-type: application/pdf' \
--data-binary @invoice.pdf
We read the document into the same invoice model and run the same rules, so you get the same teaching errors, each with the line and column of its element in your file. Send the file as it is — you do not have to tell us whether it is UBL or CII, or what encoding it is in, and there is no separate endpoint for each. The response adds six fields that only a file can have:
syntax—"ubl"or"cii": which reader ran. Decided from the root element, not from anything you sent.container— for a PDF, the name of the attachment the XML was read from, usually"factur-x.xml";nullfor an XML file. Each finding'slocationnames it too, asattachment: the line and column are in that XML.customizationId— BT-24, exactly as the file states it. This is what decides which CIUS rules ran.profileId— BT-23, exactly as the file states it.unmapped— everything in the file that did not reach the model. Read this. An entry with"kind": "unknown"means we have no field for that element, so its content is gone from the model and was not checked; if it matters to you, read it out of the XML yourself. An entry with"kind": "recomputed"means we derive the value from the lines instead of storing it — nothing was lost. Onerecomputedentry per line is normal. A document total we could not read appears here and as a finding — see below.source— one sentence saying what this check does not prove.
Send the content type that says what the file is.
application/xml or text/xml for XML,
application/pdf for a PDF; application/octet-stream
reads either, because the file's first bytes decide. Without one of those, the
body is read as the JSON model — that is what a request with no
content-type has always meant here — and a file sent that way, or as a
form upload (curl -F), is refused with
415 unsupported_media_type, naming what the body is and the
content type to send it with. Nothing is charged.
Both syntaxes, and both document types. A UBL 2.1
<Invoice>, a UBL 2.1 <CreditNote>, or a
UN/CEFACT CII <CrossIndustryInvoice> — which is one
document for invoices and credit notes alike, because that is how CII is
written. You do not have to say which: the reader looks at the root element,
reports the syntax it read in the syntax field and the document
type it detected in invoiceTypeCode.
Any encoding the file declares. The bytes are read the way
the file says to: a byte-order mark, or encoding= in the XML
declaration. A UTF-16 file and an ISO-8859-1 one read as what they are, and
bytes that are not valid in the encoding a file declares are refused rather
than quietly replaced. So is a file converted to UTF-8 whose declaration
still names a single-byte encoding such as ISO-8859-1: read as it declares,
every “ß” in it would be “ß”, and
the refusal says to correct the declaration. If you re-encode a file to
UTF-8 before sending it, say so —
content-type: application/xml; charset=utf-8 — and the
header outranks a declaration you left behind, as RFC 7303 ranks them;
otherwise send the file's own bytes. The XML inside a Factur-X or ZUGFeRD
PDF is the exception: those formats require it to be UTF-8, a receiver is
entitled to read it as UTF-8 whatever it declares, and an attachment in
another encoding is refused, naming the encoding.
A PDF is opened: its XML is judged, and its attachment and metadata
are checked. Factur-X and ZUGFeRD are CII XML inside a
PDF/A-3 container. We take the XML invoice out of the attachment
(factur-x.xml, zugferd-invoice.xml or
xrechnung.xml) and validate that. Of the PDF itself, the attachment (its name, /AFRelationship and MIME type) and the XMP metadata are checked, and each problem is an AW-PDF-* finding, none of them fatal; PDF/A conformance itself, and whether the page agrees with the XML, are not checked.
The container findings are warnings and information — an XMP block
without the Factur-X properties, the XML attached as /Data where
Germany asks /Alternative — so valid never moves
for them, and like every AW- finding they have a message and a fix
but no location. Every such response says what was and was not
checked in source. It is on every plan, free included, and costs one document
like any other file; the ?format=pdf PDF is the one we
write. A PDF may be up to 10 MB.
A Factur-X or ZUGFeRD MINIMUM or BASIC WL file is
valid: false, and its first finding says why. Those
profiles carry too little to be an EN 16931 invoice — neither has
invoice lines — so when BT-24 declares one, errors starts
with AW-PROFILE-SUBSET, which names the profile and what to
export instead, ahead of the rule failures that follow from it. It is the
engine's own finding about the whole file, not a rule, so it has no rules
page and no docsUrl, xpath or
location: its message and fix are the
whole explanation. The npm library and its command line report the same
finding. This API used to leave it out, so a complete invoice whose BT-24
names one of those profiles passed here while the library failed it.
What is refused. A PDF with no XML invoice attached — a
plain PDF invoice, which is a picture of one — or whose attached XML is
not UTF-8, as Factur-X and ZUGFeRD require, a
ubl:DebitNote, the one UBL billing document EN 16931 has no
binding for, any other root element, and a file that is not a document at all
(an HTML page, a ZIP archive) are refused with 415, naming what
was found. Retrying will not help.
This is a pre-flight, not a verdict. We validate the model we
read, not the XML a receiver judges. Rules that constrain the document itself
rather than the model — BR-DE-13 and BR-DE-21 on
BT-24 — do not run here, so a file that passes can
still be rejected by KoSIT or by a receiving platform.
Changed on 2026-08-14, and it can turn a
valid: true into a valid: false. The
document totals are the one part of the file we now check for
presence, not just for agreement. A document that does not state the
sum of line net amounts (BT-106), the total without VAT (BT-109), the total
with VAT (BT-112) or the amount due for payment (BT-115) fails
BR-12, BR-13, BR-14 or
BR-15; one that states a total we cannot read as a number — an
empty element, or 12,34 with a decimal comma — fails
ATW-DECLARED-TOTAL-NOT-A-NUMBER, our own id, because the
official validator rejects those at XML Schema validation rather than under a
business rule. Both used to be dropped in silence and pass. KoSIT rejected
them all along, so this is a correction, not a new restriction.
What it costs. One document once the file has been read and
judged — the same whether the answer is valid: true or
valid: false, because the work is the same. A file we cannot read
at all (400, 413, 415) never reaches the
rules and costs nothing; those responses carry the unchanged
X-RateLimit-* headers, so you can see the allowance did not move.
| Status | error | What went wrong |
|---|---|---|
| 415 | unsupported_media_type | A file sent without the content type that says so — none, application/json, curl's default form type — or as a form upload. The message names the file and the content type that works. |
| 415 | unsupported_syntax | Not an invoice document we take: an XML root that is none of the three we read (UBL Invoice, UBL CreditNote, CII CrossIndustryInvoice), such as a ubl:DebitNote; a PDF with no XML invoice attached, or whose attached XML is not UTF-8, as Factur-X and ZUGFeRD require; text that is not valid in the encoding it declares, or UTF-8 under a declaration naming a single-byte encoding; an HTML page or a ZIP archive. The message names what it found. |
| 400 | xml_malformed | Not well-formed, or outside the XML subset we accept — mixed content, an unbound prefix, a control character, the same attribute twice on one element, a comment containing --. For a PDF, the XML attached to it. |
| 400 | xml_rejected | Well-formed, but uses a construct we refuse on sight: a <!DOCTYPE or an entity declaration. That is how a hostile file reads a local file or exhausts memory. |
| 400 | pdf_unreadable | A PDF that cannot be read: it stops part-way, is corrupt or encrypted, or uses a compression filter the reader does not implement. |
| 413 | xml_limit_exceeded | Under the body ceiling, but past the reader's depth (100), element (50,000), attribute (256) or character (8,000,000) cap. |
| 413 | pdf_limit_exceeded | A PDF under 10 MB, but past one of the PDF reader's fixed limits: 4 MB for any one stream once inflated, 8 MB for all of them, 4 MB for the XML attached. A real Factur-X file is far below them; a decompression bomb is not. |
Each of those also carries xml_code when the reader raised one:
its own, finer-grained code — xml_ codes for the XML,
pdf_ and facturx_ ones for a PDF. A file recognised on
sight as something else (an HTML page) has none. Branch on error
first; read xml_code only if you need the detail.
When the file is too big: ?max_characters
The reader's depth (100), element (50,000), attribute (256) or character (8,000,000) cap exist because the XML came from somebody else,
and an ordinary invoice is nowhere near any of them. One ordinary document is:
a file carrying a base64-embedded attachment (BG-24,
EmbeddedDocumentBinaryObject) spends four characters per three
bytes, so a 300 kB PDF inside it clears the
8,000,000-character cap on the
attachment alone.
For that case, and only for the character cap, you can raise the limit for one
request — on /v1/validate and /v1/parse alike:
curl -X POST "https://api.attestwire.com/v1/validate?max_characters=1500000" \
-H "authorization: Bearer $ATTESTWIRE_API_KEY" \
-H 'content-type: application/xml' \
--data-binary @invoice-with-attachment.xml
Two ceilings, and they are independent.
max_characters accepts anything up to
16,000,000 characters,
and asking for it also raises the request-body ceiling of an
application/xml call from 1 MB to
32 MB — a raise the bytes cannot get through is not a
raise. Whichever ceiling you meet first refuses the request. For a PDF it raises
the cap on the XML inside, as far as the
4 MB the reader takes out of one PDF;
the PDF's own 10 MB ceiling does not move. Anything above the
character ceiling, or a value that is not a positive whole number, is
400: the number you asked for is never quietly clamped to
something else.
The depth, element and attribute caps do not move, and that is what makes the
raise safe rather than generous: the element cap bounds the parsed tree
whatever the character count, so the extra characters can only be text. The
default is unchanged for every request that does not ask. Running the library
yourself, the same knob is parseUbl(xml, { maxCharacters }).
Reading a file into the model
The first thing an ERP integration has to do is get from someone else's XML to
your own objects, and until this endpoint existed the only way to do that
through this API was to send the file to /v1/validate and read the
verdict — which throws the model away. POST /v1/parse hands
it back instead:
curl -X POST https://api.attestwire.com/v1/parse \
-H "authorization: Bearer $ATTESTWIRE_API_KEY" \
-H 'content-type: application/xml' \
--data-binary @invoice.xml
A Factur-X or ZUGFeRD PDF is read the same way, sent as
application/pdf: the model comes from the XML attached to it.
The response:
invoice— the model, in the sameInvoiceInputshape/v1/validateand/v1/generateaccept. Post it straight back to either one. Reading a UBL file and generating CII from the result is a syntax conversion you did not have to write.syntax,container,customizationId,profileId— the same four fields the document validate path reports, and for the same reason: the model cannot carry them.unmapped— the parse-level findings: everything in the file that did not reach the model. Read it before you trust the conversion. Document totals the file states unreadably are recorded inside the model, atinvoice.declaredTotals.defects.source— one sentence saying that nothing was judged.
It converts; it does not judge. A 200 here
means the file parsed, and nothing more. A document that comes back cleanly
can still break every rule EN 16931 has. Send invoice to
POST /v1/validate for the verdict — that is a second
document against your allowance, and it is the call that answers the
compliance question.
Documents only. This endpoint inverts the usual default: it
needs a document content type — application/xml or
text/xml, application/pdf, or
application/octet-stream — and anything else is refused with
415 unsupported_media_type, naming what the body is and the
content type that works. If you already hold the JSON model there is nothing
here to do for you.
What it costs. One document, on exactly the terms
/v1/validate charges: reading is the work. A file that cannot be
read at all costs nothing and returns the unchanged
X-RateLimit-* headers.
POST/v1/generate 1 document
Produces the compliant XML document. Validates first: a fatal failure returns
422 with the same teaching errors and consumes nothing.
Add ?format=xml for the raw document as
application/xml; the default is a JSON envelope
{"xml": "…"}.
Profiles this endpoint can emit: en16931, xrechnung-ubl, peppol-bis-3, xrechnung-cii, facturx-en16931.
Anything else fails validation as ATW-PROFILE-UNKNOWN and
returns 422 — see the profile matrix.
?format=pdf: the Factur-X / ZUGFeRD PDF,
application/pdf, for facturx-en16931 only;
any other profile returns 400 unsupported_profile. Still one
document. On the free plan it is a watermarked preview
(Attestwire-Preview: watermarked); the
Solo, Starter and Scale plans get it without the watermark.
POST/v1/bundle 1 document
The invoice, its validation report and a signed manifest, as one ZIP to keep with
the invoice and hand to your tax adviser. Takes what /v1/generate takes,
or a document as /v1/validate does; a fatal finding is a 422
that costs nothing. See Evidence bundles.
POST/v1/send 1 document
E-mails one validated invoice to one recipient: the XML generated here, the Factur-X / ZUGFeRD PDF (watermarked unless the key is on a paid plan), or a document you already have. The free plan sends only to your own address, as a test. See Sending an invoice by e-mail.
POST/v1/keys free
Self-serve free-tier signup. Body {"email": "…"}. Returns
201 with the plaintext key, once. A second request for the same
address returns 409 rather than a second key.
POST/v1/keys/rotate free
Replaces the calling key with a new one, carrying the plan, the month's usage and the subscription across. No body. Authenticated by the key being replaced. See Rotating a key.
GET/v1/usage free
Documents used and remaining in the current period, for the calling key.
GET/v1/billing/portal free
A one-time link to the billing portal for the calling key: change plan,
update the card, download invoices, cancel. POST is accepted
too. 409 no_subscription when there is nothing to manage. See
Managing or cancelling.
GET/v1/health free
Liveness, plus what this build can do: generation,
engine_version, and three separate billing facts.
billing is the long-standing flag — true when
a real card will be charged, "test-mode" for the provider's
sandbox, false when checkout answers 503.
billing_configured says the credentials are present.
billing_reachable says the payment provider actually answered a
cheap, read-only call — cached for five minutes and refreshed in the
background, so this endpoint never waits on it, and null when
billing is unconfigured or has not been checked yet on this instance. The
first two can be true while the third is false: that is a revoked key or a
provider outage, and it is the state in which a customer cannot buy anything.
GET/v1/versions free
Which engine and rule set this deployment is running, when the official validator last agreed with it, and — per release — the rule ids that release first named. See What ran, and when it changed.
Profiling a recipient
Most avoidable rejections come from what the seller had to know about the
buyer: the specification identifier (BT-24), the buyer's electronic address
(BT-49), whether a buyer reference is required (BT-10), and which VAT scenario
applies. POST /v1/recipient answers those from the buyer's country
and one identifier — a VAT number, a Leitweg-ID, a SIREN or SIRET, a
Belgian KBO/BCE number, a GLN, or a Peppol participant identifier
(scheme:value):
curl -X POST https://api.attestwire.com/v1/recipient \
-H "authorization: Bearer $ATTESTWIRE_API_KEY" \
-H 'content-type: application/json' \
-d '{"country": "BE", "id": "0417.497.106", "seller_country": "DE"}'
The identifier is read and checked offline first — shape, and the check
digits where it has them: ISO 7064 MOD 97-10 for a Leitweg-ID, Luhn for a SIREN
or SIRET, mod 97 for a KBO number, GS1 for a GLN, the French VAT key against its
SIREN. id_type says what it is when it could be read two ways. One
that fails its check is answered at once with what is wrong
(status: "invalid_identifier"), nothing is looked up, and it costs
nothing. Then the free sources it leads to are asked, in parallel:
| Source | Asked for | What it gives |
|---|---|---|
| VIES | the VAT number given, or derived from a SIREN or a KBO number | valid, invalid or unavailable, and the name and address where the member state shares them; the same check as /v1/vat/verify, never memoised |
| Peppol SML/SMP | at most two participant identifiers the input leads to (a Belgian number under 0208 and 9925, a SIREN under 0002 and 9957) | registered, with the document types the buyer advertises; not found is inconclusive, never proof of absence |
| Peppol Directory | the same identifiers | the business card, when the buyer's access point publishes one; absence says nothing about registration |
| recherche-entreprises | a SIREN, given or inside a SIRET or a French VAT number | name, head-office address, and whether the company is active |
Every source is listed in sources with the time it answered. One
that fails is marked unavailable and the rest of the answer stands
(status: "partial"). The Directory and the register are memoised
in the isolate for 10 minutes, and keep the time they first
answered; VIES and the SML/SMP lookup are asked every time.
The recommendation follows from what the sources said:
| The buyer | channel | format | BT-49 | BT-10 |
|---|---|---|---|---|
| German public buyer (a Leitweg-ID) registered on Peppol | peppol | XRechnung, xrechnung-ubl | 0204, the Leitweg-ID | required, the Leitweg-ID (BR-DE-15) |
| … federal (the ID starts 99), not found on Peppol | ozg-re | XRechnung, xrechnung-ubl | 0204, the Leitweg-ID | required, the Leitweg-ID |
| … state or municipal, not found on Peppol | public-portal | XRechnung, xrechnung-ubl | 0204, the Leitweg-ID | required, the Leitweg-ID |
| French buyer | french-pa | facturx-en16931, as the PDF | 0225, the SIREN (confirm it: the annuaire has no open API) | not required |
| Any other buyer registered on Peppol | peppol | the first of peppol-bis-3, xrechnung-ubl, xrechnung-cii it advertises | its participant identifier | Peppol BIS: BT-10 or BT-13 (PEPPOL-EN16931-R003) |
| Belgian buyer not found on Peppol | peppol, with a warning | Peppol BIS, peppol-bis-3 | 0208, the KBO number | BT-10 or BT-13 |
| German or Austrian business not on Peppol | email | facturx-en16931, as the PDF | 9930 or 9914, the VAT number | not required |
| Anyone else not found | unknown | core en16931 | the country's VAT scheme, when there is one | not required |
specification_identifier is the BT-24 the generator writes for
that format. With seller_country, vat_scenario suggests
the vatScenario for goods and for services: domestic
in one country; intra-eu-goods and intra-eu-services
between member states, confirmed only when VIES said the buyer's
number is valid, whose answer and time ride along as evidence;
export for goods leaving the EU. Outside those, and whenever VIES
says the number is invalid, it answers null and says why. It is a
suggestion, not a tax determination, and never the small-business exemption,
which is a fact about you rather than the buyer.
What it costs. One document when at least one source answered.
An identifier that fails its own check, and an answer whose every source was
unavailable, cost nothing. The identifier is sent to VIES, OpenPeppol's public
lookup, the Peppol Directory and, for a SIREN, the French register; nothing is
stored. Over MCP the same operation is get_recipient_profile.
Evidence bundles
POST /v1/bundle answers with one ZIP to keep with an invoice and give to
your tax adviser: the invoice, the report of the check it passed, and a manifest of
every file's SHA-256 signed with Attestwire's published
Ed25519 key, so the adviser — or an auditor years later — can see that
nothing in it changed.
curl -X POST "https://api.attestwire.com/v1/bundle?format=pdf" \
-H "authorization: Bearer $ATTESTWIRE_API_KEY" \
-H "content-type: application/json" \
--data-binary @invoice.json -o evidence.zip
| File | What it holds |
|---|---|
<invoice number>.xml | The invoice XML: generated from your JSON, or your document byte for byte as sent, or the XML attached to the PDF you sent. |
<invoice number>.pdf | When there is one: the Factur-X / ZUGFeRD PDF generated with ?format=pdf (the free plan's watermarked preview on a free key), or the PDF you sent, as sent. |
validation-report.json | Every finding — the whole list, not the capped one a response carries — with the engine version, the rule set's provenance, the time, and what was and was not checked. |
validation-report.html | The same report as a page, readable offline: it loads nothing from the network. |
manifest.json | Every other file's name, size and SHA-256, the engine version, the time, and the signing key's id. |
manifest.sig | An Ed25519 signature over the exact bytes of manifest.json. |
README.txt | What the bundle is for, and the commands below that check it. |
- From JSON — the body
/v1/generatetakes: the invoice alone, or with its PDF options in the{"invoice", "pdf"}wrapper.?format=pdfadds the PDF, forfacturx-en16931only, drawn exactly as/v1/generatedraws it. - From a document — the bodies
/v1/validatetakes, read by the same reader, with the same refusals. The XML goes in byte for byte as you sent it; a PDF goes in as sent, with the XML attached to it beside it. - Only a clean invoice is bundled. A fatal finding is a
422with the usualValidationResult, and costs nothing. One document is charged when the bundle comes back; the ZIP's entries are stored, not compressed, so any unzip opens it. - Unsigned when no key is configured. A deployment without a signing key
still makes the bundle, without
manifest.sig, with"signature": nullin the manifest and a README that says so. The response headerAttestwire-SignatureisEd25519 <key id>ornone.
To check a bundle, in the unzipped folder: the first command compares every file with the
manifest and should print OK for each; the other two check the manifest's
signature with OpenSSL 3 against the key the manifest names (its
signature.key_id). README.txt carries the same commands with the
key id filled in, and a Node script that does both.
jq -r '.files[] | "\(.sha256) \(.name)"' manifest.json | shasum -a 256 -c
curl -s https://api.attestwire.com/.well-known/attestwire-signing-keys.json \
| jq -r '.keys[] | select(.kid == "<key id from manifest.json>") | .pem' > attestwire.pem
openssl pkeyutl -verify -pubin -inkey attestwire.pem -rawin -in manifest.json -sigfile manifest.sig
The report is a record of an automated software check. Its statement says so in the words a validation record uses, and adds that it is not tax advice: what you must keep, and for how long, is a question for your tax adviser.
Sending an invoice by e-mail
Attestwire sends invoices by e-mail on request; it does not operate Peppol or
French-platform transmission. POST /v1/send generates an invoice
— or takes one you already have — checks it, and e-mails it to one recipient:
one invoice per message, attached under its invoice number, with a subject and a short
note in the invoice's language, for customers who take their invoices by e-mail.
jq '{to: "ap@buyer.example", format: "pdf", invoice: .}' invoice.json \
| curl -X POST https://api.attestwire.com/v1/send \
-H "authorization: Bearer $ATTESTWIRE_API_KEY" \
-H "content-type: application/json" \
--data-binary @-
{
"status": "accepted",
"message_id": "<…>",
"to": "ap@buyer.example",
"from": {
"email": "invoices@attestwire.com",
"name": "Nordwind Software GmbH via Attestwire"
},
"reply_to": "rechnungen@nordwind.example",
"subject": "Rechnung INV-2026-0042 von Nordwind Software GmbH",
"language": "de",
"test_mode": false,
"source": "generated",
"format": "pdf",
"profile": "facturx-en16931",
"attachment": {
"filename": "INV-2026-0042.pdf",
"content_type": "application/pdf",
"bytes": 70646,
"sha256": "…"
},
"warnings": [],
"information": [],
"delivery": "…",
"provenance": "{ … }"
}
- What is sent.
invoicewith"format": "xml"sends the XML of its profile — an XRechnung forxrechnung-ubl; with"format": "pdf", the Factur-X / ZUGFeRD PDF (facturx-en16931only), drawn with thepdfoptions as/v1/generatedraws it. Ordocument: a file you already have, as base64 — XML, or a Factur-X or ZUGFeRD PDF — read as/v1/validatereads it and attached byte for byte.jq -n --arg d "$(base64 < invoice.xml)" '{to: "ap@buyer.example", document: $d}'builds that body. - Nothing with a fatal finding is sent. That is a
422with the usualValidationResult, and costs nothing. - From, Reply-To, subject. The message comes from this deployment's sender
address, shown as “{seller} via Attestwire”. Replies go to the seller's contact
e-mail (BT-43,
seller.contact.email), so the invoice must carry one (400 seller_email_required). The subject is the document's title, number and seller — “Rechnung INV-2026-0042 von …”, “Facture … de …”, “Invoice … from …”; a German credit note is a Rechnungskorrektur, as on its page. The language islanguageif you name one (en,de,fr), else the PDF page's, else the seller country's. - The free plan is a test mode. It sends only to the address the key was
registered with (
403 recipient_not_allowedotherwise); the subject starts[TEST], the first line says the message is not a request for payment, and a PDF is the watermarked preview. The Solo, Starter and Scale plans send to anyone. - Limits. 10 messages per key per UTC day on the
free plan and 500 on a paid plan (
429 send_limit_exceeded); and a ceiling on the day's sends across all keys, 50 unless this deployment sets another (429 send_capacity_exceeded, which says when it resets). Both carryRetry-Afteruntil midnight UTC, and an attempt the mail service refuses still counts. Then the pace limit, and an attachment of at most 7 MB, so the message stays under the 10 MB many receiving mail servers accept. - One document is charged once the mail service has accepted the message.
Every refusal is free, and nothing is ever retried on your behalf: a
503 email_unavailablesays when trying again can help, and a502 email_failedsays whether the message may already have gone.
A 202 is not a delivery receipt. Accepted by the mail service, which delivers it and retries on its own. Attestwire is not told whether it was delivered, bounced or marked as spam, and does not store the message: keep attachment.sha256 and message_id as your record of what was sent, and ask the recipient to confirm receipt if you need to know. A
retry after a timeout can send the invoice twice. Attestwire stores nothing about the message
— not the invoice, not the recipient. The message goes out through Resend
(resend.com), the e-mail service Attestwire sends with, which processes it to deliver it and
keeps its own delivery logs. Every message carries the header X-Attestwire-Account
with your key's 8-character fingerprint, so an abuse report can be traced to the key that sent
it. A deployment without e-mail configured answers 503 email_not_enabled.
The invoice model
One JSON object, field names mapped to EN 16931 business terms. Full schema in
the OpenAPI document. This is the same invoice the
quickstart writes to invoice.json:
{
"profile": "xrechnung-ubl",
"invoiceNumber": "INV-2026-0042",
"issueDate": "2026-08-09",
"dueDate": "2026-09-08",
"deliveryDate": "2026-07-31",
"currency": "EUR",
"buyerReference": "04011000-1234512345-06",
"seller": {
"name": "Nordwind Software GmbH",
"vatId": "DE123456789",
"address": {
"line1": "Hafenstraße 12",
"city": "Hamburg",
"postalCode": "20095",
"countryCode": "DE"
},
"electronicAddress": {
"schemeId": "9930",
"value": "DE123456789"
},
"contact": {
"name": "Buchhaltung",
"phone": "+49 40 1234567",
"email": "rechnungen@nordwind.example"
}
},
"buyer": {
"name": "Stadt Musterstadt",
"address": {
"line1": "Rathausplatz 1",
"city": "Musterstadt",
"postalCode": "80331",
"countryCode": "DE"
},
"electronicAddress": {
"schemeId": "0204",
"value": "04011000-1234512345-06"
}
},
"payment": {
"meansCode": "58",
"iban": "DE02120300000000202051",
"accountName": "Nordwind Software GmbH"
},
"paymentTerms": "Net 30 days",
"lines": [
{
"id": "1",
"description": "Implementation services, July 2026",
"quantity": 12,
"unitCode": "HUR",
"unitPrice": 145,
"vatCategory": "S",
"vatRate": 19
}
]
}
Every field
Every object the model has, and every field in it: its type, whether the model requires it, the EN 16931 business term (BT) or group (BG) it carries, and what it is. This list is built from the same schema /openapi.json publishes, and that schema is tested against the engine's own types, so a field is either in all three or in none. “Required” is the model's minimum; a profile asks for more (XRechnung wants a buyer reference, a seller contact and payment instructions), and the findings name the rule that does.
InvoiceInput
Field names map to EN 16931 business terms. The fields in required are the only ones the model needs; every other field and group is optional, and one you leave out is not written. Types are the model's and nothing more: the rules judge the values and report what is wrong as findings, so a value this schema allows can still fail validation. Mirrors InvoiceInput in @attestwire/en16931.
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
profile | one of en16931 xrechnung-ubl xrechnung-cii facturx-en16931 peppol-bis-3 auto | required | Selects the rule set, and on POST /v1/generate the syntax. | |
invoiceNumber | string | required | BT-1 | Invoice number. |
issueDate | date | required | BT-2 | Issue date, ISO 8601. |
currency | string | required | BT-5 | Invoice currency code, ISO 4217. |
invoiceTypeCode | string | BT-3 | Document type code, UNTDID 1001: "380", the default, is a commercial invoice and "381" a credit note. | |
dueDate | date | BT-9 | Payment due date, ISO 8601. | |
note | string | BT-22 | Invoice note, free text. | |
noteSubjectCode | string | BT-21 | Invoice note subject code, UNCL 4451. | |
buyerReference | string | BT-10 | Buyer reference, which XRechnung requires (BR-DE-15). | |
orderReference | string | BT-13 | Purchase order reference. | |
salesOrderReference | string | BT-14 | Sales order reference: your own order number, not the buyer's. | |
projectReference | string | BT-11 | Project reference. | |
contractReference | string | BT-12 | Contract reference. | |
despatchAdviceReference | string | BT-16 | Despatch advice reference. | |
receivingAdviceReference | string | BT-15 | Receiving advice reference. | |
tenderOrLotReference | string | BT-17 | Tender or lot reference. | |
invoicedObjectIdentifier | SchemedIdentifier | BT-18 | Invoiced object identifier, with its BT-18-1 scheme from UNTDID 1153. | |
buyerAccountingReference | string | BT-19 | Buyer accounting reference: the buyer's cost centre or ledger account. | |
taxPointDate | date | BT-7 | Value added tax point date, ISO 8601. | |
invoicingPeriod | InvoicingPeriod | BG-14 | Invoicing period: the period the supply relates to. | |
precedingInvoices | list of PrecedingInvoiceReference | BG-3 | Preceding invoice references: the invoices this document corrects, credits or settles against. | |
seller | Party | required | BG-4 | Seller. |
buyer | Party | required | BG-7 | Buyer. |
payee | Payee | BG-10 | Payee, when payment goes to someone other than the seller. | |
taxRepresentative | TaxRepresentative | BG-11 | Seller tax representative, for a seller registered through a fiscal representative. | |
vatScenario | one of domestic intra-eu-goods intra-eu-services export small-business-exemption | What happened, in business terms, for every line, document level allowance and charge that does not say otherwise: the engine writes the VAT category (BT-151, BT-95, BT-102), the rate and the exemption reason (BT-120, BT-121) from it. | ||
lines | list of InvoiceLine | required | BG-25 | Invoice lines, at least one (BR-16). |
allowances | list of DocumentAllowanceCharge | BG-20 | Document level allowances: discounts on the whole invoice. | |
charges | list of DocumentAllowanceCharge | BG-21 | Document level charges: freight, packing, surcharges. | |
paymentTerms | string | BT-20 | Payment terms, free text. | |
payment | PaymentInstructions | BG-16 | Payment instructions, which XRechnung requires (BR-DE-1). | |
supportingDocuments | list of SupportingDocument | BG-24 | Additional supporting documents. | |
deliveryDate | date | BT-72 | Actual delivery date, ISO 8601. | |
deliverTo | DeliverToAddress | BG-15 | Deliver-to address. | |
deliverToName | string | BT-70 | Deliver-to party name. | |
deliverToLocationId | SchemedIdentifier | BT-71 | Deliver-to location identifier, with its ISO 6523 ICD scheme. | |
vatAccountingCurrency | string | BT-6 | VAT accounting currency, ISO 4217, when VAT is reported in a currency other than BT-5. | |
taxAmountInAccountingCurrency | number | BT-111 | Invoice total VAT amount in the VAT accounting currency. | |
paidAmount | number | BT-113 | Paid amount: a prepayment or deposit already received. | |
roundingAmount | number | BT-114 | Rounding amount, added to the total to produce a payable round figure. | |
vatExemptionReasons | map of S Z E AE K G O L M to string | BT-120 | VAT exemption reason text, keyed by VAT category, such as {"E": "…"}. | |
vatExemptionReasonCodes | map of S Z E AE K G O L M to string | BT-121 | VAT exemption reason code from the CEF VATEX list, keyed by VAT category. | |
declaredTotals | DeclaredTotals | Totals you computed yourself, checked against ours. |
Party
Where a field names two terms, the first is the seller's and the second the buyer's; a field with one term exists for the seller only.
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
name | string | required | BT-27 / BT-44 | Name, written as both the legal name and the trading name (BT-28 / BT-45) unless legalName or tradingName says otherwise. |
vatId | string | BT-31 / BT-48 | VAT identifier, with its country prefix. | |
taxRegistrationId | string | BT-32 | Seller tax registration identifier, such as the German Steuernummer. | |
legalRegistrationId | string | BT-30 / BT-47 | Legal registration identifier, such as an HRB number. | |
legalRegistrationSchemeId | string | BT-30-1 / BT-47-1 | ISO 6523 ICD scheme of the legal registration identifier. | |
identifier | SchemedIdentifier | BT-29 / BT-46 | Seller or buyer identifier, with its BT-29-1 / BT-46-1 ISO 6523 ICD scheme. | |
additionalLegalInformation | string | BT-33 | Seller additional legal information: legal form, share capital and the like. | |
tradingName | string | BT-28 / BT-45 | Trading name, when the party trades under a name other than its legal one. | |
legalName | string | BT-27 / BT-44 | Registered legal name, when it differs from name. | |
address | PostalAddress | required | BG-5 / BG-8 | Postal address. |
electronicAddress | ElectronicAddress | BT-34 / BT-49 | Electronic address: where the network delivers the document, such as a Peppol participant identifier. | |
contact | Contact | BG-6 / BG-9 | Contact, which XRechnung requires in full for the seller: name, phone and email (BR-DE-2/5/6/7). |
PostalAddress
The terms are listed in that order.
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
line1 | string | BT-35 / BT-50 / BT-64 | Address line 1. | |
line2 | string | BT-36 / BT-51 / BT-65 | Address line 2. | |
line3 | string | BT-162 / BT-163 / BT-164 | Address line 3. | |
city | string | required | BT-37 / BT-52 / BT-66 | City. |
postalCode | string | required | BT-38 / BT-53 / BT-67 | Post code. |
countrySubdivision | string | BT-39 / BT-54 / BT-68 | Country subdivision: a Bundesland, a région, a county. | |
countryCode | string | required | BT-40 / BT-55 / BT-69 | Country code, ISO 3166-1 alpha-2. |
Contact
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
name | string | BT-41 / BT-56 | Contact point: a person or a department. | |
email | string | BT-43 / BT-58 | Contact email address. | |
phone | string | BT-42 / BT-57 | Contact telephone number. |
ElectronicAddress
Both halves are required.
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
schemeId | string | required | BT-34-1 / BT-49-1 | Scheme identifier, from the Peppol EAS code list. |
value | string | required | BT-34 / BT-49 | The address itself. |
SchemedIdentifier
The field that holds it names the business term, and which code list the scheme comes from.
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
value | string | required | The identifier. | |
schemeId | string | Its scheme identifier: ISO 6523 ICD for party, location and item identifiers, UNTDID 1153 for the invoiced object and line object identifiers. |
InvoiceLine
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
id | string | required | BT-126 | Invoice line identifier. |
description | string | required | BT-153 | Item name. |
longDescription | string | BT-154 | Item description, when it should say more than the item name. | |
quantity | number | required | BT-129 | Invoiced quantity. |
unitCode | string | required | BT-130 | Unit of measure code, UN/ECE Recommendation 20. |
unitPrice | number | required | BT-146 | Item net price. |
grossUnitPrice | number | BT-148 | Item gross price, before the BT-147 price discount. | |
priceDiscount | number | BT-147 | Item price discount: the difference between BT-148 and BT-146. | |
baseQuantity | number | BT-149 | Item price base quantity, 1 when absent. | |
vatCategory | one of S Z E AE K G O L M | required | BT-151 | Invoiced item VAT category code, UNCL 5305: S standard rate, Z zero rated, E exempt, AE reverse charge, K intra-community supply, G export outside the EU, O not subject to VAT, L IGIC (Canary Islands), M IPSI (Ceuta and Melilla). |
vatRate | number | BT-152 | Invoiced item VAT rate, in percent. | |
vatScenario | one of domestic intra-eu-goods intra-eu-services export small-business-exemption | What happened on this line, instead of its VAT code: the engine writes vatCategory, the rate (except for "domestic", whose rate you give in vatRate) and the exemption reason from it. | ||
note | string | BT-127 | Invoice line note. | |
objectIdentifier | SchemedIdentifier | BT-128 | Invoice line object identifier, with its BT-128-1 scheme from UNTDID 1153. | |
buyerAccountingReference | string | BT-133 | Invoice line buyer accounting reference. | |
period | InvoicingPeriod | BG-26 | Invoice line period. | |
orderLineReference | string | BT-132 | Referenced purchase order line reference. | |
allowances | list of LineAllowanceCharge | BG-27 | Invoice line allowances: discounts on this line only. | |
charges | list of LineAllowanceCharge | BG-28 | Invoice line charges: surcharges on this line only. | |
sellerItemId | string | BT-155 | Item seller's identifier. | |
buyerItemId | string | BT-156 | Item buyer's identifier. | |
standardItemId | SchemedIdentifier | BT-157 | Item standard identifier, such as a GTIN, with its BT-157-1 ISO 6523 ICD scheme. | |
itemClassifications | list of ItemClassification | BT-158 | Item classification identifiers. | |
originCountryCode | string | BT-159 | Item country of origin, ISO 3166-1 alpha-2. | |
itemAttributes | list of ItemAttribute | BG-32 | Item attributes: name and value pairs describing the item. |
LineAllowanceCharge
It has no VAT category of its own: it changes the line net amount (BT-131) and takes the line's VAT treatment.
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
amount | number | required | BT-136 / BT-141 | Amount, always positive. |
baseAmount | number | BT-137 / BT-142 | Base amount. | |
percentage | number | BT-138 / BT-143 | Percentage. | |
reason | string | BT-139 / BT-144 | Reason, free text. | |
reasonCode | string | BT-140 / BT-145 | Reason code: UNCL 5189 for an allowance, UNCL 7161 for a charge. |
ItemClassification
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
code | string | required | BT-158 | The classification code. |
schemeId | string | required | BT-158-1 | The scheme it is drawn from, UNTDID 7143. |
schemeVersion | string | BT-158-2 | The version of that scheme. |
ItemAttribute
Both halves are mandatory once the group exists (BR-54).
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
name | string | required | BT-160 | Item attribute name. |
value | string | required | BT-161 | Item attribute value. |
DocumentAllowanceCharge
Which array it is in decides which it is, and the reason code lists differ, so an allowance reason code in charges fails BR-CL-20.
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
amount | number | required | BT-92 / BT-99 | Amount, always positive. |
baseAmount | number | BT-93 / BT-100 | Base amount. | |
percentage | number | BT-94 / BT-101 | Percentage. | |
vatCategory | one of S Z E AE K G O L M | required | BT-95 / BT-102 | VAT category code, UNCL 5305: S standard rate, Z zero rated, E exempt, AE reverse charge, K intra-community supply, G export outside the EU, O not subject to VAT, L IGIC (Canary Islands), M IPSI (Ceuta and Melilla). |
vatRate | number | BT-96 / BT-103 | VAT rate, in percent. | |
vatScenario | one of domestic intra-eu-goods intra-eu-services export small-business-exemption | What happened, instead of the VAT code of this allowance or charge: the engine writes vatCategory, the rate (except for "domestic", whose rate you give in vatRate) and the exemption reason from it. | ||
reason | string | BT-97 / BT-104 | Reason, free text. | |
reasonCode | string | BT-98 / BT-105 | Reason code: UNCL 5189 for an allowance, UNCL 7161 for a charge. |
PaymentInstructions
Required for XRechnung profiles by BR-DE-1.
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
meansCode | string | required | BT-81 | Payment means type code, UNTDID 4461: "58" SEPA credit transfer, "30" credit transfer, "59" SEPA direct debit, "48" card, "57" standing order, "97" clearing. |
meansName | string | BT-82 | Payment means text. | |
iban | string | BT-84 | Payment account identifier (BG-17): the IBAN, for a credit transfer. | |
accountName | string | BT-85 | Payment account name (BG-17). | |
bic | string | BT-86 | Payment service provider identifier (BG-17): the BIC. | |
remittanceInformation | string | BT-83 | Remittance information: the payment reference. | |
card | PaymentCard | BG-18 | Payment card information. | |
directDebit | DirectDebit | BG-19 | Direct debit information. |
PaymentCard
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
primaryAccountNumber | string | required | BT-87 | Payment card primary account number: at most its first six and last four digits. |
holderName | string | BT-88 | Payment card holder name. |
DirectDebit
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
mandateReference | string | BT-89 | Mandate reference identifier. | |
creditorIdentifier | string | BT-90 | Bank assigned creditor identifier: the SEPA creditor identifier. | |
debitedAccount | string | BT-91 | Debited account identifier: the payer's IBAN. |
InvoicingPeriod
The first term of each pair is the document's.
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
startDate | date | BT-73 / BT-134 | Period start date, ISO 8601. | |
endDate | date | BT-74 / BT-135 | Period end date, ISO 8601. | |
descriptionCode | string | BT-8 | Value added tax point date code, UNTDID 2005 (3, 35 or 432). |
PrecedingInvoiceReference
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
invoiceNumber | string | required | BT-25 | The number of the invoice being corrected or referenced. |
issueDate | date | BT-26 | The issue date of that invoice, ISO 8601. |
Payee
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
name | string | required | BT-59 | Payee name. |
identifier | SchemedIdentifier | BT-60 | Payee identifier, with its ISO 6523 ICD scheme. | |
legalRegistrationId | SchemedIdentifier | BT-61 | Payee legal registration identifier, with its ISO 6523 ICD scheme. |
TaxRepresentative
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
name | string | required | BT-62 | Tax representative name. |
vatId | string | required | BT-63 | Tax representative VAT identifier. |
address | PostalAddress | required | BG-12 | Tax representative postal address. |
DeliverToAddress
Only the country code is required by the model; XRechnung also requires the city and post code once the group is present.
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
line1 | string | BT-75 | Deliver-to address line 1. | |
line2 | string | BT-76 | Deliver-to address line 2. | |
city | string | BT-77 | Deliver-to city. | |
postalCode | string | BT-78 | Deliver-to post code. | |
countrySubdivision | string | BT-79 | Deliver-to country subdivision. | |
countryCode | string | required | BT-80 | Deliver-to country code, ISO 3166-1 alpha-2. |
SupportingDocument
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
reference | string | required | BT-122 | Supporting document reference. |
description | string | BT-123 | Supporting document description. | |
externalUri | string | BT-124 | External document location: a URL. | |
attachment | EmbeddedAttachment | BT-125 | The document itself, embedded. |
EmbeddedAttachment
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
filename | string | required | BT-125-2 | Attached document filename. |
mimeCode | string | required | BT-125-1 | Attached document MIME code. |
content | string | required | BT-125 | The document bytes, base64-encoded. |
DeclaredTotals
Generation always writes the computed values: these exist so you can catch a mismatch in your own accounting system before a tax authority does, and omitting one is never an error. The readers (POST /v1/parse, and POST /v1/validate with a document) also record here what a document states, including the fields described as set by the readers, which a caller building the model leaves out. On that path a document must state its totals: one that omits BT-106, BT-109, BT-112 or BT-115 fails BR-12, BR-13, BR-14 or BR-15, and one that states a total that cannot be read as a number fails ATW-DECLARED-TOTAL-NOT-A-NUMBER.
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
syntax | one of ubl cii | Set by the readers: the syntax the document was read from. | ||
taxTotalsInInvoiceCurrency | number | Set by the readers: how many VAT totals carry the invoice currency (BT-5). | ||
specificationIdentifier | string | BT-24 | Specification identifier as the document states it, trimmed; empty when absent. | |
lineNetAmounts | list of number or null | BT-131 | Invoice line net amounts as each line states them, in document order; null where a line states none. | |
subtotals | list of DeclaredTaxSubtotal | BG-23 | VAT breakdown as the document states it, in document order. | |
lineExtensionAmount | number | BT-106 | Sum of invoice line net amounts. | |
allowanceTotalAmount | number | BT-107 | Sum of allowances on document level. | |
chargeTotalAmount | number | BT-108 | Sum of charges on document level. | |
taxExclusiveAmount | number | BT-109 | Invoice total amount without VAT. | |
taxAmount | number | BT-110 | Invoice total VAT amount. | |
taxInclusiveAmount | number | BT-112 | Invoice total amount with VAT. | |
payableAmount | number | BT-115 | Amount due for payment. | |
defects | list of DeclaredTotalDefect | Set by the readers: document totals the document was supposed to state and did not state readably, one entry per term. | ||
overPrecise | list of OverPreciseAmount | Set by the readers: amounts the document wrote with more than two decimal places, in document order, which the BR-DEC rules report. |
DeclaredTaxSubtotal
Set by the readers.
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
category | one of S Z E AE K G O L M | BT-118 | VAT category code, as written. | |
rate | number | BT-119 | VAT category rate, as written. | |
taxableAmount | number | BT-116 | VAT category taxable amount, as written. | |
taxAmount | number | BT-117 | VAT category tax amount, as written. |
DeclaredTotalDefect
Set by the readers.
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
key | one of lineExtensionAmount allowanceTotalAmount chargeTotalAmount taxExclusiveAmount taxAmount taxInclusiveAmount payableAmount taxAmountInAccountingCurrency | required | Which total it is, as a key of DeclaredTotals. | |
field | string | required | The business term, such as BT-115. | |
state | one of absent empty unreadable | required | Why it could not be read: absent fails a BR presence rule, while empty and unreadable fail XML Schema validation at the official validator before any business rule, and this API reports them as ATW-DECLARED-TOTAL-NOT-A-NUMBER. | |
text | string | What the element held, verbatim, cut at 200 characters. | ||
xpath | string | required | Where the value is in the document, or where it should have been. |
OverPreciseAmount
Set by the readers: a number cannot say how it was written, so they record it while reading.
| Field | Type | Required | BT / BG | What it is |
|---|---|---|---|---|
field | string | required | The business term, which selects the BR-DEC rule. | |
decimals | number | required | Digits after the decimal point, trailing zeros included. | |
text | string | required | The amount exactly as the document writes it. | |
xpath | string | required | Where it was found. | |
line | number | 1-based invoice line, for a line-level term (BT-131). | ||
group | number | 1-based VAT breakdown group, for BT-116 and BT-117. |
Profile support
profile selects the rule set, and it also decides whether this build can generate the document:
| profile | What it is | Syntax | POST /v1/validate | POST /v1/generate |
|---|---|---|---|---|
en16931 |
EN 16931 core | UBL |
yes | yes |
xrechnung-ubl |
German XRechnung CIUS, UBL | UBL |
yes | yes |
xrechnung-cii |
German XRechnung CIUS, CII | CII |
yes | yes |
facturx-en16931 |
Factur-X EN 16931 profile | CII |
yes | yes |
peppol-bis-3 |
Peppol BIS Billing 3.0 | UBL |
yes | yes |
Narrower profiles add rules; they never remove core ones. The profile decides
the syntax, in both directions: ask for xrechnung-cii or
facturx-en16931 and you get CII, ask for anything else and you
get UBL. /v1/validate works the other way round — send a document
and it reads the root element to decide, then tells you which it read in the
syntax field.
CII is XML — the PDF is a separate option. Factur-X
and ZUGFeRD files are CII XML inside a PDF/A-3 container. The JSON and XML
responses are the XML alone: no container, no factur-x.xml
attachment, no /AFRelationship. ?format=pdf with
facturx-en16931 returns the
Factur-X / ZUGFeRD PDF itself: a watermarked preview
on the free plan, the sendable file on the Solo, Starter and Scale plans. Going the
other way is on every plan: send a Factur-X or ZUGFeRD PDF to /v1/validate or
/v1/parse as application/pdf and the XML attached to it
is read and judged, and /v1/validate checks the attachment and the XMP
metadata too — PDF/A conformance itself is not checked.
And the two CII profiles are not equally evidenced. The
generator's xrechnung-cii fixture documents are run
through the official KoSIT validator on release and accepted. The
facturx-en16931 ones are not: that profile's BT-24 is
the core EN 16931 one, which matches no XRechnung scenario, so KoSIT returns
“no scenario matched” instead of a verdict — and no verdict is
not a pass. Neither case is a check on your document: this endpoint
sends nothing to KoSIT.
Error model
Two kinds of failure, and they are shaped differently on purpose.
Invoice problems come back inside a 200 as
errors[] / warnings[] / information[] of
teaching errors. Your invoice is wrong; the request was fine.
Three severities
The levels are the reference validators' own — KoSIT's schematron flags each
assertion fatal, warning or information —
and we report them where they were raised rather than rounding them together.
| severity | Array | Affects valid | What it means |
|---|---|---|---|
fatal | errors[] | yes — valid: false | The document is rejected. Fix before sending. |
warning | warnings[] | no | Accepted, but something is missing or unwise. Worth fixing before you ship. |
information | information[] | no | Advisory. The validator raises it and then accepts the invoice anyway. |
information is kept out of warnings[] deliberately: if
you treat warnings as a pre-ship checklist, you should not be handed a finding
the regulator itself raises and then ignores. It is still worth reading. The
first one, BR-DE-TMP-32, fires on an XRechnung invoice that states
no time of supply — no deliveryDate (BT-72), no
invoicingPeriod (BG-14), and no period on every line. No portal
will stop you, but §14 Abs. 4 Nr. 6 UStG requires it, and the buyer's tax
adviser finds the gap long after the portal did not.
Branch on the arrays, not on a count: rules are added between releases, so code that asserts an exact number of findings will break on an engine update that is otherwise invisible to you.
Request problems come back as a non-2xx with a flat envelope.
Branch on error, which is stable; message is prose and
may be reworded.
{
"error": "invalid_api_key",
"message": "That API key is not recognised. Check that you pasted the whole key: it is aw_live_ followed by 40 characters, and a truncated copy is the usual cause. If you rotated this key, use the key that the rotation returned. If you no longer have a key, a new one is free at POST /v1/keys.",
"docs": "https://api.attestwire.com/docs#auth"
}
One error code carries a fourth field. When your monthly allowance is gone,
quota_exceeded also returns upgrade_url: the page
where a person buys a bigger plan. It is a plain link. Nothing is bought and
nothing is charged by reading it.
{
"error": "quota_exceeded",
"message": "You have used all 100 documents in your free plan for 2026-08. …",
"docs": "https://api.attestwire.com/docs#pricing",
"upgrade_url": "https://attestwire.com/pricing#upgrade"
}
| Status | error | Meaning |
|---|---|---|
| 400 | bad_request | Body was not readable JSON, was not an object, or held an invoice the rule set cannot read at all (for example allowances that is not an array). An endpoint that takes only JSON answers a file sent to it here too, and the message says what the file is and where it goes. Nothing was charged. |
| 400 | invalid_email | An e-mail address failed the format check: the signup address, or the to of POST /v1/send, which takes one plain address. |
| 400 | seller_email_required | POST /v1/send: the invoice has no seller contact e-mail (BT-43, seller.contact.email) to be the message's Reply-To. Nothing was sent or charged. |
| 403 | recipient_not_allowed | POST /v1/send on the free plan: invoices go, as a test, only to the address the key was registered with; the message links the paid plans. Nothing was sent or charged. |
| 429 | send_limit_exceeded | POST /v1/send: this key's daily e-mail ceiling is used. Resets at 00:00 UTC; carries Retry-After. Nothing was charged. |
| 429 | send_capacity_exceeded | POST /v1/send: the day's e-mail sending capacity is used up for everybody — this deployment's daily ceiling across all keys, or the shared mail account's own day or month. The message says which and when it resets; Retry-After names the next 00:00 UTC. Nothing was sent or charged. |
| 502 | email_failed | POST /v1/send: the mail service refused the message as invalid, or did not confirm it at all — in which case it may have gone, so ask the recipient before sending it again. Nothing was charged. |
| 503 | email_not_enabled | POST /v1/send: e-mail delivery is not enabled on this deployment. Answered before authentication; nothing is charged. |
| 503 | email_unavailable | POST /v1/send: the mail service is busy or down, or refused this deployment's sending account. Nothing was sent or charged; Retry-After when a retry can help. |
| 400 | confirmation_required | POST /v1/diagnose with reviewed edits but without confirmed: true. Review the proposed changes, then send them again with it. |
| 400 | unsupported_profile | A /v1/generate request named a profile this build has no generator for. The message lists the profiles it does emit. Nothing was charged. For JSON and XML output it is a safety net: an unknown profile fails validation first (ATW-PROFILE-UNKNOWN, a 422). With ?format=pdf it is the answer to any profile other than facturx-en16931, the only one a Factur-X PDF can carry. |
| 400 | xml_malformed | Document request: the XML is not well-formed — sent as a file, or attached to a PDF. See above. |
| 400 | xml_rejected | Document request: a DOCTYPE or entity declaration, refused on sight. |
| 400 | pdf_unreadable | Document request: the body is a PDF that cannot be read — truncated, corrupt, encrypted, or compressed with a filter the reader does not implement. The message says which. |
| 401 | missing_api_key | No Authorization: Bearer header. |
| 401 | invalid_api_key | Key is unknown or revoked. |
| 403 | forbidden_origin | The MCP endpoint only: the request carried an Origin header this server does not permit. The MCP specification requires a 403 here, because an unchecked origin is what would let a page in a browser drive someone else's MCP client. A server-to-server caller sends no Origin at all and never meets this. |
| 403 | demo_key_not_allowed | The public demo key sent to a route other than POST /v1/validate, POST /v1/generate and POST /v1/recipient, or with ?record=true. It has no account, so nothing that belongs to one — records, usage, rotation, billing, the other live checks — takes it. Nothing was charged; get a free key instead. |
| 404 | not_found | No such route. |
| 404 | record_not_found | GET /v1/records/{id} and /r/{id}: no validation record with that id. Either the id is wrong or incomplete, or the record is past its 365-day expiry and has been deleted — the two are indistinguishable, because an expired record leaves nothing behind. Not cached. |
| 405 | method_not_allowed | Right path, wrong verb. |
| 404 | claim_not_found | POST /v1/fix/claim: that transaction id does not match a completed Invoice Fix Report purchase. Deliberately the same answer for an unknown id, an unsettled payment and a transaction for something else — the route must not be usable to test which transaction ids exist. Nothing was charged. |
| 409 | already_claimed | POST /v1/fix/claim: this Fix Report was already redeemed. The key it minted was shown once and only its hash is kept, so it cannot be re-sent — email hello@attestwire.com with the transaction id rather than buying a second one. |
| 409 | key_already_issued | This email already has a free key. |
| 409 | document_changed | POST /v1/diagnose: expected_sha256 does not match the document that was reviewed. Diagnose the current document again before confirming edits. |
| 409 | rotation_already_done | This key has already been rotated; the replacement was returned once. |
| 409 | already_subscribed | This key already has a subscription; change plan in the billing portal (POST /v1/billing/portal) rather than buying a second one. |
| 409 | no_subscription | /v1/billing/portal only: this key has no subscription behind it, so there is no portal to open. Free keys, and tiers granted by hand, both land here. The route and the request were fine — the account state is what makes it inapplicable, which is why it is a 409 and not a 404. |
| 410 | key_rotated | This key was replaced by a rotation. Use the key that rotation returned. |
| 413 | payload_too_large | Body over 1 MB. |
| 413 | xml_limit_exceeded | Document request: past the XML reader's depth, element, attribute or character cap. |
| 413 | pdf_limit_exceeded | Document request: a PDF under the 10 MB ceiling that is past one of the PDF reader's fixed limits — most often a stream that inflates far beyond its size, which is what a decompression bomb is. |
| 415 | unsupported_syntax | Document request: not an invoice document the reader takes. An XML root that is none of UBL Invoice, UBL CreditNote and CII CrossIndustryInvoice (a ubl:DebitNote, say); a PDF with no XML invoice attached, or whose attached XML is not UTF-8, as Factur-X and ZUGFeRD require; text that is not valid in the encoding it declares, or UTF-8 under a declaration naming a single-byte encoding; or a file that is not a document at all — an HTML page, a ZIP archive. The message names what it found. |
| 415 | unsupported_media_type | A file sent through the wrong door, and the message names the file and the content-type that would have worked. POST /v1/validate reads a body as JSON unless its content-type says it is a document, so XML or a PDF sent with no content-type (or a JSON one) lands here, as does a form upload (curl -F) to either endpoint. POST /v1/parse reads only documents, so anything without a document content-type lands here, JSON included — parsing a body that is already our model would be a no-op. Nothing charged. |
| 422 | — | Generation refused: body is a ValidationResult. |
| 429 | quota_exceeded | Monthly document allowance used up. Body carries upgrade_url. |
| 429 | rate_limited | Too many requests per second on this key. Carries Retry-After; nothing charged. The same pace limit applies to the metered MCP tools, off the same buckets. |
| 429 | rotation_rate_limited | More than 3 rotations of this key today. |
| 429 | too_many_requests | Per-IP signup limit. |
| 500 | internal_error | Something broke on our side. The body never carries the underlying exception — only the class name is logged, and never the message. Retry; if it persists, email hello@attestwire.com. |
| 500 | rotation_failed | The rotation could not be completed and nothing changed. The existing key still works. |
| 501 | generation_not_yet_available | Generation is not enabled in this build. |
| 502 | billing_error | The payment provider refused the request — a checkout that could not be created, or a billing-portal session that could not be minted. Nothing was bought and nothing was charged. Retryable. |
| 503 | billing_not_enabled | Checkout is not configured on this deployment. |
What ran, and when it changed
Pin the npm library for bit-identical CI; the hosted API always runs
the current ruleset — provenance tells you exactly what ran; watch
/v1/versions for newly added rule IDs.
Both doors are correct and they are not the same door. A pinned
@attestwire/en16931 gives you the same answer for the same invoice
forever, which is what a CI suite needs. This API always runs the current rule
set, because a compliance service that froze its rules would be answering last
quarter's regulation. The difference used to be invisible: a
valid: false looked identical whether your invoice had changed or
our rules had.
Every metered response now carries provenance —
/v1/validate and /v1/parse, JSON body or XML
document, and /v1/generate's JSON envelope, including the
422 it returns for an invoice it will not emit:
{
"provenance": {
"engine": "@attestwire/en16931",
"engine_version": "0.14.1",
"ruleset": "en16931@0.14.1",
"profile": "xrechnung-ubl",
"kosit_conformance": {
"recorded": "2026-09-24",
"validator": "1.6.3",
"configuration": "3.0.2",
"configuration_published": "2026-08-31"
}
}
}
engine/engine_version— the npm package every compliance decision here comes from, and the exact release of it. Install that version locally and you have the build that judged your document.ruleset— the standard and our build of it, as one identifier to quote.profile— the CIUS that actually ran. Read from the result, not from your request: a body that names no profile is judged asen16931, and this is where you see that.kosit_conformance— when the official KoSIT validator was last run against this engine's fixtures, and with which validator and configuration versions. Nothing is sent to KoSIT when you call us — this is a dated record of a check we ran, not a per-call verification.
?format=xml on /v1/generate is the one metered
response without it: it returns the document alone, and putting our metadata
inside a customer's tax document is not something we will do. Ask for the JSON
envelope, or read /v1/versions.
Watching for new rules
GET /v1/versions is free, unauthenticated and cacheable. It
reports the running engine and rule set, the KoSIT record, and a release list
where each entry carries the rule ids that release first named:
{
"engine": "@attestwire/en16931",
"engine_version": "0.14.1",
"ruleset": "en16931@0.14.1",
"rule_id_count": 321,
"kosit_conformance": {
"recorded": "2026-09-24",
"validator": "1.6.3",
"configuration": "3.0.2",
"configuration_published": "2026-08-31"
},
"releases": [
{
"version": "0.14.1",
"date": "2026-10-04",
"rules_added": [
"BR-AE-08",
"BR-E-08",
"BR-G-08",
"BR-IC-08",
"BR-Z-08"
]
},
"… 20 earlier releases"
],
"derivation": "Each rule id is attributed to the oldest release of @attestw…"
}
Point a weekly job at it. A rule id appearing in
rules_added is the answer to "why did this document start
failing?", and it is the answer without a support ticket. The list is derived
from the engine's own changelog rather than typed, and the response says so in
derivation: an id is attributed to the oldest release whose notes
name it, which is usually — not always — the release that started
enforcing it. Treat it as where to look first, not as a specification.
Validation records
Add ?record=true to a /v1/validate call and you
get back a URL you can send to somebody else. It costs nothing extra, it
works on the free plan, and it stores a fingerprint of your invoice rather than
your invoice.
The problem it solves is the one that starts after the API answers. You validated the document; now a buyer's accounts-payable team, an auditor or a platform onboarding desk wants to see that you did. Pasting a JSON body into an email proves nothing about who produced it or when. A record is that same answer, stored by us and served from our own copy at a URL — so whoever holds the link sees exactly what Attestwire recorded.
curl -X POST "https://api.attestwire.com/v1/validate?record=true" \
-H "authorization: Bearer $ATTESTWIRE_API_KEY" \
-H "content-type: application/xml" \
--data-binary @invoice.xml
The 200 is the response you already get, with one field added:
{
"record": {
"id": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"url": "https://api.attestwire.com/r/a1b2c3d4e5f60718293a4b5c6d7e8f90",
"json": "https://api.attestwire.com/v1/records/a1b2c3d4e5f60718293a4b5c6d7e8f90",
"expires": "2027-08-14T09:12:33.000Z"
}
}
url is a human-readable page; json is the same object
for a machine. Neither takes an API key, and that is the point — a record
whose reader needs an Attestwire key is a record you cannot show to the people it
exists for. The 128-bit id is the credential. It is unguessable,
so anyone you send the link to can read the record and anyone you do not, cannot.
Treat the URL the way you would treat the document itself.
What is in one, and what is not
This is the property that makes a record safe to hand out. A record contains:
- the SHA-256 fingerprint of the document, so a third party can match it against their own copy of the file — nothing more;
- the verdict and the finding counts by severity;
- the findings themselves — rule id, severity, the engine's own message, and the XPath where it applied;
- the
provenanceblock: engine, version, ruleset, profile, and the KoSIT conformance record date; - when it was created and when it expires.
It does not contain the document. We fingerprint it in memory and store the fingerprint; the file is never written anywhere. It does not contain your email, your API key, or any invoice content beyond what a finding already quotes. Nothing in a record identifies which customer ran the check.
A document request fingerprints the file byte for byte, as it was sent — the
PDF itself, when you sent a PDF — so shasum -a 256 invoice.xml
(or invoice.pdf) gives you the same string. A JSON-model request has
no file to hash, so it fingerprints the model in canonical form (keys sorted,
whitespace removed) — otherwise the fingerprint would be a fact about your
HTTP client's field order rather than about your invoice. The record says which
of the two it did, in document.hash_input.
What a record states — and what it does not
Every record carries this sentence, verbatim, in the JSON and on the page:
This record states that a document with the SHA-256 fingerprint shown was checked by Attestwire engine 0.0.0 against ruleset en16931@0.0.0 on 2026-01-01, producing the findings listed. It is a record of an automated software check. It is not a certification of legal or tax compliance, not a guarantee that any platform, authority, or recipient will accept the document, and not legal advice.
(The version, ruleset and date are the real ones on a real record.) Read it as written. A record is evidence that a rule run happened and what it said. It is not a conformity assessment, it does not commit any platform or authority to accepting your document, and it is not advice. Anyone who reads it as more than that has read it wrong, which is why the wording is on the artefact rather than in a footer here.
Tamper-evidence, and checking it yourself
A record carries up to two signatures, each over the record's own canonical JSON
— object keys sorted at every level, no whitespace — and each with a
key_id naming the key that made it:
signature_ed25519— Ed25519, by the key published under Signing keys. Anybody can check it without asking us. It covers every field except the two signatures.signature— HMAC-SHA256, the signature records have carried since they shipped, kept so that nothing that already checks it breaks. It covers every field except itself, the Ed25519 signature included. Thesignaturefield is an HMAC-SHA256 over this record, which proves the record was issued by Attestwire and has not been modified since. HMAC is symmetric: the key that signs is the key that verifies and only Attestwire holds it, so checking that signature means asking us — email hello@attestwire.com with the record id. The Ed25519 signature, on a record that carries one, is the one you can check yourself.
Records made before the Ed25519 key existed carry only the HMAC. A record from a deployment with no signing key configured carries neither, and says it is unsigned.
To check the Ed25519 signature with Node.js 18 or later — no dependencies, and the canonical form restated in four lines:
// verify-record.mjs — node verify-record.mjs
import { createPublicKey, verify } from 'node:crypto';
const id = 'RECORD_ID';
const record = await (await fetch('https://api.attestwire.com/v1/records/RECORD_ID')).json();
const { keys } = await (await fetch('https://api.attestwire.com/.well-known/attestwire-signing-keys.json')).json();
// What was signed: the record without its two signatures, keys sorted, no whitespace.
const canonical = (v) =>
v === null || typeof v !== 'object' ? JSON.stringify(v)
: Array.isArray(v) ? `[${v.map(canonical).join(',')}]`
: `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;
const { signature, signature_ed25519: sig, ...signed } = record;
const key = sig && keys.find((k) => k.kid === sig.key_id);
const ok = Boolean(key) && verify(null, Buffer.from(canonical(signed)),
createPublicKey({ key, format: 'jwk' }), Buffer.from(sig.value, 'hex'));
console.log(ok ? `Valid: signed by Attestwire key ${sig.key_id}` : 'NOT VALID');
process.exitCode = ok ? 0 : 1;
Or with jq and OpenSSL 3, which prints Signature Verified
Successfully. The openssl that macOS ships is LibreSSL and cannot
verify Ed25519; Homebrew's openssl@3 can.
curl -s https://api.attestwire.com/v1/records/RECORD_ID -o record.json
jq -j -S -c 'del(.signature, .signature_ed25519)' record.json > signed.json
jq -r .signature_ed25519.value record.json | xxd -r -p > record.sig
curl -s https://api.attestwire.com/.well-known/attestwire-signing-keys.json \
| jq -r --arg kid "$(jq -r .signature_ed25519.key_id record.json)" \
'.keys[] | select(.kid == $kid) | .pem' > attestwire.pem
openssl pkeyutl -verify -pubin -inkey attestwire.pem -rawin -in signed.json -sigfile record.sig
The Node script is the reference. jq -S -c writes the same bytes for
every record, with one exception: it escapes the DEL control character, which
JavaScript does not, so a record quoting an invoice value that contains one checks
only with the script.
A valid Ed25519 signature proves the record was issued by the holder of that key and has not changed by one byte since — wherever you got the copy from. That is the assurance the record's URL alone could not give a copy that was emailed on.
Retention and limits
- 365 days, from creation, then the record is deleted and both URLs answer 404. There is no extension and no recovery — the expiry date is on the record and on the page from the moment it is made.
- Immutable. There is no update path. A record cannot be re-issued with different findings at the same address, which is what makes the URL worth anything.
- Free, and not metered separately. The validate call is charged exactly as it would be without the parameter, on every plan including free.
- 500 stored records per key per UTC day,
as a storage-abuse ceiling. Past it, the validation still succeeds and is charged
normally, and the response carries
record_unavailablewith the reason instead ofrecord. The verdict is never affected by a record that could not be stored.
/v1/parse has no equivalent parameter, and will not grow one: a parse
returns the invoice model and reaches no verdict, so there would be nothing for a
record to state.
Signing keys
What Attestwire signs for you to show others, it signs with Ed25519, and the
public keys are published at /.well-known/attestwire-signing-keys.json — so anybody can
check a signature without asking us. The address is free, needs no key and is
open to any origin.
curl -s https://api.attestwire.com/.well-known/attestwire-signing-keys.json
{
"issuer": "Attestwire",
"docs": "https://api.attestwire.com/docs#signing-keys",
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "…",
"kid": "…",
"use": "sig",
"status": "active",
"pem": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n"
}
]
}
- It is a JWK Set (RFC 7517).
kidis the name every signature quotes askey_id, and it is the key's own RFC 7638 thumbprint, so you can recompute it fromx.xis the key for any JWK library,node:cryptoincluded;pemis the same key for OpenSSL 3. statusisactivefor the key that signs today. A key that is replaced stays in the set asretiredand signs nothing new, so what it signed can still be checked years later. Akey_idyou have not seen means the key was rotated: fetch the set again (it may be cached for a day).- Take the keys from this address. A key, or a link to one, that arrives with the thing you are checking proves nothing about who made it.
- A deployment with no signing key configured publishes
"keys": []and anote, and everything that would have been signed says it is unsigned instead. Nothing fails because a key is missing.
Rate limits and quotas
The quota is documents per calendar month, counted per key. One document is one
/v1/validate, /v1/parse, /v1/generate or
/v1/bundle call, or one invoice e-mailed by /v1/send. /v1/keys, /v1/usage, /v1/health,
/v1/versions and this page are free.
Every metered response carries the current state, so you never have to guess:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 97
X-RateLimit-Reset: 1756684800
X-RateLimit-Period: 2026-08
Once per month, the response that takes you past 80% of the
allowance also carries X-Quota-Notice: one sentence naming the
documents left and the next plan up. It never repeats and it changes nothing
else about the response.
Counters reset at 00:00 UTC on the 1st of each month. Over the
limit you get 429 with error: "quota_exceeded" and an
upgrade link — and the refused call does not consume a document, so a
retry loop cannot push your reset further away.
More generally: a document is charged when we hand you an answer.
A 400 (unreadable body), a 422 from
/v1/generate or /v1/bundle (invoice not valid, so no XML
and no bundle), a 429 and a 5xx all consume nothing. A 200 from
/v1/validate costs one document whether the verdict is valid or
invalid — the verdict is the work.
Per-second pace limit
/v1/validate, /v1/generate and the other metered routes
— /v1/parse, /v1/bundle, /v1/send —
are limited to 10 requests per second per key, with a
bucket that lets a short burst of 20 through so a batch job
that submits a handful of invoices in one tick is not punished for it. Go over
and you get 429 with error: "rate_limited" — a
different code from quota_exceeded, deliberately — plus a
Retry-After header. Nothing is charged, your monthly allowance is
untouched, and the same request succeeds a second later.
Two honest caveats. First, the limit is enforced per Worker isolate, not globally: we run in many isolates across many locations, so a client that sprays connections wide can exceed the nominal rate. We are not pretending otherwise — a global limit would mean a cross-datacentre round trip on every single request, which is a real latency cost on the hot path to defend against a case the monthly quota already bounds. The quota is the backstop; this is a pace limit that stops runaway loops and mis-configured crons, which is what actually happens. Second, the number above is what we run today and may be raised or lowered without a version bump; if you need sustained throughput beyond it, email us and we will tell you honestly whether it will hold.
Very large responses
A findings array is capped at 500 entries per
severity. Past that, the array is truncated and the response gains a top-level
findings_truncated object:
{
"valid": false,
"errors": [ /* 500 findings */ ],
"warnings": [],
"information": [],
"findings_truncated": {
"total": 20012,
"returned": 500,
"cap": 500,
"by_severity": {
"errors": { "total": 20012, "returned": 500 },
"warnings": { "total": 0, "returned": 0 },
"information": { "total": 0, "returned": 0 }
},
"note": "…"
}
}
The field is absent unless something was actually cut, so a
normal response is exactly what it always was. valid is never
affected — it reflects the whole document, not the part we printed.
You are very unlikely to meet this. Findings are per rule per line, so reaching 500 of them takes on the order of a hundred-plus invoice lines on which every field is wrong — a mapping failure rather than an invoice. The cap exists because without it a 1 MB body of line spam could produce a multi-tens-of-megabyte response that helps nobody: at that scale the errors are systematic, and every distinct problem in the document already appears in the first few lines listed.
Need local processing instead?
The hosted API is the simplest managed path and works from any backend language. The local library is an alternative for JavaScript and TypeScript applications that must keep processing inside their own environment.
Stay with the hosted API
Call it from PHP, Python, Java, C#, Go or any backend language: the quickstart shows each call in all of them. We run the service and deploy rule updates; usage follows your monthly plan.
Use the local TypeScript library
The MIT library works offline with no key or network call. Your team installs updates and operates the integration.
npm install @attestwire/en16931
Both paths read and write UBL and CII XML. In the library, use
parseUbl(xml), parseCiiInvoice(xml),
generateXRechnungUBL and generateCii. In the API,
POST /v1/validate accepts XML and PDF files; over MCP, use
validate_invoice_xml. UBL 2.1 uses Invoice or
CreditNote; CII uses CrossIndustryInvoice for an
invoice or a credit note. Set invoiceTypeCode to
"381" for a credit note.
Both paths open a PDF, and neither judges its PDF/A conformance.
Factur-X and ZUGFeRD place CII XML inside a PDF/A-3 container. The library's
validate(bytes) and the API's /v1/validate both take
the PDF, judge the XML attached to it and report what the attachment and the
XMP metadata get wrong as AW-PDF-* findings; the PDF/A-3 container's
own conformance, and whether the page agrees with the XML, are checked by neither.
Only the API builds one (?format=pdf, watermarked on the free plan),
and over MCP validate_invoice_xml takes one base64-encoded, as
pdf_base64. Reading an invoice
here is a pre-flight, not a verdict; a document can still be rejected by KoSIT
or a receiving platform.
Pricing
| Plan | Documents / month | Price |
|---|---|---|
| Free | 100 | €0 |
| Solo | 1,000 | €9/mo |
| Starter | 5,000 | €29/mo |
| Scale | 50,000 | €99/mo |
Prices are in EUR, plus VAT where due. Starter and Scale can each also be billed yearly, at 10 months' price: €290/yr for Starter and €990/yr for Scale. The allowance is still monthly and still resets on the 1st.
Every plan includes the Factur-X / ZUGFeRD PDF
(?format=pdf), and it costs one document, like the XML. On the free
plan it is a preview with a watermark on every page; the paid plans return it
without one. Checking a Factur-X PDF you were sent is on
every plan too: send it to /v1/validate.
Upgrading
Self-serve, through Paddle-hosted checkout — card details never touch our
servers. Post your key and the plan you want, then send the customer to the
url that comes back:
curl -X POST https://api.attestwire.com/v1/billing/checkout \
-H 'content-type: application/json' \
-d '{"key":"aw_live_…","plan":"starter"}'
{
"url": "https://attestwire.com/pay?_ptxn=txn_…",
"transaction_id": "txn_…",
"plan": "starter",
"portal_endpoint": "/v1/billing/portal"
}
plan is solo, starter or scale.
Starter and Scale can also take yearly billing: add
"period": "annual" (or send starter_annual /
scale_annual as the plan). The key's tier
changes when the payment confirms, so the new allowance applies from the next
request after checkout completes — no redeploy, no new key.
Paddle is the merchant of record. The charge on the card statement is Paddle's, not Attestwire's, and Paddle — not us — registers, collects and remits VAT and sales tax on the sale. The receipt it emails is the tax document; it carries the VAT treatment and a link to the billing portal.
The body is the canonical form, and it is what the upgrade form on
attestwire.com/pricing sends. Server-side callers may put the key in an
Authorization: Bearer aw_live_… header instead, matching every
other endpoint here; the header wins if you send both. This is the one
account-level endpoint a browser may call — see CORS — and
only from https://attestwire.com, with content-type
as the only permitted request header.
Managing or cancelling your subscription
GET/v1/billing/portal free
curl https://api.attestwire.com/v1/billing/portal \
-H "authorization: Bearer aw_live_YOUR_KEY"
{ "url": "https://customer-portal.paddle.com/cpl_…", "note": "…" }
Open that url to change plan, update the card, download
every invoice, and cancel, without emailing us. POST
works identically — it reads like a fetch and creates a session at Paddle's
end, so both verbs are accepted.
The link is one-time and it expires. Paddle mints a session bound to your customer record rather than publishing a login page, so there is no permanent URL to bookmark: ask for a fresh one each time. Treat it like a password — anyone who opens it is treated as the account holder — and do not put it in a shared inbox or a chat channel. Every receipt Paddle emails carries the same link, so a customer who cannot reach the API still has one.
A key with no subscription behind it — a free key, or a tier we granted by
hand — gets 409 no_subscription: the route is fine, there is
simply nothing to manage.
Changing plan goes through the portal, never through a second
checkout. Posting to /v1/billing/checkout again while you
are already subscribed returns 409 already_subscribed and
deliberately does nothing: it would create a second subscription and
bill you for both plans. A portal switch is prorated, and your key follows the
new price automatically — no redeploy and no new key.
Cancelling takes effect at the end of the period you have paid for; you keep the allowance you bought until then, and the key drops to the free tier afterwards rather than stopping. Nothing is deleted and no data is lost.
You never have to come back to this page to find it: GET /v1/usage
returns portal_endpoint for any paid key, and the checkout
response carries the same field — so an integration that has either one already
knows where to send its user. It is the endpoint rather than a URL because
there is no durable URL to give; see above.
If a payment fails
Renewals fail for boring reasons — an expired card, a bank's fraud hold — and the overwhelming majority recover. So a failed charge does not cut you off:
| State | What happens to your key |
|---|---|
Payment failed, Paddle retrying (past_due) |
Full paid allowance, unchanged. GET /v1/usage starts
reporting "past_due": true with an explanation — that is
your warning, and it is the only one the API can give you. |
Every retry failed (canceled) |
Drops to the free tier (100/month). The key keeps working; only the allowance shrinks. |
| Cancelled | Cancellation takes effect at period end, so you keep the paid allowance until the period you already paid for is over, then drop to free. Nothing is deleted. |
Nothing here revokes a key or deletes data — a downgrade only changes the
monthly limit. Poll /v1/usage if you want to detect billing
trouble programmatically.
Invoice Fix Report — $99, one payment
A rejected invoice and no idea why is a different problem from "we need an API". The Fix Report is for that day: one payment of $99, no subscription, nothing to cancel. It is the only thing here that is not billed monthly.
What you get, and it is three things — the third is a person:
- 30 days on Starter (5,000 documents/month), so you can run every version of the invoice through the validator while you fix it rather than rationing attempts.
-
The signed Validation Record for each run.
Add
?record=trueto your/v1/validatecall and the response carries a public URL. That link is the thing you send back to the platform or the buyer who rejected the file — they need no key, no account, and no explanation from you. - A human reading your findings and writing back. Email hello@attestwire.com with your transaction id, and attach the failing invoice or the Validation Record URL. Nothing in the API delivers this half — the email is the step that gets it, and it is included in what you already paid.
Claiming it
POST/v1/fix/claim no key
curl -X POST https://api.attestwire.com/v1/fix/claim \
-H 'content-type: application/json' \
-d '{"transaction_id":"txn_..."}'
{
"key": "aw_live_...",
"tier": "starter",
"monthly_limit": 5000,
"expires": "2026-09-14T09:12:33.104Z",
"days": 30,
"instructions": [ "..." ]
}
The transaction id is the credential. It is the long
txn_… string on the receipt Paddle emails you, and it is
in the address bar of the page checkout returns you to. There is no key on
this route on purpose: not having one is the situation you just paid to get
out of. Every claim is checked against Paddle before a key is minted —
we never take the client's word for a purchase.
The key is displayed exactly once, in that response, exactly
like every other Attestwire key: we store only its SHA-256 hash, so nobody
here can read it back to you afterwards (why).
Store it before you close the tab. A second claim answers
409 already_claimed — not a second key, and never a reason
to buy again; email hello@attestwire.com
with the transaction id and a replacement is a support action.
A mistyped, unsettled or unrelated transaction id all answer the same
404 claim_not_found, so the route cannot be used to find out
which transaction ids exist. If the payment has only just gone through, wait
a minute and try once more. Claims are limited to
10 attempts per address per day; a real claim
needs one.
After 30 days
The key keeps working and drops to the free tier
(100 documents/month). Nothing is switched off, nothing is
deleted, and the Validation Records you created stay readable for their full
year — only the monthly allowance changes.
GET /v1/usage reports the date as tier_expires before
and after it passes, so an integration can see it coming. If you want to carry
on at Starter, upgrade the
same key — there is nothing to migrate.
MCP server
Attestwire is also a Model Context
Protocol server, so an agent can use it as a tool rather than as an HTTP
API. Endpoint: https://api.attestwire.com/mcp, streamable HTTP transport,
stateless.
Six of the fourteen tools
are free and need no key —
explain_rule (321 rule ids explained in plain English —
every rule id this validator can report),
check_vies_status, check_french_readiness, list_approved_platforms, issue_api_key and send_feedback.
Connect with no credential at all and those work immediately.
validate_invoice, validate_invoice_xml, generate_invoice, diagnose_invoice, lookup_peppol_participant, verify_vat_vies and get_recipient_profile need a key and
cost one document each, exactly like the HTTP
endpoints they wrap — and they share those endpoints' per-second pace
limit, off the same buckets, so a key does not get one allowance here and
another over HTTP. The remaining
one,
get_upgrade_link, needs a key
but costs no documents: it returns a hosted checkout URL and buys nothing on
its own. Pass the key as an Authorization: Bearer
header on the server, not as a tool argument — get_upgrade_link
is the single tool with an optional key argument, for the case
where a key was minted a minute ago and is not in any config yet, and even
there the header is preferred because an argument is written into the agent's
transcript.
The metered tools draw on the same monthly allowance as the HTTP API:
100 documents a month on the free key, and
the paid plans above it (Solo: 1,000 documents/month for €9/month; Starter: 5,000 for €29/month; Scale: 50,000 for €99/month (EUR, plus VAT where due)).
Every metered tool result ends with the documents left, the plan and the
reset date, and the call that crosses 80% of the allowance carries a one-time
notice naming the next plan — so an agent sees the ceiling before it meets
quota_exceeded. (Over HTTP the same numbers ride the
X-RateLimit-* headers.)
Two stages: mint the key, then configure it and reconnect
Minting a key does not authenticate the connection it was minted
on. This server reads the credential from the request
(Authorization: Bearer, or x-api-key for clients
that only offer that), sent by the client from its own configuration; the
stdio bridge reads ATTESTWIRE_API_KEY once when the process
starts. Nothing a tool returns mutates either. So a session that calls
issue_api_key and immediately retries a metered tool gets the
same missing_api_key back, and there is no automatic
reauthentication to wait for.
- Connect with no credential and use the six free tools.
- Mint a key — call
issue_api_keywith an email address, or use the browser form at attestwire.com/pricing#keyform, which keeps a one-time secret out of a chat transcript entirely. - Save it outside the chat. It is returned once and only its SHA-256 hash is stored; there is no second copy to ask for.
- Configure it in the MCP client: the header for the remote
HTTP transport,
ATTESTWIRE_API_KEYfor the stdio bridge. Both shapes are below. - Restart or reconnect the MCP server. A client reads its config at connect time and a live session keeps the credential it started with.
- Verify with one metered call. A second
missing_api_keymeans the config did not take effect — it never means the key needs minting again, and asking for a second free key on the same address returns409.
validate_invoice_xml takes the invoice file itself, so an agent
handed an .xml attachment does not have to retype it into a JSON
object — retyping loses fields and invents others. It reads
both syntaxes and picks the reader itself, so an agent that
cannot tell UBL from CII does not have to guess. There is deliberately no
second tool for CII: an agent usually does not know which syntax it is
holding, and the server always can.
A Factur-X or ZUGFeRD PDF goes to the same tool as pdf_base64: the
file's bytes, base64-encoded, because a PDF does not survive being turned into
text. It is read exactly as POST /v1/validate reads an
application/pdf body, so the agent gets the same findings, the
container's AW-PDF-* ones included, for the same one document;
PDF/A conformance itself is not checked there either. The whole MCP call is
capped at 1 MB, which is about 750 KB of PDF once encoded; a larger
file goes to POST /v1/validate, up to 10 MB.
Claude Code
claude mcp add --transport http attestwire https://api.attestwire.com/mcp
# with a key, so validate_invoice and generate_invoice work.
# remove first: adding over an existing entry is the step people skip,
# and the result looks like a broken key rather than a stale config.
claude mcp remove attestwire
claude mcp add --transport http attestwire https://api.attestwire.com/mcp \
--header "Authorization: Bearer aw_live_..."
Start a new session afterwards. The header is attached when the client connects, so a session already running keeps the credential it started with.
Claude Desktop
Settings → Connectors → Add custom connector, with the URL above. The credential belongs to the connector, not to the conversation: after adding or changing it, restart the app before expecting a metered tool to work.
Clients that can only spawn a local process
@attestwire/mcp is a published stdio bridge — zero dependencies,
it moves JSON-RPC between stdio and this endpoint and does nothing else. Paste
into claude_desktop_config.json, .cursor/mcp.json, or
whatever your client calls its MCP config:
{
"mcpServers": {
"attestwire": {
"command": "npx",
"args": ["-y", "@attestwire/mcp"],
"env": { "ATTESTWIRE_API_KEY": "aw_live_..." }
}
}
}
Drop the env block to run keyless — the six free tools work
straight away. The bridge reads ATTESTWIRE_API_KEY once, when the
process starts, so adding or changing it takes effect on the next restart and
not before. Prefer the remote HTTP transport above where your client supports
it: it is one fewer moving part and new tools appear without an upgrade.
Cursor
In .cursor/mcp.json (or the global ~/.cursor/mcp.json):
{
"mcpServers": {
"attestwire": {
"url": "https://api.attestwire.com/mcp",
"headers": { "Authorization": "Bearer aw_live_..." }
}
}
}
Omit the headers / env block entirely to run keyless.
The agent can then call issue_api_key to mint one, which is
returned once and cannot be recovered — save it, paste it into the block
above, and restart the client. The minted key does not authenticate the
session that minted it.
For agents
openapi.json is the full OpenAPI 3.1 document, and postman.json is a Postman collection generated from that document — import it and the first request works. Teaching errors are written to be pasted straight into a coding agent's context: rule id, requirement, and fix, with no spec lookup required. For tool-calling agents, the MCP server is usually the better door.
Diagnosis, Peppol and live VAT checks
Open the invoice-check workbench or call these authenticated JSON endpoints:
POST /v1/diagnose: supplyinvoiceorxml, with optional providerrejectiontext or SVRL. Revalidate reviewed edits usingexpected_sha256andconfirmed:true.POST /v1/peppol/lookup: supplyparticipant_idas scheme:value, optionallyinvoice_participant_idanddocument_type(invoice or credit_note). Uses OpenPeppol's public production lookup. Checks advertised document support, not endpoint certificates or delivery.POST /v1/vat/verify: supplyvat_numberwith country prefix. Live EU VIES response: valid, invalid, or unavailable; one bounded retry and no Attestwire cache.POST /v1/recipient: supplycountryandid, optionallyid_typeandseller_country. The identifier checked, VIES, Peppol and the French register, and how to invoice the buyer: see Profiling a recipient.
Each completed diagnosis or conclusive live check costs one document from the existing allowance. Unavailable live checks cost nothing. All results include dated evidence returned only to you; no invoice or lookup-result storage. Receipts are HMAC-signed if the deployment's record signing key is configured, otherwise explicitly unsigned. HMAC verification requires Attestwire. Identifiers go to the named public provider. Neither lookup supplies a tax determination, delivery confirmation, nor acceptance guarantee.
MCP: diagnose_invoice, lookup_peppol_participant, verify_vat_vies, get_recipient_profile. Same key and metering as HTTP. See OpenAPI for request schemas.