Should an agent book this invoice? The EN 16931 verdict
One paid call returns a deterministic EN 16931 verdict on an inbound e-invoice — official BR-xx rule ids, observed vs expected values, plus XRechnung and Peppol rule packs.
By Matthias Begot ·
An agent that receives an invoice has to decide something binary: book it, or
send it back. POST /invoice/validate answers
exactly that question and nothing else — is this document conformant to
EN 16931? — and it answers it the only way an accounting pipeline can use:
deterministically and with sources. Every violation comes back with its
official CEN rule id (BR-CO-14, BR-DEC-19, BR-S-08, BR-CL-xx), the
normative rule text, the semantic location in BT/BG terms, and the observed
and expected values whenever the arithmetic can be recomposed. No model
guesses. The same document always gets the same verdict, and the response cites
the exact version of the normative artefacts it was judged against.
The rejection has to be actionable, or it is not a decision
“This invoice looks wrong” is not something an agent can act on. It cannot book the invoice, and it cannot send a supplier a rejection they can fix. It escalates to a human, and the automation was pointless.
That is why the negative verdict — not the positive one — is the actual product
here. BR-CO-14 fatal, path BG-22/BT-110, observed 21.73, expected 20.73 is a
sentence an agent can put in a reply: your VAT total does not match the sum of
your VAT breakdown, we computed 20.73, you wrote 21.73. The supplier’s own
software will recognise the rule id, because it is the id from the official CEN
artefacts, not one we invented.
The x402 consequence is direct: a readable document gets a 200, billed,
whether the verdict is valid or invalid. You asked “is this invoice
conformant?” and you got the answer. Only requests the service cannot answer —
a malformed body, a scanned PDF with no embedded XML, a body over the cap, an
unknown query parameter — leave the 200 range, and those are not billed.
Four layers, reported separately
The verdict is not a boolean with a paragraph attached. It is four independent layers, each reported on its own so you can see which one objected:
- XSD — schema validity against the official schemas (CII D16B, UBL 2.1 Invoice/CreditNote, or the XSD of the declared Factur-X profile). Any XSD error is fatal.
- EN 16931 business rules — every violated rule with
rule_id,severity, the normativemessage, the BT/BGpath, andobserved/expectedwhen they can be computed. - Factur-X / ZUGFeRD profile (CII only) — the profile declared in BT-24,
the content’s conformance to that profile’s XSD, and the
content_profile: the smallest nested profile whose XSD actually accepts the document. A gap between declared and content is reported asprofile_mismatch. - Identifier plausibility — IBAN mod-97, BIC format, SIREN/SIRET Luhn, VAT
syntax by country prefix, calendar validity of BT-2 and BT-9, the shape of
the electronic addresses BT-34/BT-49, and the monetary use of the currency
code BT-5. Every check reports
pass,failornot_present— never silent, so “the IBAN checked out” and “there was no IBAN” are never confused.
Layers 1 to 3 are the standard. Layer 4 is ours, and it is honest about being ours: it lives outside the XSLT oracle, because no EN 16931 business rule has an opinion on the content of an IBAN or a SIREN. Adding it does not touch the equality guarantee against the reference validator.
What a real refusal looks like
Actual service response for a CII invoice whose totals were mutated: the VAT total disagrees with the VAT breakdown, and that cascades into the grand total. Two rules fire, each with the recomposed numbers.
{
"data": {
"verdict": "invalid",
"warnings_present": false,
"document": {
"format": "cii",
"number": "12115118",
"seller": "De Koksmaat",
"grand_total": "250.33",
"currency": "EUR"
},
"layers": {
"xsd": { "ok": true, "errors": [] },
"business_rules": {
"ok": false,
"violations": [
{
"rule_id": "BR-CO-14",
"severity": "fatal",
"message": "Invoice total VAT amount (BT-110) = Σ VAT category tax amount (BT-117).",
"path": "BG-22/BT-110",
"observed": "21.73",
"expected": "20.73"
},
{
"rule_id": "BR-CO-15",
"severity": "fatal",
"message": "Invoice total amount with VAT (BT-112) = Invoice total amount without VAT (BT-109) + Invoice total VAT amount (BT-110).",
"path": "BG-22/BT-112",
"observed": "250.33",
"expected": "251.33"
}
]
},
"profile": { "ok": true, "status": "declared", "profile": "en16931", "content_profile": "en16931" },
"plausibility": { "ok": true, "iban_checksum": { "status": "pass" }, "…": "…" }
},
"summary": {
"violations": { "xsd": 0, "business_rules_fatal": 2, "business_rules_warning": 0, "profile": 0, "plausibility_failed": 0 },
"profile_mode": "declared",
"coverage": { "families": ["BR", "BR-CO", "BR-DEC", "BR-S / BR-Z / BR-E / BR-AE / BR-IC / BR-G / BR-O / BR-AF / BR-AG / BR-B", "BR-CL"], "not_implemented": [] },
"artefacts_version": "en16931-1.3.16+facturx-1.08+xrechnung-3.0.2+peppol-bis-3.0.20"
}
},
"provenance": {
"source": "en16931-artefacts",
"fetched_at": "2026-07-12T10:29:22Z",
"freshness": { "kind": "snapshot", "as_of": "2026-07-11T00:00:00Z" }
}
}
Two details carry more weight than the violations themselves.
data.document is a minimal identification echo — format, number, seller, grand
total, currency — so the verdict is attributable to a document without a second
call. The full semantic model is a different job, done by
POST /invoice/read.
summary.coverage states what the verdict actually evaluated, in every
response, with a not_implemented list beside it. A verdict that does not
declare its own scope is asking to be over-trusted.
One surface, three markets
EN 16931 is the core, but almost nobody is only subject to the core. A national
or network CIUS sits on top, and it is the CIUS that rejects your invoice in
practice. ?ruleset= layers one on:
?ruleset= | Rule ids | Faithful to | The obligation behind it |
|---|---|---|---|
en16931 (default) | BR-xx, BR-CO, BR-DEC, BR-CL | CEN validation artefacts | the French reform core |
xrechnung | BR-DE-xx | the KoSIT reference validator | the German mandate, UBL and CII alike |
peppol | PEPPOL-EN16931-Rxxx | the OpenPeppol reference validator | the Peppol network, UBL 2.1 only |
The pack is additive: the four core layers always run, and the pack’s own
verdict arrives in a separate data.ruleset block with its own violations,
its own warnings, its contractual coverage and its own pinned
artefacts_version. A BR-DE failure therefore shows up with the core
business_rules layer still ok: true — which is precisely the situation you
want reported that way, because it tells you the invoice is a valid EN 16931
invoice that a German buyer will still refuse.
Under the default en16931, no ruleset field is serialised at all. The
response is bit-for-bit what it was before the packs existed; integrations that
predate them change nothing.
A CII document asked about Peppol is a verdict, not an error
This is the teaching case for the x402 golden rule, so it is worth stating
plainly. Peppol BIS Billing 3.0 mandates UBL 2.1; the network does not accept
CII. So a CII document sent with ?ruleset=peppol cannot be Peppol-valid.
That is a legitimate question with a normative answer, and the answer is no. It
comes back as a 200, billed, verdict invalid, carrying one dedicated fatal
violation peppol_requires_ubl with observed: "cii" and expected: "ubl" —
never a 4xx, never a 5xx.
Contrast POST /invoice/generate, where the same
combination is a 400: there you are asking the service to fabricate a
document that cannot exist, and an incoherent instruction is a bad request. Here
you are asking about a document that already exists. Judging is not building.
?profile=declared or strict — two different questions
Factur-X profiles (MINIMUM, BASIC WL, BASIC, EN 16931, EXTENDED) are nested subsets, and the smaller ones deliberately carry fewer fields than full EN 16931 requires. Failing a MINIMUM invoice for missing fields it is not allowed to carry would be noise, so the yardstick is a parameter:
declared(default) — “is this document conformant to what it claims to be?” The business rules are restricted to the subset applicable to the declared profile.strict— “is this invoice usable as a full EN 16931 invoice?” The complete rule set is evaluated even when the declared profile is poorer. This is the mode that matters if you are checking a supplier against the French reform rather than against their own claim.
The mode is echoed in summary.profile_mode. For UBL the two are equivalent —
Factur-X profiles are a CII notion, the profile layer reports not_applicable
with a reason, and that is a documented no-op rather than an error. A mistyped
key (?profil=) is a named 400, never a silent fallback to the default.
Plausibility is syntax; existence is a different call
The plausibility layer answers “could this identifier exist?” — it never checks that it does. That boundary is not a limitation to apologise for, it is what keeps the endpoint stateless, offline and reproducible. Whether the counterparty is real is a separate paid question:
- registry existence of a SIREN/SIRET →
GET /company/resolve - live EU VAT status →
GET /company/vat - can the recipient actually receive a BIS invoice →
GET /company/peppol - is the payment IBAN sane and reachable →
GET /iban/resolve
The natural chaining for an inbound document is: validate it, then check the counterparty, then check the IBAN before paying it. Three grids, three settled calls, one flow — and each one refusable on its own evidence.
One subtlety worth knowing if you handle French documents: under the ISO 6523
scheme 0002 (SIRENE), the value is accepted as 9 digits (SIREN) or 14
digits (SIRET), because that is how the official FNFE Factur-X samples declare
it, and for a 14-digit value the Luhn key is checked on the SIREN part. A real
French SIRET is a pass, not a spurious fail. Scheme 0009 stays strict
SIRET. Fidelity to how the ecosystem actually writes identifiers beats
theoretical purity.
INV-VAT-* is informational, and says so in the response
An invoice can satisfy every BR- rule and still be refused by a tax authority:
an intra-Community supply with no reverse-charge mention, a VAT rate that does
not exist in the country of taxation, a reverse charge claimed between two
parties of the same State. data.vat_consistency reports those observations —
with three hard guarantees:
- the ids are
INV-VAT-xx, neverBR-. An Invoket check never appears among the EN 16931 violations, nor among a CIUS pack’s; - it is informational:
informational: trueand an explicitnotesay so in the payload itself, and it does not changedata.verdict; - it is contractual:
checked[]lists what was judged,findings[]what was observed, andnot_applicable[]what could not conclude with its reason.
Each finding cites its articles in the form
GET /legal/eu-act can serve, so the legal basis is
itself checkable rather than asserted.
And the block refuses to guess where it cannot know. The place of taxation
cannot be read off a document — it depends on the nature of the operation, which
an invoice does not carry — so on a cross-border invoice the rate check comes
back not_applicable, pointing at
POST /invoice/vat-treatment, which
receives that nature and renders the full verdict. The document check informs;
it never votes.
Read the artefact pin, because the standard is moving under you
Every response carries summary.artefacts_version — a single string naming the
core and the pack artefacts, e.g.
en16931-1.3.16+facturx-1.08+xrechnung-3.0.2+peppol-bis-3.0.20. Same version,
same document, same verdict.
That pin is not bookkeeping. It is the only way to know what a “compliant” answer meant, because the ground is moving on three independent clocks right now:
- Peppol BIS Billing 3.0 ships on a semi-annual train. Version 3.0.20 was published on 24 November 2025 and became mandatory on 23 February 2026, followed by a hotfix on 27 January 2026; the May 2026 release becomes mandatory on 17 August 2026, with the current documentation available only until 16 August (docs.peppol.eu, Peppol May 2026 Release).
- XRechnung moves on its own. The KoSIT validator configuration v2026-01-31 was published on 5 February 2026 and compiles against CEN Schematron rules 1.3.15, while the current CEN artefacts are at v1.3.16 (itplr-kosit, ConnectingEurope). Two “compliant” validators can legitimately disagree, and only the pinned versions explain why.
- EN 16931 itself is being revised. CEN approved EN 16931-1:2026 in February 2026, with publication expected mid-2026, and KoSIT has announced XRechnung 4.0 on that basis for mid-to-late 2026, gated on the CEN syntax bindings and validation artefacts (XStandards Einkauf).
A validator that returns a bare valid: true without naming its artefacts is
telling you nothing durable. Compare the version in the response against the
release your own obligation is written to, and you know exactly where you stand.
Where the verdict sits in the cycle
The invoice endpoints are one rule set seen from several angles:
POST /invoice/read— extract an inbound document into the EN 16931 semantic model, faithfully, without judging it.POST /invoice/validate— the verdict on that document, with official rule ids and observed-vs-expected values.POST /invoice/generateandPOST /invoice/generate-pdf— the invoice you issue, re-proved against this same engine before it is served.POST /invoice/late-payment— once a conformant invoice goes unpaid, what it owes in statutory interest.
Read and validate are designed as a pair: read extracts without judging, validate judges without extracting. The inbound flow is covered in reading an inbound e-invoice, and the outbound one in generating an invoice that proves its own conformance.
The authoritative endpoint listing and price live in the
catalog, served live and never hardcoded in
an article. The payment loop is the usual discover → 402 → pay → replay; the
Quickstart walks it with runnable snippets and
For agents covers the discovery surfaces.
What the verdict will not do
- No OCR. A scanned PDF with no embedded XML is a typed
400, not billed, never reconstructed. A picture of an invoice is not an invoice. - No duplicate or fraud detection. The endpoint judges one document against a rule set; it has no memory of what you validated yesterday, by design — the runtime is stateless pure computation, with no network call, no secret and no store.
- No registry lookup. Plausibility stops at syntax, deliberately (see above).
- No tax verdict.
vat_consistencyflags what a document contradicts on its own face; determining the treatment needs facts the document does not carry. - Faithful to the reference validator, quirks included. The official artefacts have known misalignments between their UBL and CII bindings — code lists differ, some rules branch differently — so the same semantic invoice can get a different verdict depending on the syntax it is written in. Exactly as it would with the official tooling. We reproduce the reference validator and report the divergences upstream rather than patching verdicts locally.
Used for what it is — a settled call that turns a received document into a
sourced, reproducible accept-or-reject decision — validation is the step that
lets an agent book invoices without a human opening each one. The full request
contract, the four-layer response schema, the ruleset matrix and the complete
error table are in the
POST /invoice/validate documentation.