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
OutputXML core, data.xmldata.pdf_base64 and data.xml
SyntaxesCII D16B (default) or UBL 2.1CII D16B only (Factur-X embeds CII)
Peppol BIS 3.0Yes, ruleset: "peppol" with syntax: "ubl"No
Post-conditionsRe-validatedRe-validated, re-read bit-for-bit, PDF/A-3B
Input schemaIdentical business JSONIdentical 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 official BR-CO rounding rules.
  • Provide totals and they are verified against that same computation. A divergence is a 400 carrying an inconsistent_totals issue 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 in data.warnings[] on the 200, so the agent sees the non-binding recommendations without the invoice being withheld.
  • ruleset: "peppol" with syntax: "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=peppol is a billed 200 with an invalid verdict — 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_reason you supplied is kept exactly as written, and notes[] says so.
  • A country in vat_treatment that contradicts the country the invoice declares (BT-40 / BT-55) is a 400 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:

StatusCaseBilled
200Invoice generated and passed its own validation (validated: true)yes
400 INVALID_INPUTNot the schema: bad JSON, an unknown field, or a query parameter (the endpoint takes none)no
400 INVALID_INVOICE_INPUTSchema-valid but not generatable conformant — all issues in issues[]no
413Body over the 10 MB capno
500A violated post-condition — an internal bug, never servedno

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:

  1. POST /invoice/read — extract an inbound document into the EN 16931 semantic model, faithfully, without judging it.
  2. POST /invoice/validate — the verdict on a document you received, with official rule ids and observed-vs-expected values.
  3. POST /invoice/generate — the XML core you issue, re-proved against that same verdict engine.
  4. 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/resolve and /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.