Attestwire API
Menu

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.

Base URL https://api.attestwire.com  ·  Machine-readable: OpenAPI 3.1, Postman collection

Create a free key

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:

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 overDetail
Your planTier, limit and any past_due flag are unchanged.
This month's usageThe counter follows you. Rotating does not hand you a fresh allowance, and it does not lose the documents you have already used.
Your subscriptionThe 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 datecreated 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:

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)
}

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" }
}
OptionTakesWhat it does
languageen, de, frThe 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.
logostringA 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.
paymentQrbooleanThe 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.
paymentLinkstringAn 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 saysProfilerule
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 allXRechnung UBL, xrechnung-ublleitweg-id
the buyer is in BelgiumPeppol BIS, peppol-bis-3belgium
the buyer's electronic address is a Peppol participant identifier (any Peppol scheme but 0225, the French platforms' address)Peppol BIS, peppol-bis-3peppol-address
the buyer is in FranceFactur-X, facturx-en16931france
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-en16931germany-austria-business
none of thesecore en16931; with ?format=pdf, facturx-en16931default

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:

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.

StatuserrorWhat went wrong
415unsupported_media_typeA 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.
415unsupported_syntaxNot 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.
400xml_malformedNot 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.
400xml_rejectedWell-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.
400pdf_unreadableA PDF that cannot be read: it stops part-way, is corrupt or encrypted, or uses a compression filter the reader does not implement.
413xml_limit_exceededUnder the body ceiling, but past the reader's depth (100), element (50,000), attribute (256) or character (8,000,000) cap.
413pdf_limit_exceededA 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:

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:

SourceAsked forWhat it gives
VIESthe VAT number given, or derived from a SIREN or a KBO numbervalid, 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/SMPat 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 Directorythe same identifiersthe business card, when the buyer's access point publishes one; absence says nothing about registration
recherche-entreprisesa SIREN, given or inside a SIRET or a French VAT numbername, 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 buyerchannelformatBT-49BT-10
German public buyer (a Leitweg-ID) registered on PeppolpeppolXRechnung, xrechnung-ubl0204, the Leitweg-IDrequired, the Leitweg-ID (BR-DE-15)
… federal (the ID starts 99), not found on Peppolozg-reXRechnung, xrechnung-ubl0204, the Leitweg-IDrequired, the Leitweg-ID
… state or municipal, not found on Peppolpublic-portalXRechnung, xrechnung-ubl0204, the Leitweg-IDrequired, the Leitweg-ID
French buyerfrench-pafacturx-en16931, as the PDF0225, the SIREN (confirm it: the annuaire has no open API)not required
Any other buyer registered on Peppolpeppolthe first of peppol-bis-3, xrechnung-ubl, xrechnung-cii it advertisesits participant identifierPeppol BIS: BT-10 or BT-13 (PEPPOL-EN16931-R003)
Belgian buyer not found on Peppolpeppol, with a warningPeppol BIS, peppol-bis-30208, the KBO numberBT-10 or BT-13
German or Austrian business not on Peppolemailfacturx-en16931, as the PDF9930 or 9914, the VAT numbernot required
Anyone else not foundunknowncore en16931the country's VAT scheme, when there is onenot 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
FileWhat it holds
<invoice number>.xmlThe 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>.pdfWhen 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.jsonEvery 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.htmlThe same report as a page, readable offline: it loads nothing from the network.
manifest.jsonEvery other file's name, size and SHA-256, the engine version, the time, and the signing key's id.
manifest.sigAn Ed25519 signature over the exact bytes of manifest.json.
README.txtWhat the bundle is for, and the commands below that check it.

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": "{ … }"
}

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 43 fields, 7 required. The invoice as JSON: the body of POST /v1/validate (as application/json) and of POST /v1/generate, and the invoice POST /v1/parse returns.

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.

