Attestwire API

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

profileWhat it isPOST /v1/validatePOST /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"
}
StatuserrorMeaning
400bad_requestBody was not readable JSON, or was not an object.
400invalid_emailSignup email failed the format check.
401missing_api_keyNo Authorization: Bearer header.
401invalid_api_keyKey is unknown or revoked.
404not_foundNo such route.
405method_not_allowedRight path, wrong verb.
409key_already_issuedThis email already has a free key.
409rotation_already_doneThis key has already been rotated; the replacement was returned once.
410key_rotatedThis key was replaced by a rotation. Use the key that rotation returned.
413payload_too_largeBody over 1 MB.
422Generation refused: body is a ValidationResult.
429quota_exceededMonthly document allowance used up.
429rotation_rate_limitedMore than 3 rotations of this key today.
429too_many_requestsPer-IP signup limit.
501generation_not_yet_availableGeneration is not enabled in this build.
409already_subscribedThis key already has a subscription; change plan in the billing portal.
503billing_not_enabledCheckout 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

PlanDocuments / monthPrice
Free100$0
Starter2,500$49/mo
Scale25,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:

StateWhat 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 keyexplain_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.