Zurück zum Blog
Tutorial
6. August 2026
8 min Lesezeit

XRechnung & ZUGFeRD mit Python erstellen: Tutorial für EN 16931

Python SDK EN 16931 XRechnung ZUGFeRD API

Letzte Aktualisierung: August 2026 Voraussetzung: Python 3.9 oder neuer


Wer in Python eine KoSIT-konforme E-Rechnung nach EN 16931 erzeugen muss, landet schnell bei denselben drei Baustellen: XML-Bäume von Hand zusammensetzen (lxml, xml.etree), UN/ECE-Einheitencodes und BR-DE-Geschäftsregeln nachpflegen, und am Ende gegen die KoSIT-Schematron-Regeln validieren. Der Aufwand steckt weniger im ersten Wurf als in der Pflege: jedes Format-Update zieht Anpassungen nach sich.

Dieses Tutorial zeigt den anderen Weg — die Rechnungsdaten als Python-Dict an das offizielle SDK übergeben und XML und PDF fertig validiert zurückbekommen.


1. Installation

pip install factora

Das Paket liegt auf PyPI und braucht Python 3.9+. Einen Test-Key holst du dir in der Console; er beginnt mit fa_test_.

Wichtig für den Einstieg: Sandbox oder Produktivbetrieb entscheidet der Key, nicht ein Schalter im Code. Ein fa_test_-Key läuft durch dieselbe Pipeline wie ein fa_live_-Key — Validierung inklusive — speichert aber nichts und antwortet mit status: "sandbox".


2. Der erste Call

Eine Rechnung entsteht in genau einem POST auf /api/v1/invoices/atomic/. Das SDK kapselt den Endpoint in FactoraClient.create_atomic_invoice().

import base64
from pathlib import Path

from factora import FactoraAPIError, FactoraClient

client = FactoraClient(api_key="fa_test_DEIN_API_KEY")

invoice_payload = {
    "api_mode": "atomic_single_post",
    "invoice_header": {
        "invoice_number": "INV-2026-001",
        "invoice_date": "2026-08-06",
        "payment_due_date": "2026-08-20",
        "currency": "EUR",
        "buyer_reference": "04011000-12345-34",  # Leitweg-ID, BR-DE-15
        "profile": "xrechnung",                  # oder "en16931"
    },
    "validation_totals": {
        "net_amount": "1200.00",
        "vat_amount": "228.00",
        "gross_amount": "1428.00",
    },
    "seller_snapshot": {
        "name": "TechSolutions GmbH",
        "street": "Hauptstraße 42",
        "zip": "20095",
        "city": "Hamburg",
        "country": "DE",
        "vat_id": "DE123456789",
        "contact": {
            "name": "Max Mustermann",
            "phone": "+49 40 123456",
            "email": "rechnung@techsolutions.example",
        },
    },
    "buyer": {
        "name": "Behörde für Digitales",
        "street": "Rathausmarkt 1",
        "zip": "10117",
        "city": "Berlin",
        "country": "DE",
        "contact": {"email": "einkauf@amt.example"},
    },
    "items": [
        {
            "description": "Software-Entwicklung",
            "quantity": "10.000",
            "unit": "HUR",  # UN/ECE Rec. 20 — Stunde
            "unit_price_net": "120.00",
            "vat_rate": "19.00",
        }
    ],
}

result = client.create_atomic_invoice(invoice_payload)

Drei Dinge daran sind erfahrungsgemäß die häufigsten Stolpersteine:

  • api_mode ist Pflicht und hat genau einen gültigen Wert: atomic_single_post. Es ist ein Format-Diskriminator, kein Test-Schalter.
  • profile gehört in den invoice_header, nicht auf die oberste Ebene. Top-level gesendet wird es kommentarlos verworfen, und die Rechnung fällt still auf xrechnung zurück.
  • validation_totals ist Pflicht. Die Summen kommen aus deinem System und werden gegen die Positionen gegengerechnet — sie sind eine Prüfsumme, keine Bequemlichkeit.

Beträge schreibst du als String. Das Feld ist serverseitig ein Decimal; Strings vermeiden Float-Rundungsfehler auf dem Weg durch JSON.


3. Was dabei passiert

Der Call macht drei Schritte in einem Durchlauf:

  1. Mapping der Daten auf das EN-16931-Modell inklusive der Geschäftsregeln des gewählten Profils.
  2. Erzeugung von XML und PDF.
  3. KoSIT-Validierung gegen die offiziellen Schematron-Regeln — vor der Finalisierung, nicht danach.

Der dritte Punkt ist der wichtigste: Ist der Validator nicht erreichbar, gibt es einen klaren Fehler statt einer ungeprüften Rechnung. Das ist bewusst so, denn eine Rechnung, die niemand geprüft hat, ist beim Empfänger genau so viel wert wie gar keine.


4. XRechnung und ZUGFeRD — du schaltest nicht um

Hier weicht die API von der Erwartung ab, die viele mitbringen: es gibt keinen Umschalter zwischen XRechnung und ZUGFeRD. Jede erfolgreiche Antwort enthält beides.

data = result["data"]

Path("rechnung.xml").write_bytes(base64.b64decode(data["xml_base64"]))
Path("rechnung.pdf").write_bytes(base64.b64decode(data["pdf_base64"]))
  • xml_base64 — XRechnung 3.0.2
  • pdf_base64 — PDF/A-3B mit eingebettetem XML und vollständigen Factur-X-XMP-Metadaten

