Generate a Factur-X e-invoice that proves its own conformance
One paid call turns a business JSON into an EN 16931 invoice — CII, UBL, Peppol BIS 3.0 or a full Factur-X PDF/A-3B — that has already re-passed the official rule set before it is served.
By Matthias Begot · · Updated
An agent can write an invoice. What it cannot do, on its own, is prove the
invoice is conformant — and from September 2026 in France, and already today in
Belgium, an invoice that is not conformant is not an invoice. Two Invoket
endpoints close that gap by refusing to serve their own output until it has
passed the official rule set: POST /invoice/generate
returns the XML core (CII D16B or UBL 2.1), and
POST /invoice/generate-pdf returns the
complete Factur-X document — a readable PDF/A-3B carrying that same XML.
Both take the same business JSON, and both return data.validated: true by
construction: a post-condition that fails is a 500, never served and never
billed.
The deadline is not the interesting part, but it sets the clock
France first. From 1 September 2026, every VAT-registered business established in France must be able to receive electronic invoices through an approved platform; large and intermediate-sized enterprises must also issue them from that date, and micro, small and medium enterprises follow no later than 1 September 2027 (impots.gouv.fr, economie.gouv.fr).
Belgium is further along and shows what enforcement looks like. Domestic B2B e-invoicing over Peppol has been mandatory since 1 January 2026; the three-month tolerance ended on 31 March, and since 1 April 2026 a graduated penalty regime applies — €1,500, then €3,000, then €5,000 for repeat offences within three months (Loyens & Loeff). The mandated format there is Peppol BIS Billing 3.0, which is UBL 2.1, not CII — a syntax difference that is invisible to a business user and fatal to a naive generator.
So an agent issuing invoices in Europe now has to hit a moving, jurisdiction- specific target where “it looked right” carries a price tag.
Writing valid XML is the easy half; proving it is the product
Emitting a plausible <rsm:CrossIndustryInvoice> is a templating exercise. Any
model can do it, and that is exactly the problem: the output looks correct in
all the ways a human reviewer checks and fails in the ways a Schematron engine
checks. EN 16931 conformance is several hundred business rules with official
ids — BR-xx, BR-CO-xx, BR-DEC-xx, the VAT-category families
BR-S/BR-Z/BR-E/BR-AE, the code-list rules BR-CL-xx — plus XSD
validity, plus profile conformance, plus identifier plausibility.
/invoice/generate therefore does not stop at serialisation. Before any byte
leaves the service, the XML it just produced is re-parsed and re-passed
through the four layers of /invoice/validate in
strict mode, on the same pinned artefacts — the same yardstick sold separately
as a verdict on incoming documents. If that re-validation does not return
valid, the request ends as a 500 INTERNAL: the output is never served, the
detail is logged, and the call is not billed. A 5xx on this endpoint means
we broke a promise, not that your input was bad.
/invoice/generate-pdf adds a second post-condition on top. The served PDF is
opened by our own /invoice/read and the embedded
factur-x.xml is extracted and compared bit for bit against the data.xml
returned flat in the response. The readable document and the machine document
cannot drift apart, because the service proves they have not before it hands
either one over.
That is the whole pitch: validated: true is not a claim about the generator,
it is the echo of a check that already ran.
One input, two deliverables
/invoice/generate | /invoice/generate-pdf | |
|---|---|---|
| Output | XML core, data.xml | data.pdf_base64 and data.xml |
| Syntaxes | CII D16B (default) or UBL 2.1 | CII D16B only (Factur-X embeds CII) |
| Peppol BIS 3.0 | Yes, ruleset: "peppol" with syntax: "ubl" | No |
| Post-conditions | Re-validated | Re-validated, re-read bit-for-bit, PDF/A-3B |
| Input schema | Identical business JSON | Identical business JSON |
The input is deliberately unglamorous — parties, lines, VAT, an optional payment IBAN — and the endpoint does the EN 16931 mapping:
{
"invoice_number": "FA-2026-0042",
"issue_date": "2026-07-12",
"due_date": "2026-08-11",
"currency": "EUR",
"seller": {
"name": "Menuiserie Dubois SARL",
"legal_id": { "value": "732829320", "scheme": "0002" },
"vat_id": "FR44732829320",
"address": { "line1": "12 rue des Artisans", "city": "Lyon",
"postal_code": "69003", "country": "FR" }
},
"buyer": {
"name": "Agence Lumen SAS",
"vat_id": "FR23342663721",
"address": { "line1": "4 avenue du Parc", "city": "Paris",
"postal_code": "75011", "country": "FR" }
},
"lines": [
{ "description": "Fabrication comptoir chene massif", "qty": "1",
"unit": "C62", "unit_price": "1200.00",
"vat": { "category": "S", "rate": "20" } },
{ "description": "Pose et finition sur site", "qty": "6",
"unit": "HUR", "unit_price": "85.00",
"vat": { "category": "S", "rate": "20" } }
],
"payment": { "iban": "FR7630006000011234567890189", "terms": "30 jours nets" }
}
Note what is absent: no totals. The response fills them in and shows its work.
{
"data": {
"xml": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rsm:CrossIndustryInvoice …>…</rsm:CrossIndustryInvoice>",
"profile": "urn:cen.eu:en16931:2017",
"validated": true,
"computed": {
"totals": { "lines_net": "1710", "net": "1710", "vat_total": "342",
"grand_total": "2052", "due": "2052" },
"vat_breakdown": [
{ "taxable_amount": "1710", "tax_amount": "342", "category": "S", "rate": "20" }
]
}
},
"provenance": {
"source": "en16931-artefacts",
"fetched_at": "2026-07-12T14:18:41Z",
"freshness": { "kind": "snapshot", "as_of": "2026-07-11T00:00:00Z" }
}
}
data.computed is the read-back: the BG-22 document totals and the BG-23 VAT
breakdown actually serialized into the XML, as exact decimal strings. An
agent never has to re-parse the document to learn what it just sent.
Totals: computed if absent, verified if provided, never repaired
This is the policy that decides whether a generator is trustworthy under automation.
- Omit
totals— the recommended path — and the document totals and VAT breakdown are computed from the lines with the officialBR-COrounding rules. - Provide
totalsand they are verified against that same computation. A divergence is a400carrying aninconsistent_totalsissue that names the BT term and the value the engine computed:
{ "code": "INVALID_INVOICE_INPUT",
"error": "invoice input is invalid: 1 actionable issue(s) listed in issues[]",
"issues": [ { "code": "inconsistent_totals", "term": "BT-112",
"message": "provided total BT-112 = 2152.00 is inconsistent with the value computed from the invoice body (2052, EN 16931 BR-CO rules)" } ] }
The service never silently repairs a total. Every amount in the XML is either yours and verified, or computed and echoed back. A generator that quietly corrects an agent’s arithmetic is hiding the bug that produced it — and in an accounting pipeline, the divergence is the signal.
The same discipline applies to decimals. qty, unit_price, vat.rate and
totals.* accept a JSON number or a plain decimal string; scientific
notation ("1e3") is refused with an invalid_decimal issue naming the field,
because an agent that emits it has most likely made a scale error, and 1e3
euros should never be reinterpreted on its behalf.
Peppol BIS 3.0: syntax: "ubl" plus ruleset: "peppol"
For the Belgian mandate and for anything transmitted over the Peppol network,
ruleset: "peppol" layers the PEPPOL-EN16931-Rxxx pack on top of the
EN 16931 core — faithful to OpenPeppol, with sourced rule ids, and replayed at
re-validation exactly like the core rules. The service writes the
CustomizationID and ProfileID headers itself; the agent does not supply
them.
What the agent does supply are the electronic addresses. Peppol requires the
seller’s (BT-34, R010) and the buyer’s (BT-49, R020), each declared as
electronic_address: { value, scheme } with an official EAS code — 0208
for a Belgian enterprise number, 0009 for a SIRET. value is the bare
identifier: the scheme code lives in scheme and must not be repeated inside
value. A missing address is a sourced, unbilled 400:
{ "code": "INVALID_INVOICE_INPUT",
"error": "invoice input is invalid: 1 actionable issue(s) listed in issues[]",
"issues": [ { "code": "rule_violation", "rule_id": "PEPPOL-EN16931-R010",
"message": "[PEPPOL-EN16931-R010] Buyer electronic address (BT-49) MUST be provided.",
"path": "/*/AccountingCustomerParty/Party/EndpointID" } ] }
Two design choices worth calling out:
- Fatal blocks, warning informs. Missed fatal Peppol rules join the
EN 16931 violations in
issues[]. Passed warning-severity rules do not block: they come back indata.warnings[]on the 200, so the agent sees the non-binding recommendations without the invoice being withheld. ruleset: "peppol"withsyntax: "cii"is a 400, not a verdict. Peppol mandates UBL, so the request contradicts itself. Contrast the validation side, where a CII document sent with?ruleset=peppolis a billed 200 with aninvalidverdict — there you asked a legitimate question about a document that exists. Here you are fabricating one, and an incoherent instruction is a bad request.
Address shape is checked on every call, with or without the Peppol ruleset:
an EAS code repeated inside value, or a malformed identifier for a scheme
Peppol treats as fatal, is an implausible_identifier issue. Nothing that a
Peppol access point could not route comes back validated: true.
The rule EN 16931 does not enforce: the VAT mention
An invoice can satisfy every BR- rule and still be rejected by a tax
authority. An intra-Community supply or a cross-border B2B service without its
reverse-charge mention is irregular under Article 226 of Directive
2006/112/EC — and no business rule says so, because the rules check structure,
not tax treatment.
The optional vat_treatment field has the transaction judged by the engine
behind POST /invoice/vat-treatment and
writes the determined mention into BT-120, the field the standard provides
for it — in the CII, in the UBL, and in both the embedded XML and the rendered
page of the Factur-X PDF. Three properties keep it honest:
- The mention is written only on VAT breakdown groups whose category can
carry it (
E,AE,K,G,O— where no VAT is charged), never on a taxed group. - A
vat.exemption_reasonyou supplied is kept exactly as written, andnotes[]says so. - A country in
vat_treatmentthat contradicts the country the invoice declares (BT-40 / BT-55) is a400 vat_treatment_mismatch, not billed — a verdict rendered on other countries judges a different transaction, and the mention it produced would be false.
An uncovered regime returns mechanism: "undetermined" with its rationale; the
invoice is still generated, and no mention is invented. The
VAT-treatment article
covers that verdict on its own terms.
Errors are actionable and unbilled, all in one turn
The x402 golden rule applies literally here. The question is “give me a conformant invoice”, so the only billed case is a 200 with a generated, self-validated document. Everything an agent can fix is a 400:
| Status | Case | Billed |
|---|---|---|
| 200 | Invoice generated and passed its own validation (validated: true) | yes |
400 INVALID_INPUT | Not the schema: bad JSON, an unknown field, or a query parameter (the endpoint takes none) | no |
400 INVALID_INVOICE_INPUT | Schema-valid but not generatable conformant — all issues in issues[] | no |
| 413 | Body over the 10 MB cap | no |
| 500 | A violated post-condition — an internal bug, never served | no |
The detail that saves round trips: every independent issue is reported
together, each with a stable code, a message, and the normative vocabulary
where it exists (term in BT/BG, rule_id in BR-xx or PEPPOL-Rxxx, path). An
agent fixes its payload in one turn rather than discovering violations one 400
at a time. Unknown fields are rejected rather than ignored, so a typo names
itself instead of silently dropping a value.
Read the pinned artefact version — the standard moves
Every response carries provenance.freshness.as_of, dating the exact normative
artefact set the invoice was generated and validated against — EN 16931
validation artefacts, the Factur-X specification, and the OpenPeppol BIS pack,
each at a named version. There is no fetch and no wall clock behind the
guarantee: it is pinned.
That pin matters because the ground moves faster than most integrations. FNFE-MPE
and FeRD published Factur-X 1.09 / ZUGFeRD 2.5 on 10 June 2026, applicable
from 1 July, then the corrigendum 1.09.2 / 2.5.2 in August
(fnfe-mpe.org). Peppol BIS Billing
3.0 is updated semi-annually, in May and November
(docs.peppol.eu).
A generator that does not tell you which artefacts it used is telling you
nothing durable — compare the as_of in the response against the release your
own obligation is written to, and you know exactly where you stand.
Where generation sits in the invoice cycle
The four invoice endpoints are one rule set seen from four angles:
POST /invoice/read— extract an inbound document into the EN 16931 semantic model, faithfully, without judging it.POST /invoice/validate— the verdict on a document you received, with official rule ids and observed-vs-expected values.POST /invoice/generate— the XML core you issue, re-proved against that same verdict engine.POST /invoice/generate-pdf— the same core wrapped in a PDF/A-3B a human can open and a machine can parse.
For a Peppol issuance, three settled calls make one flow: generate the BIS 3.0
invoice, re-prove it with ?ruleset=peppol if you
want the verdict as a separate artefact to file, and check the recipient is
actually reachable with GET /company/peppol before
you send. The inbound counterpart — what to do with an invoice that arrives — is
covered in
reading an inbound e-invoice.
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 generation will not do
- No numbering.
invoice_number(BT-1) is yours: the service does not allocate, sequence or de-duplicate invoice numbers. - No archiving, no signature, no transmission. The runtime is stateless pure computation — no network call, no secret, no store. It is not an approved platform and it does not send anything on your behalf.
- No registry lookup. Identifiers are checked for syntactic
plausibility only — IBAN mod-97, SIREN/SIRET Luhn, VAT syntax. Whether the
counterparty exists is
/company/resolveand/company/vat, separate paid calls. - No design. The PDF page is a minimal deterministic render — the value is the conformance and the embedded XML, not the template. Same input, same PDF, byte for byte, because every value comes from the invoice and none from the clock.
- Bounded profiles in v1. Factur-X EN 16931 profile only in CII (no MINIMUM/BASIC/EXTENDED emission); XRechnung is validated but not generated; the UBL CreditNote (type 381) is not produced, though the CII credit note is.
Used for what it is — a settled call that turns business data into a document
whose conformance has already been proven against the official rule set —
generation is the step that lets an agent issue invoices without a human
verifying each one. The full request contract, the field-by-field schema and the
complete issue-code table are in the
POST /invoice/generate documentation and the
POST /invoice/generate-pdf documentation.