FieldTypeRequiredBT / BGWhat it is
profileone of en16931 xrechnung-ubl xrechnung-cii facturx-en16931 peppol-bis-3 autorequiredSelects the rule set, and on POST /v1/generate the syntax.
invoiceNumberstringrequiredBT-1Invoice number.
issueDatedaterequiredBT-2Issue date, ISO 8601.
currencystringrequiredBT-5Invoice currency code, ISO 4217.
invoiceTypeCodestringBT-3Document type code, UNTDID 1001: "380", the default, is a commercial invoice and "381" a credit note.
dueDatedateBT-9Payment due date, ISO 8601.
notestringBT-22Invoice note, free text.
noteSubjectCodestringBT-21Invoice note subject code, UNCL 4451.
buyerReferencestringBT-10Buyer reference, which XRechnung requires (BR-DE-15).
orderReferencestringBT-13Purchase order reference.
salesOrderReferencestringBT-14Sales order reference: your own order number, not the buyer's.
projectReferencestringBT-11Project reference.
contractReferencestringBT-12Contract reference.
despatchAdviceReferencestringBT-16Despatch advice reference.
receivingAdviceReferencestringBT-15Receiving advice reference.
tenderOrLotReferencestringBT-17Tender or lot reference.
invoicedObjectIdentifierSchemedIdentifierBT-18Invoiced object identifier, with its BT-18-1 scheme from UNTDID 1153.
buyerAccountingReferencestringBT-19Buyer accounting reference: the buyer's cost centre or ledger account.
taxPointDatedateBT-7Value added tax point date, ISO 8601.
invoicingPeriodInvoicingPeriodBG-14Invoicing period: the period the supply relates to.
precedingInvoiceslist of PrecedingInvoiceReferenceBG-3Preceding invoice references: the invoices this document corrects, credits or settles against.
sellerPartyrequiredBG-4Seller.
buyerPartyrequiredBG-7Buyer.
payeePayeeBG-10Payee, when payment goes to someone other than the seller.
taxRepresentativeTaxRepresentativeBG-11Seller tax representative, for a seller registered through a fiscal representative.
vatScenarioone of domestic intra-eu-goods intra-eu-services export small-business-exemptionWhat 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.
lineslist of InvoiceLinerequiredBG-25Invoice lines, at least one (BR-16).
allowanceslist of DocumentAllowanceChargeBG-20Document level allowances: discounts on the whole invoice.
chargeslist of DocumentAllowanceChargeBG-21Document level charges: freight, packing, surcharges.
paymentTermsstringBT-20Payment terms, free text.
paymentPaymentInstructionsBG-16Payment instructions, which XRechnung requires (BR-DE-1).
supportingDocumentslist of SupportingDocumentBG-24Additional supporting documents.
deliveryDatedateBT-72Actual delivery date, ISO 8601.
deliverToDeliverToAddressBG-15Deliver-to address.
deliverToNamestringBT-70Deliver-to party name.
deliverToLocationIdSchemedIdentifierBT-71Deliver-to location identifier, with its ISO 6523 ICD scheme.
vatAccountingCurrencystringBT-6VAT accounting currency, ISO 4217, when VAT is reported in a currency other than BT-5.
taxAmountInAccountingCurrencynumberBT-111Invoice total VAT amount in the VAT accounting currency.
paidAmountnumberBT-113Paid amount: a prepayment or deposit already received.
roundingAmountnumberBT-114Rounding amount, added to the total to produce a payable round figure.
vatExemptionReasonsmap of S Z E AE K G O L M to stringBT-120VAT exemption reason text, keyed by VAT category, such as {"E": "…"}.
vatExemptionReasonCodesmap of S Z E AE K G O L M to stringBT-121VAT exemption reason code from the CEF VATEX list, keyed by VAT category.
declaredTotalsDeclaredTotalsTotals you computed yourself, checked against ours.
Party 12 fields, 2 required. BG-4 seller or BG-7 buyer.

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.

FieldTypeRequiredBT / BGWhat it is
namestringrequiredBT-27 / BT-44Name, written as both the legal name and the trading name (BT-28 / BT-45) unless legalName or tradingName says otherwise.
vatIdstringBT-31 / BT-48VAT identifier, with its country prefix.
taxRegistrationIdstringBT-32Seller tax registration identifier, such as the German Steuernummer.
legalRegistrationIdstringBT-30 / BT-47Legal registration identifier, such as an HRB number.
legalRegistrationSchemeIdstringBT-30-1 / BT-47-1ISO 6523 ICD scheme of the legal registration identifier.
identifierSchemedIdentifierBT-29 / BT-46Seller or buyer identifier, with its BT-29-1 / BT-46-1 ISO 6523 ICD scheme.
additionalLegalInformationstringBT-33Seller additional legal information: legal form, share capital and the like.
tradingNamestringBT-28 / BT-45Trading name, when the party trades under a name other than its legal one.
legalNamestringBT-27 / BT-44Registered legal name, when it differs from name.
addressPostalAddressrequiredBG-5 / BG-8Postal address.
electronicAddressElectronicAddressBT-34 / BT-49Electronic address: where the network delivers the document, such as a Peppol participant identifier.
contactContactBG-6 / BG-9Contact, which XRechnung requires in full for the seller: name, phone and email (BR-DE-2/5/6/7).
PostalAddress 7 fields, 3 required. A postal address: BG-5 seller, BG-8 buyer or BG-12 tax representative.

