EN 16931 compliance, as an API
Post an invoice as JSON. Get back every rule it breaks — with the official rule id, the business term at fault, what the regulation actually requires, and the fix. No Java, no Schematron toolchain, no XSLT.
Base URL https://api.attestwire.com ·
Machine-readable: OpenAPI 3.1, llms.txt
Quickstart
1. Get a key
Free tier, no card, 100 documents a month. One key per email address.
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"
}
The key is shown once. We store only its SHA-256 hash, so we cannot look it up, email it to you, or recover it. Save it before you close the terminal; if you lose it, ask support to revoke it and issue a new one.
2. Validate an invoice
curl -X POST https://api.attestwire.com/v1/validate \
-H "authorization: Bearer $ATTESTWIRE_KEY" \
-H 'content-type: application/json' \
-d '{"profile":"xrechnung-ubl","invoiceNumber":"INV-2026-0042","issueDate":"2026-08-09","dueDate":"2026-09-08","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}]}'
{
"valid": true,
"profile": "xrechnung-ubl",
"errors": [],
"warnings": []
}
3. See a teaching error
Drop buyerReference from the invoice above and the XRechnung CIUS rejects it:
{
"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…",
"fix": "Ask your client for their Leitweg-ID (public sector) or an order/customer reference, and set buyerReference.",
"example": "\"buyerReference\": \"04011000-1234512345-06\"",
"docsUrl": "https://attestwire.com/rules/BR-DE-15"
}
],
"warnings": []
}
Errors are designed to be actionable by a person or by an agent: the
fix field is an instruction, not a restatement of the message, and
rule is the same identifier that appears in KoSIT validator
reports, so you can cross-reference an official run against ours.
Authentication
A bearer token on every metered endpoint. Keys look like
aw_live_ followed by 40 characters.
authorization: Bearer aw_live_qh4t2m…
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.
Rotating a key
Leaked a key into a git commit, a CI log, a screenshot? Replace it yourself, immediately, without opening a support ticket:
curl -X POST https://api.attestwire.com/v1/keys/rotate \
-H "authorization: Bearer aw_live_YOUR_CURRENT_KEY"
The call is authenticated by the key you are replacing — that is the proof of ownership — and it is free: rotating consumes no documents, because a security action you might put off to save quota is a security action you will not take.
Everything follows the new key, atomically:
| Carries over | Detail |
|---|---|
| Your plan | Tier, limit and any past_due flag are unchanged. |
| This month's usage | The counter follows you. Rotating does not hand you a fresh allowance, and it does not lose the documents you have already used. |
| Your subscription | The Stripe link is repointed at the new key, so renewals, plan switches and cancellations keep landing on the right account. Billing events already in flight are re-routed too. |
| Your signup date | created stays the original one; rotated_at is new. |
{
"key": "aw_live_…",
"tier": "starter",
"monthly_limit": 2500,
"period": "2026-08",
"used": 3,
"remaining": 2497,
"rotated_at": "2026-08-09T12:00:00.000Z",
"warning": "Store this key now. It is shown once and cannot be recovered. …"
}
The new key is shown exactly once, the same as at signup, and
the old key stops working the instant that response is generated — it answers
410 with error: "key_rotated" from then on. Deploy
the new key before you rotate, or rotate during a window you can watch.
Rotations are limited to 3 per key per UTC day,
and the limit travels with the key, so chaining rotations does not reset it.
Two rotate calls sent at the same time cannot both succeed: one gets the new
key, the other gets 409 rotation_already_done.
If you have lost the key entirely there is no self-serve path, 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. Email hello@attestwire.com from the address on your Stripe billing receipt and we will do it by hand.
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;
warnings never make it false.
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.
Anything else returns 400 unsupported_profile — see the
profile matrix.
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 Stripe 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/health free
Liveness, plus which capabilities this build has (generation, billing).
The invoice model
One JSON object, field names mapped to EN 16931 business terms. Full schema in the OpenAPI document; here is a complete valid example:
{
"profile": "xrechnung-ubl",
"invoiceNumber": "INV-2026-0042",
"issueDate": "2026-08-09",
"dueDate": "2026-09-08",
"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
}
]
}
Profile support
profile selects the rule set, and it also decides whether this build can generate the document:
| profile | What it is | POST /v1/validate | POST /v1/generate |
|---|---|---|---|
en16931 |
EN 16931 core (UBL syntax) | yes | yes |
xrechnung-ubl |
German XRechnung CIUS, UBL | yes | yes |
xrechnung-cii |
German XRechnung CIUS, CII | yes | no |
facturx-en16931 |
Factur-X / ZUGFeRD EN 16931 profile (CII in PDF) | yes | no |
peppol-bis-3 |
Peppol BIS Billing 3.0 | yes | yes |
Narrower profiles add rules; they never remove core ones. Generation is
UBL-syntax only today: xrechnung-cii and
facturx-en16931 are CII documents (and Factur-X is CII embedded
in a PDF/A-3), and emitting UBL under those names would produce a file that
passes nothing — so /v1/generate answers 400
unsupported_profile for them rather than something plausible and
invalid. Validation of those profiles is fully supported.
Error model
Two kinds of failure, and they are shaped differently on purpose.
Invoice problems come back inside a 200 as
errors[] / warnings[] of teaching errors. Your invoice
is wrong; the request was fine.
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.",
"docs": "https://api.attestwire.com/docs#auth"
}
| Status | error | Meaning |
|---|---|---|
| 400 | bad_request | Body was not readable JSON, or was not an object. |
| 400 | invalid_email | Signup email failed the format check. |
| 401 | missing_api_key | No Authorization: Bearer header. |
| 401 | invalid_api_key | Key is unknown or revoked. |
| 404 | not_found | No such route. |
| 405 | method_not_allowed | Right path, wrong verb. |
| 409 | key_already_issued | This email already has a free key. |
| 409 | rotation_already_done | This key has already been rotated; the replacement was returned once. |
| 410 | key_rotated | This key was replaced by a rotation. Use the key that rotation returned. |
| 413 | payload_too_large | Body over 1 MB. |
| 422 | — | Generation refused: body is a ValidationResult. |
| 429 | quota_exceeded | Monthly document allowance used up. |
| 429 | rotation_rate_limited | More than 3 rotations of this key today. |
| 429 | too_many_requests | Per-IP signup limit. |
| 501 | generation_not_yet_available | Generation is not enabled in this build. |
| 409 | already_subscribed | This key already has a subscription; change plan in the billing portal. |
| 503 | billing_not_enabled | Checkout is not configured on this deployment. |
Rate limits and quotas
The quota is documents per calendar month, counted per key. One document is one
/v1/validate or one /v1/generate call.
/v1/keys, /v1/usage, /v1/health 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
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.
There is no per-second rate limit today. Be reasonable; if you need sustained high throughput, talk to us first so we can tell you honestly whether it will hold.
Pricing
| Plan | Documents / month | Price |
|---|---|---|
| Free | 100 | $0 |
| Starter | 2,500 | $49/mo |
| Scale | 25,000 | $199/mo |
Upgrading
Self-serve, through Stripe-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://checkout.stripe.com/c/pay/cs_live_…",
"session_id": "cs_live_…",
"plan": "starter"
}
plan is starter or scale. The key's tier
changes when Stripe confirms the subscription, so the new allowance applies
from the next request after checkout completes — no redeploy, no new key.
Changing plan. Use the Stripe billing portal, linked from any
receipt we send — not a second checkout. Posting to this endpoint 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.
If a payment fails
Renewals fail for boring reasons — an expired card, a bank's fraud hold — and the overwhelming majority recover. So a failed charge does not cut you off:
| State | What happens to your key |
|---|---|
Payment failed, Stripe 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 (unpaid) |
Drops to the free tier (100/month). The key keeps working; only the allowance shrinks. |
| Cancelled | Stripe cancels at period end, so you keep the paid allowance until the period you already paid for is over, then drop to free. |
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.
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.
Five of the eight tools are free and need no key —
explain_rule (any of the 57 rule ids in plain English),
check_vies_status, check_french_readiness,
list_approved_platforms, and issue_api_key. Connect
with no credential at all and those work immediately.
validate_invoice and generate_invoice need a key and
cost one document each, exactly like the HTTP
endpoints they wrap. Pass the key as an Authorization: Bearer
header on the server, not as a tool argument.
Claude Code
claude mcp add --transport http attestwire https://api.attestwire.com/mcp
# with a key, so validate_invoice and generate_invoice work:
claude mcp add --transport http attestwire https://api.attestwire.com/mcp \
--header "Authorization: Bearer aw_live_..."
Claude Desktop
Settings → Connectors → Add custom connector, with the URL above. Or, in
claude_desktop_config.json, via the stdio bridge:
{
"mcpServers": {
"attestwire": {
"command": "npx",
"args": ["-y", "@attestwire/mcp"],
"env": { "ATTESTWIRE_API_KEY": "aw_live_..." }
}
}
}
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 before continuing.
For agents
llms.txt is a compact, plain-text description of this API for LLM consumers, and openapi.json is the full OpenAPI 3.1 document. 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.