ZUGFeRD is the B2B deliverable. Since January 2025 no German business may refuse a compliant e-invoice, and an ordinary PDF stopped counting as one. Issuing becomes mandatory from January 2027 for companies above EUR 800k turnover, and from January 2028 for the rest. ZUGFeRD 2.3 (technically identical to France's Factur-X 1.0.7) wraps CII XML in a PDF/A-3, and the profiles EN 16931 and above qualify under the mandate; MINIMUM and BASIC WL are booking aids that do not.
XRechnung is the B2G deliverable. Federal authorities have required it since November 2020, with states and municipalities following. It is Germany's national CIUS of EN 16931, maintained by KoSIT, version 3.0 in force since February 2024. On top of the European core it adds the BR-DE rules, makes seller contact details and electronic addresses mandatory, and demands the Leitweg-ID in the buyer reference (BT-10). Submission goes through portals such as ZRE and OZG-RE that run the KoSIT Schematron on acceptance, so "almost valid" means rejected.
GoBD archiving frames the storage side. German bookkeeping rules require invoices archived as received, immutable and machine-evaluable. A ZUGFeRD file is self-archiving (one PDF/A-3 is both the visual record and the data); XRechnung archives as the XML, which makes the rendering step later in this guide more than a convenience.
What makes all of this harder than it sounds is the depth under the surface: roughly 200 EN 16931 business rules, German overlays, PDF/A-3 conformance with exact XMP metadata, and Schematron pipelines to prove compliance. The technical overview of ZUGFeRD automation dissects the layers if you want the details.
Which endpoint for which customer?
| Your customer | Format | Deliverable | Endpoint |
| German business (B2B) | ZUGFeRD EN 16931 | Hybrid PDF/A-3 | /v1/create/zugferd |
| Federal or state authority (B2G) | XRechnung 3.0 | Pure XML + Leitweg-ID | /v1/create/xrechnung |
| French business | Factur-X | Hybrid PDF/A-3 | /v1/create/facturx |
| Peppol network recipient | Peppol BIS 3.0 | UBL XML | /v1/create/ubl |
Every row takes the same invoice dict, so in Python the format decision is one conditional expression, not four integrations. This guide covers the two German rows; Factur-X and Peppol UBL have Python guides of their own.
The Python library landscape
Python's open-source position on the German formats is genuinely middle-of-the-pack: better than Ruby's empty shelf, well short of Java's Mustangproject. Two projects matter:
drafthorse gives you a Python object model for ZUGFeRD 2 CII XML: build the document element by element, serialize it, parse existing XML back. It is the closest thing Python has to a ZUGFeRD library, which makes its boundaries the part worth understanding.
factur-x (Akretion) handles the container mechanics: given a PDF and a finished CII XML, it produces the hybrid with the correctly named attachment and XMP metadata, and extracts the XML from existing hybrids. Widely used in the Odoo ecosystem.
Chain them and you can emit a ZUGFeRD file locally. What the chain does not give you:
Validation. The official EN 16931 and KoSIT rule sets compile to XSLT 2.0. lxml stops at XSLT 1.0, and while Saxon's Python bindings can technically execute the transforms, nobody ships a maintained package that wires the EN 16931 and XRechnung pipelines together and tracks KoSIT's yearly releases. Generating a file locally tells you nothing about whether a recipient or portal will accept it.
PDF/A-3 conformance of your actual PDFs. The factur-x library embeds into the PDF you give it; making that PDF genuinely PDF/A-3 conformant (fonts embedded, color spaces declared) is your problem, and defects surface as rejections on the receiving side.
XRechnung. Neither project targets it. The KoSIT validator is a Java application, so Python teams wanting local B2G validation end up managing a JVM subprocess.
Unstructured input. No library reads a plain supplier PDF into invoice data; that is document understanding, not XML assembly.
The rest of this guide runs the whole lifecycle over the API instead, and the comparison section weighs that maintenance load against a complete compliance service.
Client setup with requests
One import, two constants. Multipart uploads are the files= argument, JSON bodies are json=; there is no plumbing to write in Python.
Create a free InvoiceXML account to get an API key with 100 free credits, no credit card needed. Read the key from the environment, never from source:
import os
import requests
BASE_URL = "https://api.invoicexml.com"
HEADERS = {"Authorization": f"Bearer {os.environ['INVOICEXML_API_KEY']}"}
Create ZUGFeRD invoices in Python
The payload is a plain dict. You send net prices, quantities, and rates; the API computes totals and the per-rate VAT breakdown, generates the CII XML, wraps it in a conformant PDF/A-3, and validates against the full EN 16931 rule set before anything is returned. The sample mixes Germany's 19% standard and 7% reduced rates:
invoice = {
"invoice": {
"invoiceNumber": "RE-2026-2201",
"issueDate": "2026-07-28",
"currency": "EUR",
"seller": {
"name": "Isarwerk Software GmbH",
"vatIdentifier": "DE276543219",
"postalAddress": {
"line1": "Baaderstr. 41",
"city": "München",
"postCode": "80469",
"country": "DE",
},
},
"buyer": {
"name": "Hanse Logistik GmbH",
"postalAddress": {
"line1": "Schlachte 12",
"city": "Bremen",
"postCode": "28195",
"country": "DE",
},
},
"paymentDetails": {
"paymentAccountIdentifier": "DE91100000000123456789",
},
"lines": [
{
"quantity": 30,
"item": {"name": "Flottenmanagement-Software, Juli 2026"},
"priceDetails": {"netPrice": 45.00},
"vatInformation": {"rate": 19},
},
{
"quantity": 15,
"item": {"name": "Schulungsunterlagen (Druck)"},
"priceDetails": {"netPrice": 22.00},
"vatInformation": {"rate": 7},
},
],
}
}
response = requests.post(
f"{BASE_URL}/v1/create/zugferd",
headers=HEADERS,
json=invoice,
timeout=120,
)
if response.status_code != 200:
raise RuntimeError(response.text)
with open("rechnung-zugferd.pdf", "wb") as f:
f.write(response.content)
The result is a complete ZUGFeRD 2.3 document: PDF/A-3 container, embedded factur-x.xml, XMP metadata declaring the EN 16931 profile, already checked against the official Schematron. Data that cannot yield a compliant invoice (an unreconcilable VAT breakdown, a missing mandatory field) is refused with HTTP 400 and the violated rules listed as structured findings, so nothing invalid ever reaches a customer.
Runnable Python examples on GitHub →
Validate ZUGFeRD invoices in Python
Validate outgoing documents before dispatch and incoming supplier hybrids on arrival. The endpoint pulls the embedded XML from the PDF, reads the declared profile from BT-24, and runs that profile's official XSD plus Schematron rules. In requests, a file upload is just files=:
def validate_zugferd(path: str) -> dict:
with open(path, "rb") as f:
response = requests.post(
f"{BASE_URL}/v1/validate/zugferd",
headers=HEADERS,
files={"file": (os.path.basename(path), f, "application/pdf")},
timeout=120,
)
return response.json()
report = validate_zugferd("rechnung-zugferd.pdf")
if report["valid"]:
print(f"Gültiges ZUGFeRD, Profil {report['data']['profile']}")
else:
for finding in report["errors"]:
print(f"[{finding['rule']}] {finding['message']}")
print(f" Felder: {', '.join(finding.get('fields') or [])}")
A completed validation always answers HTTP 200; the verdict lives in valid, and non-2xx statuses are reserved for transport problems such as a bad key or an unreadable upload. Findings carry rule ids, plain-language messages, business term codes, and field paths, ready to surface in a UI or forward to a supplier unedited.
Create XRechnung invoices in Python
Same dict shape, different endpoint, plus the two things XRechnung enforces that base EN 16931 does not:
buyerReference carries the Leitweg-ID. It maps to BT-10, is checked by rule BR-DE-15, and is assigned by the public sector buyer during onboarding. It is routing infrastructure, never invoice data, so keep it in your buyer records and inject it into every request.
Seller contact and electronic addresses are mandatory. The full seller.contact group (name, phone, email) and electronic addresses for both parties are required.
xrechnung = {
"invoice": {
"invoiceNumber": "RE-2026-2202",
"issueDate": "2026-07-28",
"dueDate": "2026-08-27",
"currency": "EUR",
"buyerReference": "09162-00010-53",
"seller": {
"name": "Isarwerk Software GmbH",
"vatIdentifier": "DE276543219",
"postalAddress": {
"line1": "Baaderstr. 41",
"city": "München",
"postCode": "80469",
"country": "DE",
},
"contact": {
"name": "Tobias Lang",
"phone": "+49 89 4448120",
"email": "[email protected]",
},
"electronicAddress": {"identifier": "DE276543219", "schemeId": "0204"},
},
"buyer": {
"name": "Landeshauptstadt München, IT-Referat",
"postalAddress": {
"line1": "Marienplatz 8",
"city": "München",
"postCode": "80331",
"country": "DE",
},
"electronicAddress": {"identifier": "09162-00010-53", "schemeId": "0204"},
},
"paymentDetails": {
"paymentMeansCode": 58,
"paymentAccountIdentifier": "DE91100000000123456789",
},
"lines": [
{
"quantity": 160,
"unitCode": "HUR",
"item": {"name": "Weiterentwicklung Bürgerportal"},
"priceDetails": {"netPrice": 105.00},
"vatInformation": {"rate": 19, "categoryCode": "S"},
},
],
}
}
response = requests.post(
f"{BASE_URL}/v1/create/xrechnung",
headers=HEADERS,
json=xrechnung,
timeout=120,
)
if response.status_code != 200:
raise RuntimeError(response.text)
with open("rechnung-xrechnung.xml", "wb") as f:
f.write(response.content)
The response is XRechnung 3.0 in CII syntax, already validated against the EN 16931 Schematron and the KoSIT rules. XRechnung also allows a UBL binding, worth choosing if your stack serves Peppol as well: call POST /v1/create/ubl with "options": {"profile": "xrechnung"} and the same invoice dict. Both outputs are legally equivalent.
Validate XRechnung invoices in Python
POST /v1/validate/xrechnung auto-detects CII or UBL syntax and runs three layers: the XSD, the EN 16931 core Schematron, and the German BR-DE rules from the KoSIT Schematron, exactly what ZRE and OZG-RE enforce on submission. It accepts XML from any producer, including drafthorse output:
def validate_xrechnung(path: str) -> dict:
with open(path, "rb") as f:
response = requests.post(
f"{BASE_URL}/v1/validate/xrechnung",
headers=HEADERS,
files={"file": (os.path.basename(path), f, "application/xml")},
timeout=120,
)
return response.json()
report = validate_xrechnung("rechnung-xrechnung.xml")
if not report["valid"]:
for finding in report["errors"]:
# finding["layer"] is "xsd", "en16931", or "cius" (the BR-DE rules)
print(f"[{finding['rule']}] ({finding['layer']}) {finding['message']}")
Surface the layer tag to your users: a cius finding means the document is a valid EN 16931 invoice that specifically breaks a German requirement, and the textbook case is BR-DE-15 firing because the Leitweg-ID is missing. This endpoint takes XML only; a ZUGFeRD hybrid PDF declaring the XRechnung reference profile goes to /v1/validate/zugferd instead, and its embedded XML reaches the same KoSIT rules automatically.
Read incoming invoices: extraction plus AI
Since reception has been mandatory for everyone since 2025, your intake path matters as much as your output path, and it faces a mixed inbox: ZUGFeRD hybrids, XRechnung XML, and plain PDFs from unmodernised suppliers. One function can sort all three. Try deterministic extraction first; when the API answers error code 4006 (no embedded XML), hand the file to the AI parser:
def read_incoming_invoice(path: str) -> dict:
with open(path, "rb") as f:
response = requests.post(
f"{BASE_URL}/v1/extract/json",
headers=HEADERS,
files={"file": f},
timeout=120,
)
if response.ok:
return {"source": "embedded-xml", "invoice": response.json()}
error = response.json()
if error.get("errorCode") != 4006:
raise RuntimeError(response.text)
# No embedded XML: an old-school PDF, run the AI parser instead
with open(path, "rb") as f:
response = requests.post(
f"{BASE_URL}/v1/parse/json",
headers=HEADERS,
files={"file": f},
timeout=120,
)
if not response.ok:
raise RuntimeError(response.text)
return {"source": "ai", **response.json()}
The two branches differ in nature, and the response shapes reflect it. Extraction is deterministic XML parsing: what the supplier declared is exactly what you get. The AI branch reads the document visually (OCR included, scans and photos welcome) and therefore reports a confidence object alongside the invoice: an overall score plus four area scores (seller, buyer, tax calculation, line items) between 0.0 and 1.0. Gate your automation on it:
result = read_incoming_invoice("eingang/lieferant-scan.pdf")
if result["source"] == "ai" and result["confidence"]["overall"] < 0.7:
queue_for_review(result["invoice"], result["confidence"])
else:
import_into_erp(result["invoice"])
Non-invoices fail the AI branch with error code 4008 and multi-invoice files with 4009, so junk mail filters itself out. The third intake endpoint handles a German B2G habit: supporting documents (delivery notes, timesheets, order copies) travel embedded inside the invoice XML as base64 in the BG-24 group. POST /v1/extract/attachments decodes them into a ZIP, answering 404 when the invoice carries none:
def extract_attachments(path: str, zip_path: str) -> str | None:
with open(path, "rb") as f:
response = requests.post(
f"{BASE_URL}/v1/extract/attachments",
headers=HEADERS,
files={"file": f},
timeout=120,
)
if response.status_code == 404:
return None # invoice carries no embedded attachments
if not response.ok:
raise RuntimeError(response.text)
with open(zip_path, "wb") as out:
out.write(response.content)
return zip_path
extract_attachments("rechnung-xrechnung.xml", "belege.zip")
For the raw embedded CII instead of parsed JSON, /v1/extract/xml streams the XML out of a hybrid PDF untouched, ready for lxml or drafthorse to consume.
Render XRechnung previews
XRechnung's lack of a visual layer becomes a workflow problem the first time an accountant asks to see an invoice before it goes to a portal. POST /v1/render/xrechnung/to/pdf maps every business term onto a clean, readable PDF, with the Leitweg-ID labelled in the buyer reference section; both syntaxes are detected automatically:
def render_xrechnung(xml_path: str, pdf_path: str) -> None:
with open(xml_path, "rb") as f:
response = requests.post(
f"{BASE_URL}/v1/render/xrechnung/to/pdf",
headers=HEADERS,
files={"file": f},
timeout=120,
)
with open(pdf_path, "wb") as out:
out.write(response.content)
render_xrechnung("rechnung-xrechnung.xml", "rechnung-vorschau.pdf")
The preview has no legal standing; the XML remains the invoice, and only the XML goes to the portal.
Django and Celery integration
In a Django project, put the API behind one service class with a shared requests.Session, and let the B2B/B2G decision live in a single argument:
# settings.py
INVOICEXML_BASE_URL = "https://api.invoicexml.com"
INVOICEXML_API_KEY = os.environ["INVOICEXML_API_KEY"]
# invoices/services.py
import requests
from django.conf import settings
class InvoiceComplianceError(Exception):
pass
class GermanInvoiceService:
def __init__(self) -> None:
self.session = requests.Session()
self.session.headers["Authorization"] = (
f"Bearer {settings.INVOICEXML_API_KEY}"
)
def create(self, payload: dict, public_sector: bool) -> tuple[bytes, str]:
endpoint = "/v1/create/xrechnung" if public_sector else "/v1/create/zugferd"
response = self.session.post(
settings.INVOICEXML_BASE_URL + endpoint,
json={"invoice": payload},
timeout=120,
)
if response.status_code != 200:
raise InvoiceComplianceError(response.text)
return response.content, "xml" if public_sector else "pdf"
def validate(self, content: bytes, public_sector: bool) -> dict:
endpoint = "/v1/validate/xrechnung" if public_sector else "/v1/validate/zugferd"
filename = "invoice.xml" if public_sector else "invoice.pdf"
response = self.session.post(
settings.INVOICEXML_BASE_URL + endpoint,
files={"file": (filename, content)},
timeout=120,
)
return response.json()
Generation belongs in a Celery task, and the retry policy matters more than it looks: retry transport failures, never HTTP 400. A 400 carries findings about your data, and resending identical data reproduces identical findings. InvoiceComplianceError is deliberately absent from autoretry_for:
# invoices/tasks.py
import requests
from celery import shared_task
from django.core.files.base import ContentFile
from .models import Invoice
from .services import GermanInvoiceService
@shared_task(
autoretry_for=(requests.ConnectionError, requests.Timeout),
retry_backoff=True,
max_retries=3,
)
def issue_invoice(invoice_id: int) -> None:
invoice = Invoice.objects.get(pk=invoice_id)
service = GermanInvoiceService()
content, extension = service.create(
invoice.to_api_payload(),
public_sector=invoice.buyer.is_public_sector,
)
invoice.document.save(
f"{invoice.number}.{extension}", ContentFile(content)
)
For public sector buyers, treat a missing Leitweg-ID as a data error caught before the task enqueues, not something to discover at the portal. And pin the whole mapping in CI with two pytest cases, one per format:
def test_zugferd_payload_stays_compliant():
service = GermanInvoiceService()
content, _ = service.create(sample_b2b_payload(), public_sector=False)
report = service.validate(content, public_sector=False)
assert report["valid"], report["errors"]
def test_xrechnung_payload_passes_the_kosit_rules():
service = GermanInvoiceService()
content, _ = service.create(sample_b2g_payload(), public_sector=True)
report = service.validate(content, public_sector=True)
assert report["valid"], report["errors"]
Python library vs REST API
The fair framing for Python, given that real libraries exist:
The library route is an ongoing project, not a pip install. drafthorse and factur-x are free and maintained, and building on them still leaves you owning the surrounding machinery: PDF/A-3 conformance of your visual layer, validation with no in-process path (your mapping is trusted, not verified, until a recipient complains), XRechnung, previews, intake, and the KoSIT calendar. Every specification release means watching the announcement, waiting for the packages to catch up, pinning new versions, retesting, and redeploying, and KoSIT ships a new XRechnung version every year.
The API is a complete compliance service. Creation, validation, extraction, AI parsing, and rendering behind one integration, with the official EN 16931 and KoSIT rule sets live server-side before their effective dates. New format versions ship out of the box: nothing to monitor, no dependency to bump, no redeploy. Your requirements.txt carries only requests, conformant PDF/A-3 is not your conversion problem, the domain expertise sits on our side with professional support behind it, missing features get integrated on request, and the same integration reaches Factur-X and Peppol UBL when your market widens.
The deciding question is who owns the moving target. Compliance rules change on a government calendar, not yours. With libraries, that calendar drives your sprint planning forever; with the API, your integration is done the day it works, because staying current is our job.
Data handling is stateless by architecture: documents are processed in memory and purged when the response ships. Nothing is stored, nothing is logged, and no invoice data trains any model. VAT numbers, IBANs, and Leitweg-IDs exist server-side for the duration of one HTTP exchange, then they are gone.
Endpoint reference
| Operation | Endpoint | Input | Output |
| Create ZUGFeRD | POST /v1/create/zugferd | JSON | PDF/A-3 binary |
| Create XRechnung (CII) | POST /v1/create/xrechnung | JSON | XRechnung 3.0 XML |
| Create XRechnung (UBL) | POST /v1/create/ubl + options.profile: xrechnung | JSON | XRechnung UBL XML |
| Validate ZUGFeRD | POST /v1/validate/zugferd | ZUGFeRD PDF | Validation JSON |
| Validate XRechnung | POST /v1/validate/xrechnung | XRechnung XML (CII or UBL) | Validation JSON |
| Extract as JSON | POST /v1/extract/json | ZUGFeRD PDF or XRechnung XML | Structured JSON |
| Parse PDF with AI | POST /v1/parse/json | Any invoice PDF (typed, scanned, photo) | Structured JSON + confidence |
| Extract attachments | POST /v1/extract/attachments | ZUGFeRD PDF or XRechnung XML | ZIP archive |
| Render XRechnung as PDF | POST /v1/render/xrechnung/to/pdf | XRechnung XML | PDF binary |
| Extract CII XML | POST /v1/extract/xml | ZUGFeRD PDF | CII XML |
Full OpenAPI 3.1 schema: api.invoicexml.com/v1/openapi | Interactive API explorer: api.invoicexml.com/v1/scalar
Get started
Every snippet in this guide needs nothing beyond pip install requests: set INVOICEXML_API_KEY and run. The runnable versions live in the examples repository:
InvoiceXML/facturx-api-examples/python →
Create a free InvoiceXML account → get 100 credits for free, no credit card required.
Related resources:
InvoiceXML is a REST API for European e-invoice compliance covering ZUGFeRD, XRechnung, Factur-X, Peppol UBL, and CII. Stateless processing, GDPR compliant by architecture, and callable from any Python application: Django, FastAPI, Flask, Celery workers, or a plain script.