The terms are listed in that order.

FieldTypeRequiredBT / BGWhat it is
line1stringBT-35 / BT-50 / BT-64Address line 1.
line2stringBT-36 / BT-51 / BT-65Address line 2.
line3stringBT-162 / BT-163 / BT-164Address line 3.
citystringrequiredBT-37 / BT-52 / BT-66City.
postalCodestringrequiredBT-38 / BT-53 / BT-67Post code.
countrySubdivisionstringBT-39 / BT-54 / BT-68Country subdivision: a Bundesland, a région, a county.
countryCodestringrequiredBT-40 / BT-55 / BT-69Country code, ISO 3166-1 alpha-2.
Contact 3 fields, all optional. BG-6 seller contact or BG-9 buyer contact.
FieldTypeRequiredBT / BGWhat it is
namestringBT-41 / BT-56Contact point: a person or a department.
emailstringBT-43 / BT-58Contact email address.
phonestringBT-42 / BT-57Contact telephone number.
ElectronicAddress 2 fields, 2 required. BT-34 seller or BT-49 buyer electronic address: where the network delivers the document.

Both halves are required.

FieldTypeRequiredBT / BGWhat it is
schemeIdstringrequiredBT-34-1 / BT-49-1Scheme identifier, from the Peppol EAS code list.
valuestringrequiredBT-34 / BT-49The address itself.
SchemedIdentifier 2 fields, 1 required. An identifier with its scheme.

The field that holds it names the business term, and which code list the scheme comes from.

FieldTypeRequiredBT / BGWhat it is
valuestringrequiredThe identifier.
schemeIdstringIts scheme identifier: ISO 6523 ICD for party, location and item identifiers, UNTDID 1153 for the invoiced object and line object identifiers.
InvoiceLine 25 fields, 6 required. BG-25 invoice line.
FieldTypeRequiredBT / BGWhat it is
idstringrequiredBT-126Invoice line identifier.
descriptionstringrequiredBT-153Item name.
longDescriptionstringBT-154Item description, when it should say more than the item name.
quantitynumberrequiredBT-129Invoiced quantity.
unitCodestringrequiredBT-130Unit of measure code, UN/ECE Recommendation 20.
unitPricenumberrequiredBT-146Item net price.
grossUnitPricenumberBT-148Item gross price, before the BT-147 price discount.
priceDiscountnumberBT-147Item price discount: the difference between BT-148 and BT-146.
baseQuantitynumberBT-149Item price base quantity, 1 when absent.
vatCategoryone of S Z E AE K G O L MrequiredBT-151Invoiced 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).
vatRatenumberBT-152Invoiced item VAT rate, in percent.
vatScenarioone of domestic intra-eu-goods intra-eu-services export small-business-exemptionWhat 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.
notestringBT-127Invoice line note.
objectIdentifierSchemedIdentifierBT-128Invoice line object identifier, with its BT-128-1 scheme from UNTDID 1153.
buyerAccountingReferencestringBT-133Invoice line buyer accounting reference.
periodInvoicingPeriodBG-26Invoice line period.
orderLineReferencestringBT-132Referenced purchase order line reference.
allowanceslist of LineAllowanceChargeBG-27Invoice line allowances: discounts on this line only.
chargeslist of LineAllowanceChargeBG-28Invoice line charges: surcharges on this line only.
sellerItemIdstringBT-155Item seller's identifier.
buyerItemIdstringBT-156Item buyer's identifier.
standardItemIdSchemedIdentifierBT-157Item standard identifier, such as a GTIN, with its BT-157-1 ISO 6523 ICD scheme.
itemClassificationslist of ItemClassificationBT-158Item classification identifiers.
originCountryCodestringBT-159Item country of origin, ISO 3166-1 alpha-2.
itemAttributeslist of ItemAttributeBG-32Item attributes: name and value pairs describing the item.
LineAllowanceCharge 5 fields, 1 required. BG-27 invoice line allowance or BG-28 invoice line charge; the first term of each pair is the allowance's.

