XRechnung and EN 16931 Invoice Validator
A zero-dependency Python conformance validator for EN 16931 invoices — UBL 2.1, UN/CEFACT CII, the German XRechnung CIUS, and the XML embedded in Factur-X / ZUGFeRD PDFs.
E-Invoicing
Grades an electronic invoice against the EN 16931 rule set and reports which business rules failed, on your own machine.
- Published version: 0.2.9 on PyPI (
pip install verifyhash-einvoice). The source tree is ahead at 0.2.10, which is not published — 0.2.9 is what apip installgets you today. - Language: Python 3.8+, standard library only
- License: Apache-2.0
🌐 Project site · 🧪 Grade an invoice in your browser · 📖 Read Blog Post
What it grades
EN 16931 is the European semantic model for the core invoice, and it is carried in two XML syntaxes: UBL 2.1 Invoice and UN/CEFACT CII. The validator reads both. Germany’s XRechnung is a CIUS — a narrowing — layered on top of EN 16931, and it is selected explicitly with --profile=xrechnung rather than guessed from the document.
Whether that German layer matters to a given reader has a statutory answer. Under the Wachstumschancengesetz (Growth Opportunities Act, passed by the Bundesrat on 22 March 2024), businesses established in Germany have had to be able to receive an EN 16931-conformant e-invoice since 1 January 2025, with no turnover threshold; issuing one is phased by prior-year turnover, from 1 January 2027 above EUR 800,000 and from 1 January 2028 for everyone else. The scope is domestic B2B: B2C sales and small-value invoices of EUR 250 gross or less sit outside it, and a plain emailed PDF does not count as an e-invoice, though the XML inside a Factur-X / ZUGFeRD file does. That is what gives a conformance check in a build a dated boundary rather than a vague one; it says nothing about whose verdict counts.
Factur-X / ZUGFeRD invoices are hybrid PDF/A-3 files: a human-readable PDF with the invoice XML attached inside it. Point the CLI at the PDF and it extracts that attachment and grades the XML directly, so there is no separate unzip-the-attachment step in front of the validator.
The published 0.2.9 package’s own PyPI summary describes it as asserting 297 business rules, including the national BR-DE and BR-DEX CIUS and extension layers, each differential-tested against the official Schematron within the implemented set. That figure is the published artifact’s claim about itself, quoted here rather than independently measured.
Zero dependencies, and what that buys
The package is Python 3.8+ standard library only: no lxml, no Java runtime, no Schematron or XSLT toolchain, and no network calls. Three practical consequences follow from that:
- It embeds. Rules are plain functions over a parsed invoice model, so an ERP or billing system can
importit in-process instead of shelling out to — or HTTP-posting invoices at — a separate validator service. - It installs anywhere. Alongside
pip install, copying the bareeinvoice/package directory into your own tree is a supported install method, which matters on an air-gapped build box with no package index. - Nothing leaves the machine. Invoices are commercially sensitive; a validator that makes no network calls cannot leak one.
Honest limits
It is a conformance validator, not an e-invoicing stack. It does not do XSD schema validation, it does not write ZUGFeRD PDFs, and it does not cover full Peppol BIS Billing 3.0 ecosystems. It is also not the reference implementation. KoSIT’s official validator is free, open source, and published by the body that publishes the XRechnung standard itself — and it is the artifact this engine’s correctness is measured against. Mustangproject is likewise free, mature and in wide production use.
The comparison those two leave out is the one a developer meets first: the free browser validator you drag a file into. The European Commission’s own eInvoice Validator checks UBL and CII invoices against EN 16931 in a web form: no install, no account, an answer in seconds. Free upload forms of that class often cover more syntaxes and national rule sets than this engine does. For one file and one question right now, that is the easier and usually the broader tool — reach for it.
The difference is structural, not a matter of quality. A web form means sending the document to somebody else’s server, a policy question inside plenty of finance teams; here the file never leaves the machine, and an upload form cannot run inside a build.
So why run this one? On a single axis: it is a zero-dependency, pure-Python, CI-native gate — no Java, no Saxon, no Schematron toolchain — whose correctness is differentially proven against the official KoSIT artifact rather than read independently off the spec. That is worth something when the gate has to run on every push in whatever CI image you already have. Otherwise it is worth nothing.
Several jobs are genuinely better done by the other two:
- You need the official verdict. Before an invoice goes to a government portal, run KoSIT’s validator (or your receiver’s) — its answer is the one that counts. This is a pre-flight, not the final word.
- You need XSD schema validation. KoSIT runs the official schemas; this engine does business rules only.
- You need full Peppol BIS Billing 3.0. Only the 21 KoSIT-vendored
PEPPOL-EN16931-R*rules are asserted here; both other ecosystems do more. - You need to create ZUGFeRD / Factur-X PDFs. Mustangproject writes them — builds the PDF/A-3, embeds the XML. This is validation-only.
The full side-by-side, including the rows where the honest answer was “not verified”, is at verifyhash.com/einvoice/compare/.
Running it
pip install verifyhash-einvoice # 0.2.9, the published release
einvoice validate invoice.xml
einvoice validate --profile=xrechnung invoice.xml
einvoice validate invoice.pdf # Factur-X / ZUGFeRD: grades the embedded XML
Exit codes are the part that makes it usable in CI: 0 when every implemented fatal rule passes, 1 when at least one fatal rule failed, 2 for a usage error, and 3 when the input is not well-formed XML at all. A build gate can therefore tell “this invoice is wrong” apart from “you handed me something that is not an invoice.”
The same engine is importable, so an ERP or an ingest worker can grade an invoice in process rather than shelling out to the CLI and re-parsing its output:
import io, einvoice
result = einvoice.validate_file("invoice.xml") # -> Result: .valid, .first, .ok, .to_dict()
print(result.valid, result.first)
payload = message.body # bytes already in memory
report = einvoice.validate_bytes(payload, filename="from-queue.xml")
print(report["valid"], report["fatal_count"], report["violations"])
try:
einvoice.validate_file(io.BytesIO(payload)) # file-like input, so no temp file
except einvoice.NotWellFormed as exc:
reject(f"not well-formed XML: {exc}")
Three things are worth knowing before wiring it in. The profile defaults are not uniform — validate_file defaults to en16931, while validate_bytes and validate_batch default to xrechnung — so pass profile= explicitly instead of inheriting whichever entry point you happened to reach for. A malformed payload raises NotWellFormed, one catchable exception rather than a traceback out of the parser, which is what an ingest queue wants at the boundary. And a Factur-X / ZUGFeRD PDF container is a different input shape from raw XML: hand those bytes to validate_bytes, which pulls the embedded XML out itself, rather than to validate_file. Nothing in the artifact tags this surface as frozen, so pin the exact version you tested against.
Where to get an invoice to try it on
That invoice.xml has to come from somewhere, and your own invoices are the worst possible first file: none of them is known to fail, so a pass tells you nothing and a failure leaves you unable to say whether the tool is wrong or your data is. Start with documents whose verdict somebody else already wrote down.
One that should pass. KoSIT’s XRechnung test suite is Apache-2.0 and holds 66 complete reference invoices under src/test/business-cases/standard — 33 business cases rendered twice, once in UBL and once in UN/CEFACT CII. 01.01a-INVOICE_ubl.xml is a 6.7 kB UBL document whose CustomizationID ends in xrechnung_3.0, so it is a fair target for --profile=xrechnung. Its README is candid that rule-set churn can make even these reference messages report errors for a while.
One that should fail. The EU’s EN 16931 Schematron repository keeps per-rule negative tests in test/Invoice-unit-UBL — 206 XML files, each named after the rule it exercises, licensed EUPL 1.2 (GitHub does not auto-detect that; LICENSE.txt says it plainly). BR-CO-16.xml holds 13 cases, ten marked <success> and three marked <error>; one error case pairs TaxInclusiveAmount 1200.00 and PrepaidAmount 100 with a PayableAmount of 1200.00 where the arithmetic gives 1100.00. Check the shape before you reach for it: those are cac:LegalMonetaryTotal fragments inside a <testSet> wrapper rather than standalone invoices, so copy the numbers into a passing file and watch the verdict flip.
What it says when an invoice is wrong
The file below is a single-line UBL 2.1 invoice that is conformant in every respect but one: the payable total reads 283.00 where the arithmetic gives 238.00. It is a transposed digit, the most ordinary error there is, and an XSD schema check waves it straight through — 283.00 is a perfectly well-formed decimal sitting in a perfectly well-placed element. Here is what verifyhash-einvoice==0.2.9, installed into an empty virtualenv, actually prints:
$ einvoice validate invoice.xml
FAIL: invoice.xml
BR-CO-16: Amount due for payment (BT-115=283.00) must equal Invoice total with VAT (BT-112=238.00) - paid amount (BT-113=(absent)) + rounding amount (BT-114=(absent)).
offending element: cac:LegalMonetaryTotal/cbc:PayableAmount
how to fix: einvoice --explain BR-CO-16
rule page: https://verifyhash.com/einvoice/rules/BR-CO-16/
1 finding(s) total: 1 fatal, 0 non-fatal (--format json carries every field of each)
Syntax-binding warnings: 0
Three things in that report are the reason to run one of these at all. The rule id BR-CO-16 is not a label this tool invented — --explain below names its source as the official en16931-ubl Schematron, so it is the identifier other conformance tools use for the same defect. The substituted values show the rule’s arithmetic with this invoice’s own numbers already filled in: BT-115=283.00 against BT-112=238.00, with the two optional terms reported honestly as (absent) rather than silently as zero. The offending element is an XPath into the document, so you know which element to edit; when the failing element is missing altogether the tool prints an insertion point with a file and line number instead. The process exits 1.
Both pointers are written in UBL names whatever syntax the invoice arrived in. The CII twin of that same KoSIT business case, 01.01a-INVOICE_uncefact.xml, and a ZUGFeRD PDF/A-3 whose embedded factur-x.xml had a transposed total, each came back pointing at cac:LegalMonetaryTotal/cbc:PayableAmount — a path no CII document holds, since CII spells that total ram:DuePayableAmount. The rule id, the substituted values and the explanation stay correct; the element pointer has to be mapped across to the equivalent CII element by hand, and neither non-UBL input produced an insertion point, file or line number. The national BR-DE-* rules were the exception, printing ram: paths.
The last line of the finding is a command rather than a suggestion. --explain takes any rule id and prints the remediation-catalog entry for it, reading no invoice at all:
$ einvoice --explain BR-CO-16
BR-CO-16 Amount due for payment (BT-115) = Invoice total with VAT (BT-112) − Paid amount (BT-113) + Rounding amount (BT-114).
requires : Amount due for payment (BT-115) = Invoice total amount with VAT (BT-112) -Paid amount (BT-113) +Rounding amount (BT-114).
BT/BG : BT-112, BT-113, BT-114, BT-115
location : cac:LegalMonetaryTotal
fix : Correct the calculated amount at `cac:LegalMonetaryTotal` so that Amount due for payment (BT-115) = Invoice total amount with VAT (BT-112) -Paid amount (BT-113) +Rounding amount (BT-114).
severity : fatal
source : en16931-ubl (Schematron)
assert : Amount due for payment (BT-115) = Invoice total amount with VAT (BT-112) -Paid amount (BT-113) +Rounding amount (BT-114).
rule page: https://verifyhash.com/einvoice/rules/BR-CO-16/
in German: einvoice --explain BR-CO-16 --lang=de
So a failure names a rule and the rule names a fix, without a round trip to the standard. One thing worth knowing before you reach for --lang=de: grading the same file under --profile=xrechnung --lang=de returned BR-CO-16 still in English, because a CORE EN 16931 rule keeps its binding English text, while the BR-DE-* rules that the stricter German profile additionally tripped came back in German. That German is not one thing: for the BR-DE-* national rules the title and requires text are the official XRechnung Schematron’s own German, quoted as-is, while the core rules have no official German, so theirs is this project’s translation and the binding English requires line stays. Every --lang=de explanation prints a german: line saying which of the two you are looking at.
Wiring it into a build
A build gate is the CLI plus a report format your CI already renders. Pin the published release so the rule set cannot shift under a green build:
pip install verifyhash-einvoice==0.2.9
einvoice validate --format sarif invoice.xml > results.sarif # one file
einvoice validate-batch invoices/ --format junit > results.xml # a directory
The gate keys on the exit code. 0 passes. 1 means at least one fatal rule failed — fail the build. 3 means the input was not well-formed XML, which is worth failing separately, because it usually points at a broken generator upstream rather than at a bad invoice. Over a directory, validate-batch rolls those up: 1 if any file has a fatal violation, else 3 if any file errored, else 0 — and an empty directory is an honest file_count: 0 and exit 0, not a silent pass.
That leaves every corpus with history: old invoices already fail. The CLI diffs a run against a report you store, so the gate trips only on new violations — switch it on this afternoon and burn the backlog down deliberately:
einvoice validate invoice.xml --json > baseline.json # exits 1
einvoice validate --baseline=baseline.json invoice.xml # exits 0
The second exits 0 with the pre-existing fatal finding still present and still counted: "new_count":0 beside "unchanged_count":1 and "current_fatal_count":1. A ratchet, not an amnesty — anything the baseline lacks raises new_fatal_count and returns exit 1. Diff JSON goes to stdout alone, notes to stderr, so | jq still parses. One note the tool prints: a baseline that declares no 'profile' diffs two different rule sets if captured under a different --profile. The tool cannot check on your behalf which profile a stored baseline was captured under, so keeping that straight is your side of the bargain — capture and diff with the same profile and the ratchet behaves exactly as described above. Nothing is remembered between runs; it compares against that stored file only.
One more subcommand records what a run graded. einvoice receipt invoice.xml emits a JSON document holding the SHA-256 of the exact bytes it read, the profile, the engine name and version, the verdict, and the id and message of every fatal rule that fired. The same bytes under the same profile give a byte-identical file; it reads no clock. einvoice receipt --verify receipt.json re-checks one in a single command: VERIFIED and exit 0, or TAMPERED with the recomputed and the stored hash and exit 1. But content_sha256 is an unkeyed hash over the canonical body, so anyone who can edit the receipt can recompute it. This is tamper-evident, not tamper-proof.
Pick the format your system already consumes. sarif uploads to GitHub code scanning and draws inline pull-request annotations; gitlab emits a Code Quality document for merge-request annotations; github and azure emit inline logging commands (::error and ##vso[task.logissue]); junit lands in almost any CI’s test-report tab; badge is a shields.io endpoint; html and text are for humans. One caveat that bites: validate-batch aggregates only json, junit and text. The other six each describe a single invoice, so run those per file.
On GitHub there is a shorter route: a composite Action ships in the same repository, at einvoice/action/:
- id: einvoice
uses: verifyhash/verifyhash/einvoice/action@v0.2.10
with:
path: invoices/
format: github # or sarif (default), json, junit, text
fail-on: fatal # or warning
profile: xrechnung # or en16931
sarif-file: einvoice.sarif
# baseline: ci/baseline/2024-11.json # 6th input: fail only on NEW findings.
# Needs path: to name ONE file, not a directory — see below.
Those five cover the everyday shape, but a sixth input, baseline, is the one that decides whether this Action can be switched on at all in a repository whose invoices already fail. Point it at a prior JSON report — captured once with python3 -m einvoice.report --profile xrechnung --format json invoices/2024-11.xml and committed — and the build fails only on a violation that is new relative to that report; fatals already recorded in it are tolerated, so there is no red build on day one. The merged SARIF and the pull-request annotations are still produced in full: the baseline decides the verdict, not what gets annotated. Two limits are load-bearing — it diffs one invoice against one stored report, so path must select exactly one file rather than a directory, and the baseline must have been captured under the same profile the run uses. It is a migration tool, not a conformance verdict. Its one output, also sarif-file, is the path of a merged SARIF 2.1.0 document. That merge is what the raw CLI will not do: validate describes one invoice per report, while the Action folds a directory into one file for github/codeql-action/upload-sarif. The other addition is format: github: ::error file=…,title=<rule-id>:: lines that annotate a pull request inline without the security-events: write permission upload-sarif requires; the merged SARIF is written either way.
Two caveats about pinning. The repository publishes one release tag, v0.2.10, so a semantic pin resolves; a full commit SHA is still the stronger pin, because a tag can be moved and a commit cannot. And the Action does not run the engine you install: the public tree’s einvoice/pyproject.toml reads 0.2.10 while PyPI serves 0.2.9, so a job sourced from that tag executes source one patch ahead of what pip install gives you. Nothing here claims the two grade identically — if that matters, pin the SHA and the wheel version together. Driving its runner locally over two broken fixtures printed 2 file(s), 4 fatal, 3 warning (fail-on=fatal), the annotations above, a schema-valid SARIF file and exit 1.
Earlier than the build: before each commit. A .pre-commit-hooks.yaml sits at the repository’s clone root, the file pre-commit reads when another repository references this one remotely — so nothing is vendored into your tree. Four lines in your own .pre-commit-config.yaml:
repos:
- repo: https://github.com/verifyhash/verifyhash
rev: v0.2.10 # or a full 40-char SHA — see the caveat below
hooks:
- id: einvoice # manifest scopes it to files: \.xml$
A commit staging no XML therefore runs nothing. The manifest declares language: script, and a script hook installs nothing, which is the single prerequisite: the einvoice package has to be importable already in the environment pre-commit run executes in, whether from pip install verifyhash-einvoice or from EINVOICE_CMD / PYTHONPATH aimed at a source checkout. Pinning has the Action’s shape: rev: v0.2.10 is the published release tag, a bare main rev floats, and a full 40-character commit SHA is the pin nothing can move under you.
Off GitHub, a ready GitLab job ships alongside the hook’s wrapper script at einvoice/ci/gitlab-ci.yml. That template vendors the package into third_party/ so it works on an air-gapped runner; the shorter form, using the published wheel, is this:
invoice-conformance:
image: python:3.12-alpine
script:
- python3 -m pip install verifyhash-einvoice==0.2.9
- mkdir -p einvoice-junit
- |
rc=0
for f in invoices/*.xml; do
einvoice validate --profile xrechnung --format junit "$f" \
> "einvoice-junit/$(basename "$f" .xml).junit.xml" || rc=1
done
exit $rc
artifacts:
when: always
paths:
- einvoice-junit/
reports:
junit: einvoice-junit/*.junit.xml
The loop keys on the same exit-code contract the GitHub gate does, so the pipeline fails on exactly what a local commit would have blocked, and artifacts:reports:junit renders each fatal rule as a row in GitLab’s Tests tab instead of burying it in the job log.
What it costs
The engine is free. Every part of it is Apache-2.0 — use it, modify it, redistribute it, embed it inside a closed-source product, at no cost, as long as you keep the licence text and the NOTICE file with any redistribution and mark files you changed. Like all open-source licences it ships as is, with no warranty and no indemnity.
There is an optional commercial licence on top, at two flat one-time prices: $29 for a single developer and $290 for a whole vendor team. In the licensing page’s own words it “buys convenience, not permission” — it is not a compliance obligation, it is not metered by request volume, and you never need it to run or embed the engine.
What the money buys is two things and only two: best-effort email support from the maintainer for integration questions, and rule-corpus update notices when the EN 16931 / XRechnung rule set changes. The $290 tier is those same two things for any number of developers at one company, with priority on both. It does not buy extra rules, faster validation, certification, indemnity or a warranty — the paid and the free engine are the same engine. Either way there is a person on the other end: hello@verifyhash.com is the maintainer’s contact for licensing and integration questions, and you do not have to open a public issue to use it. Ask something, or just say you want to know when checkout opens.
Checkout is not open yet, and no payment is being taken; the licensing page’s own closing heading is “Questions, or want to know when checkout opens?”. The full terms, plus a German summary (Kurzfassung (Deutsch)), are at verifyhash.com/einvoice/licensing/.
If this project stops. That is the question that decides whether a one-person tool gets into a vendor’s build, so here is the answer. The Apache-2.0 grant is irrevocable and includes the right to fork: take the code, rename it, maintain it yourself or pay somebody to, and keep shipping it inside your product. None of that depends on this project continuing. Because the engine is standard library only, a fork inherits no dependency tree to rot — a wheel with zero requirements keeps installing for as long as a Python 3.8+ interpreter exists on your build box. And the rule corpus travels inside the artifact rather than behind an API, so there is no server anyone can switch off and no key to expire: a copy of the wheel in your own artifact store grades invoices offline for as long as you keep the file.
The honest other half of that picture: this is small, it is single-maintainer, and checkout is not open. So treat the published 0.2.9 wheel on PyPI as the thing to rely on — pin it, mirror it into your own index, or copy the package directory into your tree. That wheel is also what a fork would start from; the GitHub repository the PyPI record links to trails it.
Trying it without installing anything
The same engine runs client-side at verifyhash.com/einvoice/validate/, compiled to WebAssembly through Pyodide. The invoice is never uploaded — the grading happens in your own browser tab. The cost is a one-time download of the Python runtime into the page, so the first grade waits on that download; later ones start straight away. Worth knowing before you demo it to somebody.