Automation MCP Server Features Blog Pricing Contact
Integration ZUGFeRD Format

Create and Validate ZUGFeRD and XRechnung Invoices in Python: Complete Guide

One Python integration for both German e-invoice formats: ZUGFeRD hybrid PDFs for B2B and XRechnung pure XML for the public sector. Creating from a dict, validating against EN 16931 and the KoSIT rules, reading incoming invoices with deterministic extraction plus AI parsing, and rendering previews, all with requests. Includes Django and Celery patterns and an honest look at drafthorse and the factur-x library.

Selling into Germany from a Python stack means producing two different e-invoice deliverables from the same data. The B2B mandate (reception compulsory since January 2025, issuance phasing in from 2027) runs on ZUGFeRD, a PDF/A-3 the customer can open with CII XML their software can parse. The public sector runs on XRechnung: no PDF at all, pure XML, submitted to portals that enforce the KoSIT rule set and reject anything missing the Leitweg-ID routing field. Django shops, FastAPI services, and plain scripts all hit the same question: how much of this machinery belongs in your codebase?

This guide answers it for both formats at once, deliberately, because they share the EN 16931 semantic model and the API accepts the same invoice dict for either. What changes between a B2B and a B2G invoice in the code below is the endpoint and two extra fields. Everything runs on requests, every snippet has a runnable counterpart in the Python examples repository, and a Django and Celery section covers the production shape. If your market is France, the identical hybrid format travels as Factur-X; the companion guide to Factur-X in Python covers that side with French sample data.

Python also deserves an honest tooling conversation. Unlike Ruby, it has real open-source pieces for the German formats (drafthorse for the XML, Akretion's factur-x for the PDF embedding), and this guide is clear about what they do well and where the compliance wall stands: validation, XRechnung, PDF/A conformance, and unstructured input, the parts the ZUGFeRD API and XRechnung API keep server-side.

The German mandate in two formats

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 customerFormatDeliverableEndpoint
German business (B2B)ZUGFeRD EN 16931Hybrid PDF/A-3/v1/create/zugferd
Federal or state authority (B2G)XRechnung 3.0Pure XML + Leitweg-ID/v1/create/xrechnung
French businessFactur-XHybrid PDF/A-3/v1/create/facturx
Peppol network recipientPeppol BIS 3.0UBL 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

OperationEndpointInputOutput
Create ZUGFeRDPOST /v1/create/zugferdJSONPDF/A-3 binary
Create XRechnung (CII)POST /v1/create/xrechnungJSONXRechnung 3.0 XML
Create XRechnung (UBL)POST /v1/create/ubl + options.profile: xrechnungJSONXRechnung UBL XML
Validate ZUGFeRDPOST /v1/validate/zugferdZUGFeRD PDFValidation JSON
Validate XRechnungPOST /v1/validate/xrechnungXRechnung XML (CII or UBL)Validation JSON
Extract as JSONPOST /v1/extract/jsonZUGFeRD PDF or XRechnung XMLStructured JSON
Parse PDF with AIPOST /v1/parse/jsonAny invoice PDF (typed, scanned, photo)Structured JSON + confidence
Extract attachmentsPOST /v1/extract/attachmentsZUGFeRD PDF or XRechnung XMLZIP archive
Render XRechnung as PDFPOST /v1/render/xrechnung/to/pdfXRechnung XMLPDF binary
Extract CII XMLPOST /v1/extract/xmlZUGFeRD PDFCII 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.

Start free today

Ready to automate your invoices?

Validate, convert and embed compliant e-invoices through one API. Start your 30-day free trial. No credit card required.

GDPR Compliant No credit card required Setup in minutes
Peppol UBL
Factur-X
EN 16931
142 / 142 passed
Compliant
PDF/A-3 embedded