It has no VAT category of its own: it changes the line net amount (BT-131) and takes the line's VAT treatment.

FieldTypeRequiredBT / BGWhat it is
amountnumberrequiredBT-136 / BT-141Amount, always positive.
baseAmountnumberBT-137 / BT-142Base amount.
percentagenumberBT-138 / BT-143Percentage.
reasonstringBT-139 / BT-144Reason, free text.
reasonCodestringBT-140 / BT-145Reason code: UNCL 5189 for an allowance, UNCL 7161 for a charge.
ItemClassification 3 fields, 2 required. BT-158 item classification identifier.
FieldTypeRequiredBT / BGWhat it is
codestringrequiredBT-158The classification code.
schemeIdstringrequiredBT-158-1The scheme it is drawn from, UNTDID 7143.
schemeVersionstringBT-158-2The version of that scheme.
ItemAttribute 2 fields, 2 required. BG-32 item attribute.

Both halves are mandatory once the group exists (BR-54).

FieldTypeRequiredBT / BGWhat it is
namestringrequiredBT-160Item attribute name.
valuestringrequiredBT-161Item attribute value.
DocumentAllowanceCharge 8 fields, 2 required. BG-20 document level allowance or BG-21 document level charge; the first term of each pair is the allowance's.

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.

FieldTypeRequiredBT / BGWhat it is
amountnumberrequiredBT-92 / BT-99Amount, always positive.
baseAmountnumberBT-93 / BT-100Base amount.
percentagenumberBT-94 / BT-101Percentage.
vatCategoryone of S Z E AE K G O L MrequiredBT-95 / BT-102VAT 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).
vatRatenumberBT-96 / BT-103VAT rate, in percent.
vatScenarioone of domestic intra-eu-goods intra-eu-services export small-business-exemptionWhat 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.
reasonstringBT-97 / BT-104Reason, free text.
reasonCodestringBT-98 / BT-105Reason code: UNCL 5189 for an allowance, UNCL 7161 for a charge.
PaymentInstructions 8 fields, 1 required. BG-16 payment instructions.

Required for XRechnung profiles by BR-DE-1.

FieldTypeRequiredBT / BGWhat it is
meansCodestringrequiredBT-81Payment means type code, UNTDID 4461: "58" SEPA credit transfer, "30" credit transfer, "59" SEPA direct debit, "48" card, "57" standing order, "97" clearing.
meansNamestringBT-82Payment means text.
ibanstringBT-84Payment account identifier (BG-17): the IBAN, for a credit transfer.
accountNamestringBT-85Payment account name (BG-17).
bicstringBT-86Payment service provider identifier (BG-17): the BIC.
remittanceInformationstringBT-83Remittance information: the payment reference.
cardPaymentCardBG-18Payment card information.
directDebitDirectDebitBG-19Direct debit information.
PaymentCard 2 fields, 1 required. BG-18 payment card information.
FieldTypeRequiredBT / BGWhat it is
primaryAccountNumberstringrequiredBT-87Payment card primary account number: at most its first six and last four digits.
holderNamestringBT-88Payment card holder name.
DirectDebit 3 fields, all optional. BG-19 direct debit information.
FieldTypeRequiredBT / BGWhat it is
mandateReferencestringBT-89Mandate reference identifier.
creditorIdentifierstringBT-90Bank assigned creditor identifier: the SEPA creditor identifier.
debitedAccountstringBT-91Debited account identifier: the payer's IBAN.
InvoicingPeriod 3 fields, all optional. BG-14 invoicing period, or BG-26 when it is a line's period.

The first term of each pair is the document's.

FieldTypeRequiredBT / BGWhat it is
startDatedateBT-73 / BT-134Period start date, ISO 8601.
endDatedateBT-74 / BT-135Period end date, ISO 8601.
descriptionCodestringBT-8Value added tax point date code, UNTDID 2005 (3, 35 or 432).
PrecedingInvoiceReference 2 fields, 1 required. BG-3 preceding invoice reference.
FieldTypeRequiredBT / BGWhat it is
invoiceNumberstringrequiredBT-25The number of the invoice being corrected or referenced.
issueDatedateBT-26The issue date of that invoice, ISO 8601.
Payee 3 fields, 1 required. BG-10 payee: where the money goes, when that is not the seller.
FieldTypeRequiredBT / BGWhat it is
namestringrequiredBT-59Payee name.
identifierSchemedIdentifierBT-60Payee identifier, with its ISO 6523 ICD scheme.
legalRegistrationIdSchemedIdentifierBT-61Payee legal registration identifier, with its ISO 6523 ICD scheme.
TaxRepresentative 3 fields, 3 required. BG-11 seller tax representative party.
FieldTypeRequiredBT / BGWhat it is
namestringrequiredBT-62Tax representative name.
vatIdstringrequiredBT-63Tax representative VAT identifier.
addressPostalAddressrequiredBG-12Tax representative postal address.
DeliverToAddress 6 fields, 1 required. BG-15 deliver-to address.

