einvoice — EN 16931 / XRechnung conformance
einvoice
A zero-dependency, self-hostable conformance validator for EN 16931 electronic invoices, targeting the German XRechnung CIUS. Both official XRechnung syntaxes are validated directly: UBL 2.1 (ubl:Invoice or CreditNote) and UN/CEFACT CII (rsm:CrossIndustryInvoice — the syntax ZUGFeRD and Factur-X carry), through the same rule engine — the same syntax-agnostic EN 16931 core plus the German BR-DE-* CIUS layer — the same --profile values and the same exit codes. Dispatch is on the root element: no syntax flag to pass, and a raw CII .xml needs no PDF container. It asserts 297 business rules, each differentially proven against the official Schematron artifacts, and runs offline against a vendored copy of the official rule corpus — no lxml, no Java, no Saxon, no Schematron toolchain, no network calls. Pure Python 3 standard library, so the same check runs unchanged in any CI job.
What EN 16931 / XRechnung conformance means
EN 16931 is the European standard that defines the semantic data model of an electronic invoice — the business terms (BT-) and business term groups (BG-) an invoice must carry, and the arithmetic and code-list rules those values must satisfy. XRechnung is the German national CIUS (Core Invoice Usage Specification): it keeps the EN 16931 core and adds ~30 German-specific asserts (the BR-DE-* rules from KoSIT — BuyerReference, seller contact, payment-means grouping, Skonto/discount grammar, IBAN checks). An invoice is conformant when it violates none of the fatal rules of the profile you validate against.
Concretely, a rule like BR-DE-15 requires the buyer reference (BT-10) to be present; if it is missing, a conformance validator reports that rule ID and the invoice is rejected by the receiver's portal. This site documents every rule this engine checks, one page each, in English and German.
Who this is for
German (and EU) ERP, billing and accounts-payable developers who issue or receive structured e-invoices and need to know before they send that an invoice will pass. Since 1 January 2025 every German business must be able to receive EN 16931 invoices, with the obligation to issue phasing in through 2027–2028; France, Belgium and others are on similar timelines. If you generate XRechnung or ZUGFeRD/Factur-X from an ERP, this is the gate that tells you whether the output is valid.
What is proven — the current coverage numbers
The engine asserts 297 business rules: 219 of the 223 official EN 16931 BR-* rule ids per CEN syntax universe (UBL and CII), the complete German XRechnung CIUS + extension layer (BR-DE-*, BR-DEX-*, BR-DE-CVD-*, BR-TMP-*), and the 21 PEPPOL-EN16931-R* rules KoSIT ships inside the official XRechnung Schematron artifact — the KoSIT-vendored subset only, not Peppol BIS Billing 3.0 support. The machine-checked fireable-missing count is 0 in both CEN EN 16931 universes: every official BR-* assert that can actually fire is either asserted by the engine or a documented deliberate exclusion. That is deliberately not an uncaveated 100 % claim: 4 official ids (BR-CO-05–BR-CO-08) are shipped as literal test="true()" tautologies in the CEN artifacts — asserts that can never fire, in either universe, so implementing them with a differential proof is impossible by construction.
The last admitted gap in the KoSIT XRechnung artifact — the Clean-Vehicle-Directive family (BR-DE-CVD-*, BR-TMP-*) — is closed with differential proof in both bindings. Proof parity between the two syntaxes is machine-tracked rather than frozen in prose: a test recomputes the worklist live from the coverage matrix and the vendored CII Schematron. That worklist is now closed: 281 of the 297 asserted rules are differential-proven on both UBL and CII, 12 are officially UBL-only and 4 are CII-only, with 0 rules left on the cii-fireable worklist — every UBL-only rule is resolved with verbatim artifact evidence (4 cii-artifact-defective, 8 binding-inapplicable). All differential legs run at 0 divergences against the official Schematron.
Beyond the business rules, the two CEN artifacts also carry syntax-binding asserts (UBL-CR-*/CII-*) — pure syntax-layer restrictions like “this element must not appear” or “at most one of X”. A restricted data-driven evaluator mirrors 741 of 756 UBL + 554 of 583 CII of these per binding, each differential-proven against the official Schematron at 0 divergences; the remaining 44 (15 UBL + 29 CII) are machine-listed as known-open in COVERAGE.md, never guessed. They surface under a distinct syntax_bindings category in the --json output as advisory warnings that never change the exit code, kept strictly separate from the 297 business-rule count.
- Coverage matrix (COVERAGE.md) — the authoritative per-rule inventory: every asserted rule, the syntax it is proven in, its severity, and every deliberate exclusion with verbatim artifact evidence.
- Remediation catalog — 297 machine-readable entries (rule, plain-language fix, XML location, severity, English and German), the single source of truth these rule pages are generated from.
Reproduce these numbers yourself
Every count on this page is rebuilt from the vendored official artifacts by a single committed entrypoint — nothing here is a hand-typed figure. From a checkout of einvoice/, run the exact command the repository README documents:
PYTHONPATH=$HOME/.local/lib/python3.10/site-packages python3 prove.py
prove.py re-runs the full differential harness over every leg plus the conformance corpus, asserts the divergence count against the official CEN / KoSIT Schematron, and prints the coverage headline recomputed live this run (it reads no number from a string literal, so a stale figure cannot slip through). It exits non-zero on any failure and takes a few minutes. The authoritative per-rule inventory those figures roll up from is the coverage matrix (COVERAGE.md); consult it rather than any digit copied into prose here, which could drift.
Honest scope
Auditable, but not a legal guarantee. A green result means “no implemented fatal rule fired”, not “certified legally conformant”: every fireable official BR-CL-* code-list check is now implemented in both syntaxes (the ids the engine does not assert are documented deliberate exclusions — never-firing test="true()" tautologies), structural XSD validation is not performed, and a UBL 2.1 CreditNote (root CreditNote-2:CreditNote) is validated through the same EN 16931 engine as an Invoice. The exact implemented set and its limits are written up in the repository README, COVERAGE.md and CORRECTNESS.md.
Weighing this against the free official toolchain? Read the honest comparison with the official KoSIT validator and Mustangproject — what each is best at, and exactly when to prefer them over this tool.
Free on-ramp
New here? The fastest way in is the 5-minute worked walkthrough: it takes a broken XRechnung invoice, runs the checker, shows the real report it prints, and applies the two-element fix until the invoice passes.
Or go straight to a verdict on an invoice of your own — Python 3.10+, zero dependencies, two lines:
python3 -m pip install verifyhash-einvoice
einvoice validate --profile xrechnung invoice.xml
Exit code 0 means no implemented fatal rule fired, 1 means at least one did (the first is named on stdout), 3 means the file could not be read as XML. --profile xrechnung adds the German KoSIT layer on top of EN 16931; drop it for the European core rules alone. Add --format json for the full finding list, and einvoice --explain BR-DE-15 prints the remediation entry for any rule id you get back.
A ZUGFeRD 2.x / Factur-X PDF is valid input. The hybrid container is not a separate product to us: a PDF/A-3 e-invoice carries the very same rsm:CrossIndustryInvoice XML as an embedded attachment, so you hand the container to the command you already know. einvoice validate invoice.pdf pulls that attachment out with the Python standard library alone — no PyPDF, no Ghostscript, no network — and grades it under exactly the rules it would apply to the same XML on its own. It is not a second engine: the extracted CII runs through the identical rules.ALL_RULES core, with the same --profile values and the same exit codes as a loose .xml. validate-batch walks PDFs the same way:
einvoice validate --profile xrechnung invoice.pdf
On the Factur-X fixture that ships in this repository (corpus/pdf/facturx-valid.pdf, a repository checkout file, not a wheel file), einvoice validate exits 0 under the CLI’s default en16931 profile and 1 with --profile xrechnung — three fatal violations (BR-DE-23-b, BR-DE-31, BR-TMP-3) and two warnings. The German CIUS layer applies to a container’s payload exactly as it does to loose XML; the packaging changes nothing about which rules fire.
The extractor is a PDF reader for the classic case, and it says so instead of guessing. An encrypted PDF (/Encrypt), a cross-reference-stream / object-stream layout (PDF 1.5+ with no classic trailer), a truncated file and an unknown stream filter cannot be opened: each of those runs ends with exit code 3, the literal token unsupported-container, and one line naming the concrete cause — never a guess, and never a false 0. In practice the PDF/A-3 files real ERP exporters write are the classic case; a PDF rewritten by an optimiser may not be. Two further limits worth knowing before you wire this into CI: only the first embedded invoice file specification is read, and the FX-CONTAINER-* checks (/AFRelationship, /AF, the XMP Factur-X profile and whether it agrees with the CII’s CustomizationID, the PDF/A-3 marker) grade the packaging rather than the invoice — they are reported by python3 -m einvoice.report <invoice.pdf>, by validate-batch and by einvoice validate --format <fmt> <invoice.pdf>, but not in single-file validate’s default text output or its --json, which carry the invoice verdict.
Turning the checker on over invoices you already have is a different job from validating one new file, and two mechanisms exist for it. The validate-batch subcommand takes a directory or a glob, walks it recursively, validates every .xml and .pdf invoice it finds (dotfiles and dot-directories skipped) and prints one aggregate verdict — a PASS/FAIL/ERROR line per file, the totals, and a “most violated rules” ranking — while --fail-on=fatal|warning|information sets the severity at which the exit code turns non-zero, so your team picks its own gate height instead of inheriting ours (fatal is the default: warnings and information findings are reported but do not fail the build; the other two values make them fail it):
einvoice validate-batch --profile xrechnung --fail-on=fatal invoices/
Three things to know before you write that job. --profile xrechnung is not decoration — batch obeys the same default profile as every other subcommand (en16931), so omitting it grades the European core rules without the German KoSIT layer. The aggregate shape exists for json, junit and text only; the other six formats describe a single invoice, and asking for one over a directory is refused with exit 2 and a message naming the three — exactly the split einvoice --help prints. And a legacy corpus is gated file-by-file in a CI loop: --baseline, the regression diff that fails a run only on findings that are new since a stored report, is deliberately refused on the batch subcommand (exit 2 — one stored single-invoice report has nothing to diff against an aggregate), so there is no single call that freezes a whole archive. Loop einvoice validate --baseline prev.json invoice.xml per file if you want that, or hold the batch gate at --fail-on=fatal and raise it once the backlog is clean. Either way, a green aggregate means “no implemented fatal rule fired”, not “certified legally conformant”.
Freeze what is already broken; fail on what breaks next. That file-by-file loop deserves a closer look, because it is the mechanism that makes an existing archive adoptable at all. --baseline <prev-report.json> turns einvoice validate into a regression gate: it validates the invoice now, diffs the findings against a JSON report you captured earlier, and turns the exit code non-zero only for a violation that is new since that report. Nothing new — exit 0, even if the invoice still carries every fatal it carried yesterday. One new fatal — exit 1, and the einvoice-conformance-diff/v1 document names it in new_violations; the findings you fixed show up in resolved_violations, so progress is visible in the same artifact. A supplier archive that fails four rules per invoice can go into CI this afternoon and still block the fifth:
python3 -m einvoice.report --profile xrechnung --format json invoice.xml > prev-report.json
einvoice validate --baseline prev-report.json --profile xrechnung invoice.xml
Three details about those two lines, then the two limits that decide whether this is the right tool. The capture runs python3 -m einvoice.report rather than einvoice validate --json because only that entry point writes profile into the report it produces — a baseline that declares none still diffs, but a profile mismatch can no longer be caught: you get one note: line on stderr saying the profile could not be checked, not the refusal. That capture exits 1 on an invoice that fails and writes the full report anyway; the non-zero capture exit is the expected outcome, not an error, so do not let a set -e shell swallow the run. And both lines carry the same --profile xrechnung: capture and gate must agree, or the run is refused with exit 2 naming both profiles, rather than grading your flag change as a regression. Now the limits. The diff tolerates pre-existing fatals by design — that is the entire point of the flag — so a green --baseline run means “nothing got worse”, never “this invoice conforms”: it is a migration instrument, and the conformance verdict is the same command with the flag left off. And it diffs one invoice against one stored report: validate-batch --baseline is refused with exit 2, so a legacy corpus is gated file-by-file in a CI loop with one stored report per invoice — there is no single call that freezes a whole archive.
Pin the policy once, in the repository. The same three choices — output form, gate height, message language — end up typed in three places: the CI job, the pre-commit hook, and whatever a developer runs locally. They drift, and the first symptom is a build that fails on a machine where it passed. Drop a .einvoice.toml in the project root — or add a [tool.einvoice] table to the pyproject.toml you already have — and it sets the defaults for exactly three keys: format, fail-on and lang. Those three are the whole vocabulary; there is no fourth key and no key that turns rules on or off. einvoice --show-config then prints each resolved value together with where it came from — flag, the config filename, or default — reading no invoice and running no validation, so “why is CI stricter than my laptop?” is one command to settle:
einvoice --show-config
On a project with no config file that prints format: text (source: default), fail-on: fatal (source: default) and lang: en (source: default); a two-line .einvoice.toml containing lang = "de" is enough to make a flagless einvoice validate --profile xrechnung invoice.xml report its findings in German. Four limits are worth knowing before you commit the file. When both files exist, .einvoice.toml wins outright — the [tool.einvoice] table is not merged into it and is not even read, so a broken table sitting beside a good .einvoice.toml changes nothing and fixing the table changes nothing either. The config layer belongs to the einvoice console script only: python3 -m einvoice.report reads no .einvoice.toml and no [tool.einvoice] table at all, which is worth saying out loud because the recommended way to capture a --baseline report runs on exactly that entry point — pass those settings on its command line. A misconfigured file is a usage error, exit 2, naming the offending file and key: an unknown key, a non-string value or an unparseable file stops the run rather than quietly falling back to the built-in defaults. And an explicit flag on the command line still wins: the order is flag > config file > built-in default, so the file changes what you get when you say nothing, never what you get when you say something.
A receipt for the verdict. A validation run is ephemeral: the exit code scrolls past, and nothing is left beside the archived invoice recording what was actually checked. einvoice receipt <invoice.xml> emits a small deterministic JSON document that pins which bytes were graded — input_sha256, the SHA-256 of the input document's raw bytes — under which profile and which engine version, to what verdict, with the id and message of every fatal rule that fired, all wrapped with a content_sha256 taken over the canonicalised body; and einvoice receipt --verify <receipt.json> recomputes that digest and compares it in one command, offline, with no server and nothing uploaded:
einvoice receipt invoice.xml > receipt.json
einvoice receipt --verify receipt.json
Read that as tamper-evident, and no stronger. content_sha256 is a digest of the body only and is not part of its own pre-image, so editing any body field — or corrupting the stored hash itself — is caught, while a coordinated rewrite that edits the body and recomputes the hash over the forgery is internally consistent and verifies clean. Closing that gap needs an anchor outside the document: compare content_sha256 against an independently held copy of it, or re-run validation on the original bytes the receipt identifies via input_sha256 and check the body matches. The exact recompute recipe, in any language, is written out in RECEIPT-VERIFICATION.md in a source checkout. The document deliberately carries no wall-clock time by default: identical input bytes and profile produce a byte-identical receipt on every run, so it records what was graded, not when. And it is a record, not a certificate — a green verdict inside it means “no implemented fatal rule fired”, not “certified legally conformant”.
A report you can forward. The terminal summary is for you. The person who has to act on it is usually someone else — the supplier who sent the file, or the accountant who has to file it — and neither of them is going to run a CLI. --format html turns the same run into a self-contained conformance document: one HTML file on stdout with all of its styling inline and no external reference at all — no script, no image, no web font, no CDN, no tracker — and no wall-clock timestamp, so it renders the same way offline out of an archive folder in five years, and two runs over the same invoice produce byte-identical files. Add --lang de and it is a German document: German title, headings, status banner and field labels, <html lang="de"> so the declaration matches the content, and on every finding a German rule title and a German Behebung fix instruction. Its footer records the provenance of the verdict — the engine version, the number of business rules asserted, and the full 64-character attestation digest, never truncated, because a shortened hash cannot be recomputed — and those values are not typed into the report but read from the same payload einvoice info --json prints, so the recipient runs einvoice info --json on their own installation and checks the numbers instead of trusting ours. A value a build genuinely cannot determine is left out as a whole line rather than filled in with “unknown”: the footer gets shorter, never wrong:
einvoice validate --profile xrechnung --lang de --format html invoice.xml > bericht.html
Two different numbers govern how German that document is, and they are about two different fields. Exactly 50 of the 297 rules carry an official German message: the catalog entries tagged de_source == "kosit", whose vendored KoSIT <sch:assert> text is itself German (the German-authored BR-DE-* / BR-DE-CVD-* / BR-TMP-* / BR-DEX family), taken over word for word. A finding outside those 50 keeps the authoritative English sentence and says so: the paragraph is marked [en], tagged lang="en", and a note in the document explains the marker — showing the binding original is more honest than inventing a German sentence for a rule the standard never wrote one for. The rule titles and the Behebung fix hints are a separate matter: those are German for all 297 rules, and the report records per rule where that German came from — official KoSIT wording for the 50, a translation this project wrote for the remaining 247 (every one of the 297 fix hints is ours, the 50 included). That per-rule marking is the whole point: our prose is never handed to a reader as the standard's. And a green report is not legal conformance — it means “no implemented fatal rule fired”, not “certified legally conformant”.
Everything is free and open source (Apache-2.0). Start here:
- Repository README — install (
pip install verifyhash-einvoicefrom PyPI, orpip install ./ copy the package dir from a checkout when you need an offline, exactly-pinned copy), the CLI, and the full honest scope. - CI conformance gate recipe (
einvoice/ci/) — copy-paste POSIX sh + GitHub Actions / GitLab CI that fails a build on any non-conformant invoice and names the violated rule ID. - GitHub Action (
einvoice/action/) — auses:-pinnable composite action that surfaces each finding as an inline PR annotation via SARIF.
Try it in your browser — zero install
Want a verdict before installing anything? The in-browser validator runs the same engine on your machine via WebAssembly (Pyodide): drop an XRechnung XML or a ZUGFeRD/Factur-X PDF and read the findings, each linked to its rule page. The invoice is never uploaded — after an explicit one-time runtime download (~13 MB), validation happens entirely in your browser.
Browse the rules
Every rule the engine can fire has its own reference page — what it requires, the BT/BG terms it touches, the XML location, a one-line fix, the severity, and the verbatim official Schematron assert (English and German). Start at the rule index, grouped by family.
Safe on untrusted input
The invoices you validate arrive from untrusted suppliers, so the XML parser is hardened against the classic entity attacks. It uses only the Python standard library (xml.etree / expat, no lxml, no defusedxml): a <!DOCTYPE> — internal or external subset — is rejected before any entity can be defined, so entity definition and expansion never happen (billion-laughs and quadratic-blowup payloads abort in constant time and memory instead of exploding), and no external entity or external DTD is ever resolved — expat opens no file:// or http:// URL, so an XXE pointed at /etc/passwd or an internal host reads and fetches nothing. A hostile document is folded into the engine's ordinary not-well-formed outcome (its own report finding, CLI exit code 3) — a bounded, actionable result, never a crash, a hang, or a silent pass — and this adds zero runtime dependencies. This is documented in the “Untrusted input / XML entity handling” section of SECURITY.md and proven end-to-end by test_security.py and test_robustness.py.
Auf Deutsch: EN-16931-/XRechnung-Konformität
Deutschsprachige Produkt- und Schnellstart-Seite — was das Werkzeug ist, was es ehrlich abdeckt (und was nicht), Installation, erste Prüfung und CI-Anbindung, komplett auf Deutsch.
einvoice ist ein Konformitätsprüfer ohne Abhängigkeiten (reine Python-3-Standardbibliothek — kein Java, kein Saxon, keine Schematron-Toolchain, keine Netzwerkzugriffe) für elektronische Rechnungen nach EN 16931, mit Fokus auf die deutsche XRechnung (UBL 2.1 Invoice und UN/CEFACT CII). Er läuft offline gegen eine mitgelieferte, auditierbare Kopie des offiziellen Regelwerks — und damit unverändert in jeder CI-Pipeline.
Der Prüfer setzt 297 Geschäftsregeln durch: 219 der 223 offiziellen EN-16931-BR-*-Regeln je CEN-Syntax-Universum (UBL und CII), die vollständige deutsche XRechnung-Schicht (BR-DE-*, BR-DEX-*, BR-DE-CVD-*, BR-TMP-*) sowie die 21 PEPPOL-EN16931-R*-Regeln, die KoSIT im offiziellen XRechnung-Schematron-Artefakt mitliefert — nur diese von KoSIT mitgelieferte Teilmenge, keine Unterstützung für Peppol BIS Billing 3.0. Die maschinell geprüfte Lücke („fireable-missing“) ist in beiden CEN-Universen 0: Jede offizielle BR-*-Regel, die tatsächlich auslösen kann, wird entweder durchgesetzt oder ist eine dokumentierte, begründete Ausnahme. Das ist bewusst keine pauschale 100-%-Behauptung: 4 offizielle Regeln (BR-CO-05–BR-CO-08) sind in den CEN-Artefakten als wörtliche test="true()"-Tautologien ausgeliefert — sie können nie auslösen, ein differentieller Beweis ist für sie konstruktionsbedingt unmöglich.
Die letzte eingestandene Lücke im KoSIT-XRechnung-Artefakt — die Clean-Vehicle-Directive-Familie (BR-DE-CVD-*, BR-TMP-*) — ist mit differentiellem Beweis in beiden Syntaxen geschlossen. Die Beweis-Parität zwischen UBL und CII wird maschinell nachgehalten und von einem Test live neu berechnet, statt in Prosa eingefroren zu werden (Stand 2026-07-23: 281 von 297 Regeln auf beiden Syntaxen bewiesen, 0 CII-auslösbare Regeln auf der Arbeitsliste). Alle Differentialläufe gegen das offizielle Schematron laufen mit 0 Abweichungen.
Ehrlicher Geltungsbereich: Ein grünes Ergebnis bedeutet „keine implementierte fatale Regel hat ausgelöst“, nicht „rechtsverbindlich konform“ — jede auslösbare offizielle BR-CL-*-Codelisten-Prüfung ist in beiden Syntaxen implementiert, eine XSD-Strukturvalidierung findet nicht statt. Details und Einstieg: die Abdeckungsmatrix (COVERAGE.md) als maßgebliches Regelinventar, der Korrektur-Katalog (remediation_catalog.json) mit 297 maschinenlesbaren Einträgen, das CI-Rezept (POSIX sh + GitHub Actions / GitLab CI) und die Lizenzseite (Apache-2.0 für alle; kommerzielle Lizenz auf Anfrage). Jede Regel hat eine eigene Referenzseite auf Englisch und Deutsch.
Sicher bei nicht vertrauenswürdigen Eingaben
Die geprüften Rechnungen stammen von nicht vertrauenswürdigen Lieferanten, daher ist der XML-Parser gegen die klassischen Entity-Angriffe gehärtet. Er nutzt ausschließlich die Python-Standardbibliothek (xml.etree / expat, kein lxml, kein defusedxml): Ein <!DOCTYPE> — interne oder externe Teilmenge — wird abgewiesen, bevor eine Entity definiert werden kann, sodass Entity-Definition und -Expansion gar nicht erst stattfinden (Billion-Laughs- und Quadratic-Blowup-Angriffe brechen in konstanter Zeit und konstantem Speicher ab), und keine externe Entity und kein externes DTD wird je aufgelöst — expat öffnet keine file://- oder http://-URL, ein XXE auf /etc/passwd oder einen internen Host liest und lädt nichts. Eine bösartige Eingabe fällt in das gewöhnliche not-well-formed-Ergebnis (eigener Report-Befund, CLI-Exit-Code 3) — ein begrenztes, verwertbares Resultat, nie ein Absturz, ein Hänger oder ein stilles Durchwinken — und das ohne jede zusätzliche Laufzeitabhängigkeit. Dokumentiert im Abschnitt „Untrusted input / XML entity handling“ der SECURITY.md, end-to-end belegt durch test_security.py und test_robustness.py.