Ein Detail, das dich beim Auslesen sonst kostet: der Name der eingebetteten Datei hängt am Profil. Unter xrechnung heißt sie xrechnung.xml, unter en16931 heißt sie factur-x.xml — und im XRechnung-Profil ist der Name factur-x.xml ausdrücklich unzulässig. Wer den Anhang fest auf einen Namen sucht, findet ihn beim Profilwechsel nicht mehr. Lies stattdessen fx:DocumentFileName aus den XMP-Metadaten oder nimm einfach den ersten eingebetteten Anhang.

Du entscheidest also erst beim Versand, was der Empfänger bekommt: das reine XML für Behörden, die hybride PDF für Geschäftskunden, die zusätzlich etwas zum Ansehen wollen. Ein zweiter Call ist dafür nicht nötig.

Das Feld profile steuert etwas anderes — nämlich das semantische Regelwerk:

  • xrechnung — deutscher CIUS inklusive der BR-DE-Regeln (Default)
  • en16931 — europäisches Kernmodell ohne die deutschen Zusatzpflichten

Welches Profil tatsächlich gegriffen hat, steht immer in data["profile"]. Verlass dich darauf statt auf deine eigene Annahme — dann merkst du sofort, wenn ein Feld an der falschen Stelle stand.


5. Fehler lesen

Ein abgelehnter Request kommt als HTTP 400 mit strukturierten Fehlern zurück, nicht als halb angelegte Rechnung. Das SDK wirft dafür FactoraAPIError:

try:
    result = client.create_atomic_invoice(invoice_payload)
except FactoraAPIError as exc:
    print(f"HTTP {exc.status_code}: {exc}")
    for error in (exc.response_data or {}).get("errors", []):
        print(error["code"], error["severity"], error["message"])
else:
    data = result["data"]
    print(data["status"], data["profile"], data["total"])

Jeder Eintrag trägt code, severity und message; je nach Fehler zusätzlich field, rule, bt (der EN-16931-Geschäftsbegriff) und location.

Die Regelcodes sind der schnellste Einstieg in die Fehlersuche. Ein paar, die dir am Anfang häufig begegnen:

CodeBedeutung
BR-DE-6Telefonnummer des Verkäufer-Kontakts fehlt (BT-42)
BR-DE-7E-Mail-Adresse des Verkäufer-Kontakts fehlt (BT-43)
BR-DE-15Leitweg-ID fehlt (BT-10)
BR-DE-23-aZahlungsart Überweisung ohne Überweisungsdaten

Alle vier stammen aus dem deutschen CIUS und tauchen deshalb nur unter profile: "xrechnung" auf. Unter en16931 gelten sie nicht.


6. Wiederholte Aufrufe absichern

Bei Live-Keys ist ein Idempotency-Key Pflicht, und das aus gutem Grund: Läuft ein Request in einen Timeout, weißt du nicht, ob die Rechnung angelegt wurde. Mit demselben Key bekommst du das ursprüngliche Ergebnis zurück statt einer zweiten Rechnung.

import uuid

result = client.create_atomic_invoice(
    invoice_payload,
    idempotency_key=str(uuid.uuid4()),
)

Erzeuge den Key pro fachlichem Vorgang, nicht pro HTTP-Versuch — sonst hebelst du den Schutz beim Retry genau dann aus, wenn du ihn brauchst.


Ressourcen

Der Sandbox-Zugang ist kostenlos; abgerechnet wird erst der Live-Betrieb.

Häufig gestellte Fragen

Brauche ich einen kostenpflichtigen Zugang, um das Tutorial nachzuvollziehen?

Nein. Der Sandbox-Zugang ist kostenlos. Ein Key mit dem Präfix fa_test_ läuft durch dieselbe Pipeline inklusive KoSIT-Validierung, speichert aber nichts und antwortet mit dem Status "sandbox".

Wie schalte ich zwischen XRechnung und ZUGFeRD um?

Gar nicht — jede erfolgreiche Antwort enthält beides. Unter xml_base64 liegt die XRechnung 3.0.2, unter pdf_base64 ein PDF/A-3B mit eingebettetem XML und Factur-X-XMP-Metadaten. Der Name der eingebetteten Datei hängt am Profil: xrechnung.xml unter xrechnung, factur-x.xml unter en16931. Du entscheidest erst beim Versand, was der Empfänger bekommt.

Wozu dient das Feld profile dann?

Es wählt das semantische Regelwerk: xrechnung ist der deutsche CIUS inklusive der BR-DE-Regeln und der Default, en16931 das europäische Kernmodell ohne die deutschen Zusatzpflichten. Das Feld gehört in den invoice_header, nicht auf die oberste Ebene.

Warum muss ich validation_totals mitschicken, wenn die API die Summen selbst rechnen kann?

Weil es eine Prüfsumme ist. Die Summen aus deinem System werden gegen die Positionen gegengerechnet; weichen sie ab, bekommst du einen Fehler statt einer Rechnung, die rechnerisch etwas anderes sagt als deine Buchhaltung.

Wann brauche ich einen Idempotency-Key?

Bei Live-Keys ist er Pflicht. Er sichert den Fall ab, dass ein Request in einen Timeout läuft und du nicht weißt, ob die Rechnung angelegt wurde: Mit demselben Key kommt das ursprüngliche Ergebnis zurück statt einer zweiten Rechnung. Erzeuge ihn pro fachlichem Vorgang, nicht pro HTTP-Versuch.

Welche Python-Version brauche ich?

Python 3.9 oder neuer. Das SDK wird über pip install factora aus PyPI installiert.