Only the country code is required by the model; XRechnung also requires the city and post code once the group is present.

FieldTypeRequiredBT / BGWhat it is
line1stringBT-75Deliver-to address line 1.
line2stringBT-76Deliver-to address line 2.
citystringBT-77Deliver-to city.
postalCodestringBT-78Deliver-to post code.
countrySubdivisionstringBT-79Deliver-to country subdivision.
countryCodestringrequiredBT-80Deliver-to country code, ISO 3166-1 alpha-2.
SupportingDocument 4 fields, 1 required. BG-24 additional supporting document.
FieldTypeRequiredBT / BGWhat it is
referencestringrequiredBT-122Supporting document reference.
descriptionstringBT-123Supporting document description.
externalUristringBT-124External document location: a URL.
attachmentEmbeddedAttachmentBT-125The document itself, embedded.
EmbeddedAttachment 3 fields, 3 required. BT-125 attached document, embedded in the invoice.
FieldTypeRequiredBT / BGWhat it is
filenamestringrequiredBT-125-2Attached document filename.
mimeCodestringrequiredBT-125-1Attached document MIME code.
contentstringrequiredBT-125The document bytes, base64-encoded.
DeclaredTotals 14 fields, all optional. Totals as someone else computed them, checked against the computed ones under BR-CO-10/11/12/13/14/15/16.

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.

FieldTypeRequiredBT / BGWhat it is
syntaxone of ubl ciiSet by the readers: the syntax the document was read from.
taxTotalsInInvoiceCurrencynumberSet by the readers: how many VAT totals carry the invoice currency (BT-5).
specificationIdentifierstringBT-24Specification identifier as the document states it, trimmed; empty when absent.
lineNetAmountslist of number or nullBT-131Invoice line net amounts as each line states them, in document order; null where a line states none.
subtotalslist of DeclaredTaxSubtotalBG-23VAT breakdown as the document states it, in document order.
lineExtensionAmountnumberBT-106Sum of invoice line net amounts.
allowanceTotalAmountnumberBT-107Sum of allowances on document level.
chargeTotalAmountnumberBT-108Sum of charges on document level.
taxExclusiveAmountnumberBT-109Invoice total amount without VAT.
taxAmountnumberBT-110Invoice total VAT amount.
taxInclusiveAmountnumberBT-112Invoice total amount with VAT.
payableAmountnumberBT-115Amount due for payment.
defectslist of DeclaredTotalDefectSet by the readers: document totals the document was supposed to state and did not state readably, one entry per term.
overPreciselist of OverPreciseAmountSet by the readers: amounts the document wrote with more than two decimal places, in document order, which the BR-DEC rules report.
DeclaredTaxSubtotal 4 fields, all optional. One BG-23 VAT breakdown group exactly as the document states it.

Set by the readers.

FieldTypeRequiredBT / BGWhat it is
categoryone of S Z E AE K G O L MBT-118VAT category code, as written.
ratenumberBT-119VAT category rate, as written.
taxableAmountnumberBT-116VAT category taxable amount, as written.
taxAmountnumberBT-117VAT category tax amount, as written.
DeclaredTotalDefect 5 fields, 4 required. One document total the document failed to state readably.

Set by the readers.

FieldTypeRequiredBT / BGWhat it is
keyone of lineExtensionAmount allowanceTotalAmount chargeTotalAmount taxExclusiveAmount taxAmount taxInclusiveAmount payableAmount taxAmountInAccountingCurrencyrequiredWhich total it is, as a key of DeclaredTotals.
fieldstringrequiredThe business term, such as BT-115.
stateone of absent empty unreadablerequiredWhy 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.
textstringWhat the element held, verbatim, cut at 200 characters.
xpathstringrequiredWhere the value is in the document, or where it should have been.
OverPreciseAmount 6 fields, 4 required. A monetary amount the document wrote with more than two decimal places.

