You're upgraded. Your key's new monthly limit is live right
now — the same key, no redeploy, no new credential. The next request will see
it; GET /v1/usage confirms the tier and the allowance.
Manage or cancel any time in the
billing portal — an authenticated
POST /v1/billing/portal returns a one-time link, and the receipt
Paddle has just emailed you carries one too. Plan changes there are prorated
and your key follows the new plan automatically.
Checkout cancelled — nothing was charged. Your key still works on its current plan and your usage is untouched. Start again from Upgrading whenever you are ready, or email hello@attestwire.com if something about the flow got in the way.
Hosted EN 16931 API
Build and check EN 16931 invoices over HTTP
Generate, read and check UBL or CII for XRechnung and Peppol BIS. Send JSON or XML from any backend language and get clear errors with the rule, field and fix.
Make your first request ↓ View plans
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
Then post the file:
curl -X POST https://api.attestwire.com/v1/validate \
-H "authorization: Bearer $ATTESTWIRE_API_KEY" \
-H 'content-type: application/json' \
--data-binary @invoice.json
{
"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. xpath is a fixed UBL reference path for the field. It may not locate the value in your submitted file, especially for CII or credit notes.
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. Add
?format=xml to get the raw file instead of a JSON envelope:
curl -X POST "https://api.attestwire.com/v1/generate?format=xml" \
-H "authorization: Bearer $ATTESTWIRE_API_KEY" \
-H 'content-type: application/json' \
--data-binary @invoice.json \
-o 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. We give you the XML. We do not build
the container, attach the XML as factur-x.xml or set
/AFRelationship, so a
facturx-en16931 response is not a Factur-X document
and saving it as one will not make it work. Pass the XML to a Factur-X
packaging library if you need the PDF.
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.
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.
CORS is off almost everywhere, deliberately. No metered
endpoint sends Access-Control-Allow-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. Four routes are exempt, and none of them lets a page use a
key it was not given:
GET /v1/healthandGET /openapi.json— public and unauthenticated, so they answer any origin (*).POST /v1/keysandPOST /v1/billing/checkout— restricted tohttps://attestwire.comalone, for the signup and upgrade forms on that site.content-typeis the only permitted request header; every other origin gets no CORS headers at all and is blocked by the browser.
Browser signup is bounded by exactly the limits a script faces: five keys per IP per UTC day and one free key per email address, unchanged — see rate limits. Opening the form to the browser widens who can reach the endpoint conveniently, not what the endpoint will do.
Rotating a key
Leaked a key into a git commit, a CI log, a screenshot? Replace it yourself, immediately, without opening a support ticket:
curl -X POST https://api.attestwire.com/v1/keys/rotate \
-H "authorization: Bearer aw_live_YOUR_CURRENT_KEY"
The call is authenticated by the key you are replacing — that is the proof of ownership — and it is free: rotating consumes no documents, because a security action you might put off to save quota is a security action you will not take.
Everything follows the new key, atomically:
| Carries over | Detail |
|---|---|
| Your plan | Tier, limit and any past_due flag are unchanged. |
| This month's usage | The counter follows you. Rotating does not hand you a fresh allowance, and it does not lose the documents you have already used. |
| Your subscription | The Paddle link is repointed at the new key, so renewals, plan switches and cancellations keep landing on the right account. Billing events already in flight are re-routed too. |
| Your signup date | created stays the original one; rotated_at is new. |
{
"key": "aw_live_…",
"tier": "starter",
"monthly_limit": 2500,
"period": "2026-08",
"used": 3,
"remaining": 2497,
"rotated_at": "2026-08-09T12:00:00.000Z",
"warning": "Store this key now. It is shown once and cannot be recovered. …"
}
The new key is shown exactly once, the same as at signup, and
the old key stops working the instant that response is generated — it answers
410 with error: "key_rotated" from then on. Deploy
the new key before you rotate, or rotate during a window you can watch.
Rotations are limited to 3 per key per UTC day,
and the limit travels with the key, so chaining rotations does not reset it.
Two rotate calls sent at the same time cannot both succeed: one gets the new
key, the other gets 409 rotation_already_done.
If you have lost the key entirely there is no self-serve rotation, by design — a rotation triggered by anything other than the key itself (an email link, say) would let anyone who knows your address take your account over. What is on offer instead depends on the tier, and on a free key the answer is still self-serve, just not a rotation:
- If you are on a paid plan and the key is gone, email hello@attestwire.com from the address on your Paddle billing receipt. We check the request against Paddle's record of your subscription rather than against the receipt itself — a receipt can be forwarded, so attaching one proves only that somebody has a copy of it — and the replacement is delivered one way only: as a reply to the billing address on file, whoever did the asking. Whoever handles it retires the old key and issues the new one.
- We cannot recover a lost free key — not as a matter of policy, but because we never collected anything that could prove it was yours: a free signup address is never verified, and the key itself only ever existed in one HTTP response. The practical answer is faster than support anyway. The limit is one free key per email address, so a different address gets you a working free key on the spot, and a plus-alias such as you+attestwire@example.com counts. That limit keeps casual signups to one key each; it is housekeeping rather than a security control, and it was never meant to trap you. If you would rather we were able to rescue you, that is part of what a paid plan buys: Paddle holds a billing identity we can check.
The lost key itself is never recoverable: we store its SHA-256 hash and nothing else, so there is nothing on our side to show you again and every route above ends in a replacement rather than the old key returned. That is the same property that means a breach of our key store hands over a list of hashes, and that no support conversation can leak your credential, because the support side of it has never held one.
Endpoints
POST/v1/validate 1 document
Validates an InvoiceInput against EN 16931 and the CIUS named by
profile. Returns 200 whether or not the invoice is
valid — a rejected invoice is a successful call, and costs one document.
valid is true when there are no fatal errors;
neither warnings nor information findings make it false. A body that is not
JSON, or not a JSON object, returns 400 and consumes nothing: we
charge for work done, not for requests received. Any JSON object gets a
verdict, even one with a field of the wrong type, which is reported as
ATW-INPUT-TYPE.
It also accepts a file. Send content-type: application/xml and
the body is read as an invoice document — UBL 2.1 or CII — instead of as
JSON. See Validating a file you already have.
POST/v1/parse 1 document
Reads an invoice document into the JSON model and stops there. Same reader as
the XML 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 rules run. See
Reading a file into the model.
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 to the same endpoint with an XML content type:
curl -X POST https://api.attestwire.com/v1/validate \
-H "authorization: Bearer $ATTESTWIRE_API_KEY" \
-H 'content-type: application/xml' \
--data-binary @invoice.xml
We read the document into the same invoice model and run the same rules, so you get the same teaching errors. Send the file as it is — you do not have to tell us whether it is UBL or CII, and there is no separate endpoint for each. The response adds five fields that only a file can have:
syntax—"ubl"or"cii": which reader ran. Decided from the root element, not from anything you sent.customizationId— BT-24, exactly as the file states it. This is what decides which CIUS rules ran.profileId— BT-23, exactly as the file states it.unmapped— everything in the file that did not reach the model. Read this. An entry with"kind": "unknown"means we have no field for that element, so its content is gone from the model and was not checked; if it matters to you, read it out of the XML yourself. An entry with"kind": "recomputed"means we derive the value from the lines instead of storing it — nothing was lost. Onerecomputedentry per line is normal. A document total we could not read appears here and as a finding — see below.source— one sentence saying what this check does not prove.
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.
Not a PDF. Factur-X and ZUGFeRD are CII XML inside a
PDF/A-3 container. We read the XML, so extract it from the PDF and send
that; we cannot open the container. A PDF, a
ubl:DebitNote — the one UBL billing document EN 16931 has no
binding for — or any other root element is refused with 415,
and retrying will not help.
This is a pre-flight, not a verdict. We validate the model we
read, not the XML a receiver judges. Rules that constrain the document itself
rather than the model — BR-DE-13 and BR-DE-21 on
BT-24 — do not run here, so a file that passes can
still be rejected by KoSIT or by a receiving platform.
Changed on 2026-08-14, and it can turn a
valid: true into a valid: false. The
document totals are the one part of the file we now check for
presence, not just for agreement. A document that does not state the
sum of line net amounts (BT-106), the total without VAT (BT-109), the total
with VAT (BT-112) or the amount due for payment (BT-115) fails
BR-12, BR-13, BR-14 or
BR-15; one that states a total we cannot read as a number — an
empty element, or 12,34 with a decimal comma — fails
ATW-DECLARED-TOTAL-NOT-A-NUMBER, our own id, because the
official validator rejects those at XML Schema validation rather than under a
business rule. Both used to be dropped in silence and pass. KoSIT rejected
them all along, so this is a correction, not a new restriction.
What it costs. One document once the file has been read and
judged — the same whether the answer is valid: true or
valid: false, because the work is the same. A file we cannot read
at all (400, 413, 415) never reaches the
rules and costs nothing; those responses carry the unchanged
X-RateLimit-* headers, so you can see the allowance did not move.
| Status | error | What went wrong |
|---|---|---|
| 415 | unsupported_syntax | The root element is none of the three we read (UBL Invoice, UBL CreditNote, CII CrossIndustryInvoice): a ubl:DebitNote, a PDF, or something else. The message names what it found. |
| 400 | xml_malformed | Not well-formed, or outside the XML subset we accept — mixed content, an unbound prefix, a control character, the same attribute twice on one element, a comment containing --. |
| 400 | xml_rejected | Well-formed, but uses a construct we refuse on sight: a <!DOCTYPE or an entity declaration. That is how a hostile file reads a local file or exhausts memory. |
| 413 | xml_limit_exceeded | Under 1 MB, but past the reader's depth (100), element (50,000), attribute (256) or character (8,000,000) cap. |
Every one of those also carries xml_code: the reader's own,
finer-grained code. 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 for that one 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. 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
The response:
invoice— the model, in the sameInvoiceInputshape/v1/validateand/v1/generateaccept. Post it straight back to either one. Reading a UBL file and generating CII from the result is a syntax conversion you did not have to write.syntax,customizationId,profileId— the same three fields the XML validate path reports, and for the same reason: the model cannot carry them.unmapped— the parse-level findings: everything in the file that did not reach the model. Read it before you trust the conversion. Document totals the file states unreadably are recorded inside the model, atinvoice.declaredTotals.defects.source— one sentence saying that nothing was judged.
It converts; it does not judge. A 200 here
means the file parsed, and nothing more. A document that comes back cleanly
can still break every rule EN 16931 has. Send invoice to
POST /v1/validate for the verdict — that is a second
document against your allowance, and it is the call that answers the
compliance question.
XML only. This endpoint inverts the usual default: it needs
content-type: application/xml (or text/xml), and a
JSON body is refused with 415 unsupported_media_type. 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.
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.
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
}
]
}
Profile support
profile selects the rule set, and it also decides whether this build can generate the document:
| profile | What it is | Syntax | POST /v1/validate | POST /v1/generate |
|---|---|---|---|---|
en16931 |
EN 16931 core | UBL |
yes | yes |
xrechnung-ubl |
German XRechnung CIUS, UBL | UBL |
yes | yes |
xrechnung-cii |
German XRechnung CIUS, CII | CII |
yes | yes |
facturx-en16931 |
Factur-X EN 16931 profile | CII |
yes | yes |
peppol-bis-3 |
Peppol BIS Billing 3.0 | UBL |
yes | yes |
Narrower profiles add rules; they never remove core ones. The profile decides
the syntax, in both directions: ask for xrechnung-cii or
facturx-en16931 and you get CII, ask for anything else and you
get UBL. /v1/validate works the other way round — send a document
and it reads the root element to decide, then tells you which it read in the
syntax field.
CII is XML, and only XML. Factur-X and ZUGFeRD files are CII
XML inside a PDF/A-3 container. This API writes the XML and reads the XML. It
does not build the container, does not attach the XML as
factur-x.xml, does not set /AFRelationship, and
cannot open a Factur-X or ZUGFeRD PDF you send it. If you need the PDF, take
the XML from here and hand it to a Factur-X packaging library.
And the two CII profiles are not equally evidenced. The
generator's xrechnung-cii fixture documents are run
through the official KoSIT validator on release and accepted. The
facturx-en16931 ones are not: that profile's BT-24 is
the core EN 16931 one, which matches no XRechnung scenario, so KoSIT returns
“no scenario matched” instead of a verdict — and no verdict is
not a pass. Neither case is a check on your document: this endpoint
sends nothing to KoSIT.
Error model
Two kinds of failure, and they are shaped differently on purpose.
Invoice problems come back inside a 200 as
errors[] / warnings[] / information[] of
teaching errors. Your invoice is wrong; the request was fine.
Three severities
The levels are the reference validators' own — KoSIT's schematron flags each
assertion fatal, warning or information —
and we report them where they were raised rather than rounding them together.
| severity | Array | Affects valid | What it means |
|---|---|---|---|
fatal | errors[] | yes — valid: false | The document is rejected. Fix before sending. |
warning | warnings[] | no | Accepted, but something is missing or unwise. Worth fixing before you ship. |
information | information[] | no | Advisory. The validator raises it and then accepts the invoice anyway. |
information is kept out of warnings[] deliberately: if
you treat warnings as a pre-ship checklist, you should not be handed a finding
the regulator itself raises and then ignores. It is still worth reading. The
first one, BR-DE-TMP-32, fires on an XRechnung invoice that states
no time of supply — no deliveryDate (BT-72), no
invoicingPeriod (BG-14), and no period on every line. No portal
will stop you, but §14 Abs. 4 Nr. 6 UStG requires it, and the buyer's tax
adviser finds the gap long after the portal did not.
Branch on the arrays, not on a count: rules are added between releases, so code that asserts an exact number of findings will break on an engine update that is otherwise invisible to you.
Request problems come back as a non-2xx with a flat envelope.
Branch on error, which is stable; message is prose and
may be reworded.
{
"error": "invalid_api_key",
"message": "That API key is not recognised. Check that you pasted the whole key: it is aw_live_ followed by 40 characters, and a truncated copy is the usual cause. If you rotated this key, use the key that the rotation returned. If you no longer have a key, a new one is free at POST /v1/keys.",
"docs": "https://api.attestwire.com/docs#auth"
}
One error code carries a fourth field. When your monthly allowance is gone,
quota_exceeded also returns upgrade_url: the page
where a person buys a bigger plan. It is a plain link. Nothing is bought and
nothing is charged by reading it.
{
"error": "quota_exceeded",
"message": "You have used all 100 documents in your free plan for 2026-08. …",
"docs": "https://api.attestwire.com/docs#pricing",
"upgrade_url": "https://attestwire.com/pricing#upgrade"
}
| Status | error | Meaning |
|---|---|---|
| 400 | bad_request | Body was not readable JSON, or was not an object. |
| 400 | invalid_email | Signup email failed the format check. |
| 400 | unsupported_profile | A /v1/generate request named a profile this build has no generator for. The message lists the profiles it does emit. Nothing was charged. |
| 400 | xml_malformed | XML request: the document is not well-formed. See above. |
| 400 | xml_rejected | XML request: a DOCTYPE or entity declaration, refused on sight. |
| 401 | missing_api_key | No Authorization: Bearer header. |
| 401 | invalid_api_key | Key is unknown or revoked. |
| 403 | forbidden_origin | The MCP endpoint only: the request carried an Origin header this server does not permit. The MCP specification requires a 403 here, because an unchecked origin is what would let a page in a browser drive someone else's MCP client. A server-to-server caller sends no Origin at all and never meets this. |
| 404 | not_found | No such route. |
| 404 | record_not_found | GET /v1/records/{id} and /r/{id}: no validation record with that id. Either the id is wrong or incomplete, or the record is past its 365-day expiry and has been deleted — the two are indistinguishable, because an expired record leaves nothing behind. Not cached. |
| 405 | method_not_allowed | Right path, wrong verb. |
| 404 | claim_not_found | POST /v1/fix/claim: that transaction id does not match a completed Invoice Fix Report purchase. Deliberately the same answer for an unknown id, an unsettled payment and a transaction for something else — the route must not be usable to test which transaction ids exist. Nothing was charged. |
| 409 | already_claimed | POST /v1/fix/claim: this Fix Report was already redeemed. The key it minted was shown once and only its hash is kept, so it cannot be re-sent — email hello@attestwire.com with the transaction id rather than buying a second one. |
| 409 | key_already_issued | This email already has a free key. |
| 409 | rotation_already_done | This key has already been rotated; the replacement was returned once. |
| 409 | already_subscribed | This key already has a subscription; change plan in the billing portal (POST /v1/billing/portal) rather than buying a second one. |
| 409 | no_subscription | /v1/billing/portal only: this key has no subscription behind it, so there is no portal to open. Free keys, and tiers granted by hand, both land here. The route and the request were fine — the account state is what makes it inapplicable, which is why it is a 409 and not a 404. |
| 410 | key_rotated | This key was replaced by a rotation. Use the key that rotation returned. |
| 413 | payload_too_large | Body over 1 MB. |
| 413 | xml_limit_exceeded | XML request: past the reader's depth, element or character cap. |
| 415 | unsupported_syntax | XML request: none of the roots the reader takes (UBL Invoice, UBL CreditNote, CII CrossIndustryInvoice) — a ubl:DebitNote, a PDF, or something else. |
| 415 | unsupported_media_type | POST /v1/parse only: that endpoint reads a document, so it needs an XML content-type. It is the mirror image of /v1/validate, where XML is the opt-in and JSON the default — parsing a body that is already our JSON model would be a no-op, so the request is refused with a pointer to /v1/validate rather than answered with a copy of what was sent. Nothing charged. |
| 422 | — | Generation refused: body is a ValidationResult. |
| 429 | quota_exceeded | Monthly document allowance used up. Body carries upgrade_url. |
| 429 | rate_limited | Too many requests per second on this key. Carries Retry-After; nothing charged. The same pace limit applies to the metered MCP tools, off the same buckets. |
| 429 | rotation_rate_limited | More than 3 rotations of this key today. |
| 429 | too_many_requests | Per-IP signup limit. |
| 500 | internal_error | Something broke on our side. The body never carries the underlying exception — only the class name is logged, and never the message. Retry; if it persists, email hello@attestwire.com. |
| 500 | rotation_failed | The rotation could not be completed and nothing changed. The existing key still works. |
| 501 | generation_not_yet_available | Generation is not enabled in this build. |
| 502 | billing_error | The payment provider refused the request — a checkout that could not be created, or a billing-portal session that could not be minted. Nothing was bought and nothing was charged. Retryable. |
| 503 | billing_not_enabled | Checkout is not configured on this deployment. |
What ran, and when it changed
Pin the npm library for bit-identical CI; the hosted API always runs
the current ruleset — provenance tells you exactly what ran; watch
/v1/versions for newly added rule IDs.
Both doors are correct and they are not the same door. A pinned
@attestwire/en16931 gives you the same answer for the same invoice
forever, which is what a CI suite needs. This API always runs the current rule
set, because a compliance service that froze its rules would be answering last
quarter's regulation. The difference used to be invisible: a
valid: false looked identical whether your invoice had changed or
our rules had.
Every metered response now carries provenance —
/v1/validate and /v1/parse, JSON body or XML
document, and /v1/generate's JSON envelope, including the
422 it returns for an invoice it will not emit:
{
"provenance": {
"engine": "@attestwire/en16931",
"engine_version": "0.9.0",
"ruleset": "en16931@0.9.0",
"profile": "xrechnung-ubl",
"kosit_conformance": {
"recorded": "2026-08-13",
"validator": "1.6.2",
"configuration": "3.0.2",
"configuration_published": "2026-01-31"
}
}
}
engine/engine_version— the npm package every compliance decision here comes from, and the exact release of it. Install that version locally and you have the build that judged your document.ruleset— the standard and our build of it, as one identifier to quote.profile— the CIUS that actually ran. Read from the result, not from your request: a body that names no profile is judged asen16931, and this is where you see that.kosit_conformance— when the official KoSIT validator was last run against this engine's fixtures, and with which validator and configuration versions. Nothing is sent to KoSIT when you call us — this is a dated record of a check we ran, not a per-call verification.
?format=xml on /v1/generate is the one metered
response without it: it returns the document alone, and putting our metadata
inside a customer's tax document is not something we will do. Ask for the JSON
envelope, or read /v1/versions.
Watching for new rules
GET /v1/versions is free, unauthenticated and cacheable. It
reports the running engine and rule set, the KoSIT record, and a release list
where each entry carries the rule ids that release first named:
{
"engine": "@attestwire/en16931",
"engine_version": "0.9.0",
"ruleset": "en16931@0.9.0",
"rule_id_count": 305,
"kosit_conformance": {
"recorded": "2026-08-13",
"validator": "1.6.2",
"configuration": "3.0.2",
"configuration_published": "2026-01-31"
},
"releases": [
{
"version": "0.9.0",
"date": "2026-09-23",
"rules_added": [
"ATW-INPUT-TYPE",
"ATW-NUMBER-NOT-FINITE",
"ATW-NUMBER-TOO-LARGE",
"ATW-TEXT-NOT-XML",
"ATW-VAT-RATE-OUT-OF-RANGE",
"BR-24",
"BR-AF-09",
"BR-AG-09"
]
},
"… 13 earlier releases"
],
"derivation": "Each rule id is attributed to the oldest release of @attestw…"
}
Point a weekly job at it. A rule id appearing in
rules_added is the answer to "why did this document start
failing?", and it is the answer without a support ticket. The list is derived
from the engine's own changelog rather than typed, and the response says so in
derivation: an id is attributed to the oldest release whose notes
name it, which is usually — not always — the release that started
enforcing it. Treat it as where to look first, not as a specification.
Validation records
Add ?record=true to a /v1/validate call and you
get back a URL you can send to somebody else. It costs nothing extra, it
works on the free plan, and it stores a fingerprint of your invoice rather than
your invoice.
The problem it solves is the one that starts after the API answers. You validated the document; now a buyer's accounts-payable team, an auditor or a platform onboarding desk wants to see that you did. Pasting a JSON body into an email proves nothing about who produced it or when. A record is that same answer, stored by us and served from our own copy at a URL — so whoever holds the link sees exactly what Attestwire recorded.
curl -X POST "https://api.attestwire.com/v1/validate?record=true" \
-H "authorization: Bearer $ATTESTWIRE_API_KEY" \
-H "content-type: application/xml" \
--data-binary @invoice.xml
The 200 is the response you already get, with one field added:
{
"record": {
"id": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"url": "https://api.attestwire.com/r/a1b2c3d4e5f60718293a4b5c6d7e8f90",
"json": "https://api.attestwire.com/v1/records/a1b2c3d4e5f60718293a4b5c6d7e8f90",
"expires": "2027-08-14T09:12:33.000Z"
}
}
url is a human-readable page; json is the same object
for a machine. Neither takes an API key, and that is the point — a record
whose reader needs an Attestwire key is a record you cannot show to the people it
exists for. The 128-bit id is the credential. It is unguessable,
so anyone you send the link to can read the record and anyone you do not, cannot.
Treat the URL the way you would treat the document itself.
What is in one, and what is not
This is the property that makes a record safe to hand out. A record contains:
- the SHA-256 fingerprint of the document, so a third party can match it against their own copy of the file — nothing more;
- the verdict and the finding counts by severity;
- the findings themselves — rule id, severity, the engine's own message, and the XPath where it applied;
- the
provenanceblock: engine, version, ruleset, profile, and the KoSIT conformance record date; - when it was created and when it expires.
It does not contain the document. We fingerprint it in memory and store the fingerprint; the file is never written anywhere. It does not contain your email, your API key, or any invoice content beyond what a finding already quotes. Nothing in a record identifies which customer ran the check.
An application/xml request fingerprints the document as UTF-8 bytes,
so shasum -a 256 invoice.xml 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
Records are signed with HMAC-SHA256 over their own canonical JSON, and the
signature rides in the signature field alongside a
key_id naming which key signed. Signed by Attestwire. The signature is an HMAC-SHA256 over this record, which proves the record was issued by Attestwire and has not been modified since. HMAC is symmetric: the key that signs is the key that verifies and only Attestwire holds it, so checking the signature currently means asking us — email hello@attestwire.com with the record id. There is no public key to verify against yet.
So the honest summary is: the signature protects a record against modification and
against forgery by anyone who is not us, and independent verification is not
something you can do today. Most of the assurance in practice comes from the fact
that you are reading the record from
https://api.attestwire.com rather than from a file somebody emailed you.
Retention and limits
- 365 days, from creation, then the record is deleted and both URLs answer 404. There is no extension and no recovery — the expiry date is on the record and on the page from the moment it is made.
- Immutable. There is no update path. A record cannot be re-issued with different findings at the same address, which is what makes the URL worth anything.
- Free, and not metered separately. The validate call is charged exactly as it would be without the parameter, on every plan including free.
- 500 stored records per key per UTC day,
as a storage-abuse ceiling. Past it, the validation still succeeds and is charged
normally, and the response carries
record_unavailablewith the reason instead ofrecord. The verdict is never affected by a record that could not be stored.
/v1/parse has no equivalent parameter, and will not grow one: a parse
returns the invoice model and reaches no verdict, so there would be nothing for a
record to state.
Rate limits and quotas
The quota is documents per calendar month, counted per key. One document is one
/v1/validate, /v1/parse or /v1/generate
call. /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 (invoice not valid, so no XML), 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 and /v1/generate 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 or any backend language. We run the service and deploy rule updates; usage follows your monthly plan.
Use the local TypeScript library
The MIT library works offline with no key or network call. Your team installs updates and operates the integration.
npm install @attestwire/en16931
Both paths read and write UBL and CII XML. In the library, use
parseUbl(xml), parseCiiInvoice(xml),
generateXRechnungUBL and generateCii. In the API,
POST /v1/validate accepts XML; 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.
The one thing neither path does is PDF. Factur-X and ZUGFeRD place CII XML inside a PDF/A-3 container. These tools do not open or build that container. Reading an invoice here is a pre-flight, not a verdict; a document can still be rejected by KoSIT or a receiving platform.
Pricing
| Plan | Documents / month | Price |
|---|---|---|
| Free | 100 | $0 |
| Starter | 2,500 | $49/mo |
| Scale | 25,000 | $199/mo |
Either paid plan can be billed yearly instead, at 10 months' price: $490/yr for Starter and $1,990/yr for Scale. The allowance is still monthly and still resets on the 1st.
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 starter or scale. For yearly
billing add "period": "annual" (or send starter_annual /
scale_annual as the plan). The key's tier
changes when the payment confirms, so the new allowance applies from the next
request after checkout completes — no redeploy, no new key.
Paddle is the merchant of record. The charge on the card statement is Paddle's, not Attestwire's, and Paddle — not us — registers, collects and remits VAT and sales tax on the sale. The receipt it emails is the tax document; it carries the VAT treatment and a link to the billing portal.
The body is the canonical form, and it is what the upgrade form on
attestwire.com/pricing sends. Server-side callers may put the key in an
Authorization: Bearer aw_live_… header instead, matching every
other endpoint here; the header wins if you send both. This is the one
account-level endpoint a browser may call — see CORS — and
only from https://attestwire.com, with content-type
as the only permitted request header.
Managing or cancelling your subscription
GET/v1/billing/portal free
curl https://api.attestwire.com/v1/billing/portal \
-H "authorization: Bearer aw_live_YOUR_KEY"
{ "url": "https://customer-portal.paddle.com/cpl_…", "note": "…" }
Open that url to change plan, update the card, download
every invoice, and cancel, without emailing us. POST
works identically — it reads like a fetch and creates a session at Paddle's
end, so both verbs are accepted.
The link is one-time and it expires. Paddle mints a session bound to your customer record rather than publishing a login page, so there is no permanent URL to bookmark: ask for a fresh one each time. Treat it like a password — anyone who opens it is treated as the account holder — and do not put it in a shared inbox or a chat channel. Every receipt Paddle emails carries the same link, so a customer who cannot reach the API still has one.
A key with no subscription behind it — a free key, or a tier we granted by
hand — gets 409 no_subscription: the route is fine, there is
simply nothing to manage.
Changing plan goes through the portal, never through a second
checkout. Posting to /v1/billing/checkout again while you
are already subscribed returns 409 already_subscribed and
deliberately does nothing: it would create a second subscription and
bill you for both plans. A portal switch is prorated, and your key follows the
new price automatically — no redeploy and no new key.
Cancelling takes effect at the end of the period you have paid for; you keep the allowance you bought until then, and the key drops to the free tier afterwards rather than stopping. Nothing is deleted and no data is lost.
You never have to come back to this page to find it: GET /v1/usage
returns portal_endpoint for any paid key, and the checkout
response carries the same field — so an integration that has either one already
knows where to send its user. It is the endpoint rather than a URL because
there is no durable URL to give; see above.
If a payment fails
Renewals fail for boring reasons — an expired card, a bank's fraud hold — and the overwhelming majority recover. So a failed charge does not cut you off:
| State | What happens to your key |
|---|---|
Payment failed, Paddle retrying (past_due) |
Full paid allowance, unchanged. GET /v1/usage starts
reporting "past_due": true with an explanation — that is
your warning, and it is the only one the API can give you. |
Every retry failed (canceled) |
Drops to the free tier (100/month). The key keeps working; only the allowance shrinks. |
| Cancelled | Cancellation takes effect at period end, so you keep the paid allowance until the period you already paid for is over, then drop to free. Nothing is deleted. |
Nothing here revokes a key or deletes data — a downgrade only changes the
monthly limit. Poll /v1/usage if you want to detect billing
trouble programmatically.
Invoice Fix Report — $99, one payment
A rejected invoice and no idea why is a different problem from "we need an API". The Fix Report is for that day: one payment of $99, no subscription, nothing to cancel. It is the only thing here that is not billed monthly.
What you get, and it is three things — the third is a person:
- 30 days on Starter (2,500 documents/month), so you can run every version of the invoice through the validator while you fix it rather than rationing attempts.
-
The signed Validation Record for each run.
Add
?record=trueto your/v1/validatecall and the response carries a public URL. That link is the thing you send back to the platform or the buyer who rejected the file — they need no key, no account, and no explanation from you. - A human reading your findings and writing back. Email hello@attestwire.com with your transaction id, and attach the failing invoice or the Validation Record URL. Nothing in the API delivers this half — the email is the step that gets it, and it is included in what you already paid.
Claiming it
POST/v1/fix/claim no key
curl -X POST https://api.attestwire.com/v1/fix/claim \
-H 'content-type: application/json' \
-d '{"transaction_id":"txn_..."}'
{
"key": "aw_live_...",
"tier": "starter",
"monthly_limit": 2500,
"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 thirteen tools
are free and need no key —
explain_rule (299 rule ids explained in plain English —
most, not all, of what the validator checks; the tool says so when it has no
write-up for an id),
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 and verify_vat_vies 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 (Starter: 2,500 documents/month for $49/month; Scale: 25,000 for $199/month (USD, plus VAT where due)).
Every metered tool result ends with the documents left, the plan and the
reset date, and the call that crosses 80% of the allowance carries a one-time
notice naming the next plan — so an agent sees the ceiling before it meets
quota_exceeded. (Over HTTP the same numbers ride the
X-RateLimit-* headers.)
Two stages: mint the key, then configure it and reconnect
Minting a key does not authenticate the connection it was minted
on. This server reads the credential from the request
(Authorization: Bearer, or x-api-key for clients
that only offer that), sent by the client from its own configuration; the
stdio bridge reads ATTESTWIRE_API_KEY once when the process
starts. Nothing a tool returns mutates either. So a session that calls
issue_api_key and immediately retries a metered tool gets the
same missing_api_key back, and there is no automatic
reauthentication to wait for.
- Connect with no credential and use the six free tools.
- Mint a key — call
issue_api_keywith an email address, or use the browser form at attestwire.com/pricing#keyform, which keeps a one-time secret out of a chat transcript entirely. - Save it outside the chat. It is returned once and only its SHA-256 hash is stored; there is no second copy to ask for.
- Configure it in the MCP client: the header for the remote
HTTP transport,
ATTESTWIRE_API_KEYfor the stdio bridge. Both shapes are below. - Restart or reconnect the MCP server. A client reads its config at connect time and a live session keeps the credential it started with.
- Verify with one metered call. A second
missing_api_keymeans the config did not take effect — it never means the key needs minting again, and asking for a second free key on the same address returns409.
validate_invoice_xml takes the invoice file itself, so an agent
handed an .xml attachment does not have to retype it into a JSON
object — retyping loses fields and invents others. It reads
both syntaxes and picks the reader itself, so an agent that
cannot tell UBL from CII does not have to guess. There is deliberately no
second tool for CII: an agent usually does not know which syntax it is
holding, and the server always can.
It does not read a PDF, and the tool description says so, so an agent is not left to discover that by failing.
Claude Code
claude mcp add --transport http attestwire https://api.attestwire.com/mcp
# with a key, so validate_invoice and generate_invoice work.
# remove first: adding over an existing entry is the step people skip,
# and the result looks like a broken key rather than a stale config.
claude mcp remove attestwire
claude mcp add --transport http attestwire https://api.attestwire.com/mcp \
--header "Authorization: Bearer aw_live_..."
Start a new session afterwards. The header is attached when the client connects, so a session already running keeps the credential it started with.
Claude Desktop
Settings → Connectors → Add custom connector, with the URL above. The credential belongs to the connector, not to the conversation: after adding or changing it, restart the app before expecting a metered tool to work.
Clients that can only spawn a local process
@attestwire/mcp is a published stdio bridge — zero dependencies,
it moves JSON-RPC between stdio and this endpoint and does nothing else. Paste
into claude_desktop_config.json, .cursor/mcp.json, or
whatever your client calls its MCP config:
{
"mcpServers": {
"attestwire": {
"command": "npx",
"args": ["-y", "@attestwire/mcp"],
"env": { "ATTESTWIRE_API_KEY": "aw_live_..." }
}
}
}
Drop the env block to run keyless — the six free tools work
straight away. The bridge reads ATTESTWIRE_API_KEY once, when the
process starts, so adding or changing it takes effect on the next restart and
not before. Prefer the remote HTTP transport above where your client supports
it: it is one fewer moving part and new tools appear without an upgrade.
Cursor
In .cursor/mcp.json (or the global ~/.cursor/mcp.json):
{
"mcpServers": {
"attestwire": {
"url": "https://api.attestwire.com/mcp",
"headers": { "Authorization": "Bearer aw_live_..." }
}
}
}
Omit the headers / env block entirely to run keyless.
The agent can then call issue_api_key to mint one, which is
returned once and cannot be recovered — save it, paste it into the block
above, and restart the client. The minted key does not authenticate the
session that minted it.
For agents
openapi.json is the full OpenAPI 3.1 document, and postman.json is a Postman collection generated from that document — import it and the first request works. Teaching errors are written to be pasted straight into a coding agent's context: rule id, requirement, and fix, with no spec lookup required. For tool-calling agents, the MCP server is usually the better door.
Diagnosis, Peppol and live VAT checks
Open the invoice-check workbench or call these authenticated JSON endpoints:
POST /v1/diagnose: supplyinvoiceorxml, with optional providerrejectiontext or SVRL. Revalidate reviewed edits usingexpected_sha256andconfirmed:true.POST /v1/peppol/lookup: supplyparticipant_idas scheme:value, optionallyinvoice_participant_idanddocument_type(invoice or credit_note). Uses OpenPeppol's public production lookup. Checks advertised document support, not endpoint certificates or delivery.POST /v1/vat/verify: supplyvat_numberwith country prefix. Live EU VIES response: valid, invalid, or unavailable; one bounded retry and no Attestwire cache.
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. Same key and metering as HTTP. See OpenAPI for request schemas.