Set by the readers: a number cannot say how it was written, so they record it while reading.

FieldTypeRequiredBT / BGWhat it is
fieldstringrequiredThe business term, which selects the BR-DEC rule.
decimalsnumberrequiredDigits after the decimal point, trailing zeros included.
textstringrequiredThe amount exactly as the document writes it.
xpathstringrequiredWhere it was found.
linenumber1-based invoice line, for a line-level term (BT-131).
groupnumber1-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:

profileWhat it isSyntaxPOST /v1/validatePOST /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.

severityArrayAffects validWhat it means
fatalerrors[]yes — valid: falseThe document is rejected. Fix before sending.
warningwarnings[]noAccepted, but something is missing or unwise. Worth fixing before you ship.
informationinformation[]noAdvisory. 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"
}
StatuserrorMeaning
400bad_requestBody 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.
400invalid_emailAn e-mail address failed the format check: the signup address, or the to of POST /v1/send, which takes one plain address.
400seller_email_requiredPOST /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.
403recipient_not_allowedPOST /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.
429send_limit_exceededPOST /v1/send: this key's daily e-mail ceiling is used. Resets at 00:00 UTC; carries Retry-After. Nothing was charged.
429send_capacity_exceededPOST /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.
502email_failedPOST /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.
503email_not_enabledPOST /v1/send: e-mail delivery is not enabled on this deployment. Answered before authentication; nothing is charged.
503email_unavailablePOST /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.
400confirmation_requiredPOST /v1/diagnose with reviewed edits but without confirmed: true. Review the proposed changes, then send them again with it.
400unsupported_profileA /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.
400xml_malformedDocument request: the XML is not well-formed — sent as a file, or attached to a PDF. See above.
400xml_rejectedDocument request: a DOCTYPE or entity declaration, refused on sight.
400pdf_unreadableDocument 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.
401missing_api_keyNo Authorization: Bearer header.
401invalid_api_keyKey is unknown or revoked.
403forbidden_originThe 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.
403demo_key_not_allowedThe 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.
404not_foundNo such route.
404record_not_foundGET /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.
405method_not_allowedRight path, wrong verb.
404claim_not_foundPOST /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.
409already_claimedPOST /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.
409key_already_issuedThis email already has a free key.
409document_changedPOST /v1/diagnose: expected_sha256 does not match the document that was reviewed. Diagnose the current document again before confirming edits.
409rotation_already_doneThis key has already been rotated; the replacement was returned once.
409already_subscribedThis key already has a subscription; change plan in the billing portal (POST /v1/billing/portal) rather than buying a second one.
409no_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.
410key_rotatedThis key was replaced by a rotation. Use the key that rotation returned.
413payload_too_largeBody over 1 MB.
413xml_limit_exceededDocument request: past the XML reader's depth, element, attribute or character cap.
413pdf_limit_exceededDocument 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.
415unsupported_syntaxDocument 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.
415unsupported_media_typeA 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.
429quota_exceededMonthly document allowance used up. Body carries upgrade_url.
429rate_limitedToo 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.
429rotation_rate_limitedMore than 3 rotations of this key today.
429too_many_requestsPer-IP signup limit.
500internal_errorSomething 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.
500rotation_failedThe rotation could not be completed and nothing changed. The existing key still works.
501generation_not_yet_availableGeneration is not enabled in this build.
502billing_errorThe 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.
503billing_not_enabledCheckout 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"
    }
  }
}

?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:

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:

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

/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"
    }
  ]
}

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.

Create a free key

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

Read the library quickstart

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

PlanDocuments / monthPrice
Free100€0
Solo1,000€9/mo
Starter5,000€29/mo
Scale50,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:

StateWhat 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:

  1. 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.
  2. The signed Validation Record for each run. Add ?record=true to your /v1/validate call 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.
  3. 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.

  1. Connect with no credential and use the six free tools.
  2. Mint a key — call issue_api_key with 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.
  3. 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.
  4. Configure it in the MCP client: the header for the remote HTTP transport, ATTESTWIRE_API_KEY for the stdio bridge. Both shapes are below.
  5. Restart or reconnect the MCP server. A client reads its config at connect time and a live session keeps the credential it started with.
  6. Verify with one metered call. A second missing_api_key means 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 returns 409.

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:

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.