This guide builds a bridge for incoming e-invoices into DATEV. It is for developers who connect a company's mailbox or ERP to DATEV: in-house IT, software vendors and the IT teams of tax firms. One Python 3 script with requests checks every ZUGFeRD and XRechnung file with one REST API. Invalid files go back to the sender with a PDF report (step 1). Valid files become JSON and then a DATEV-Format booking batch (step 3). The original file and the batch then go to DATEV, as files for the import or through the DATEV online APIs (step 4). For the plain API calls in Python, read the Python guide. For the reading endpoints in depth, read the invoice parser guide. This guide does not repeat them.
Why incoming e-invoices need a pipeline now
Receiving is mandatory, and the volume grows. Since January 2025, every German business must be able to receive e-invoices. From January 2027, businesses with more than EUR 800,000 turnover in the previous year must issue them. From January 2028, all businesses must. Each month, more suppliers switch from paper and plain PDFs to ZUGFeRD and XRechnung. The Germany mandate page lists the dates and formats.
An e-invoice is data, so treat it as data. A ZUGFeRD file is a PDF with the invoice as XML inside. An XRechnung file is XML only, with no page to look at. Typing these values into a booking screen again wastes the data the supplier already sent. A pipeline checks the file, reads the data and hands DATEV two things: the original file and a booking record.
Check first, then book. A file that breaks the EN 16931 rules or the German rules should go back to the supplier, with the reasons. The pipeline does this on the day the file arrives. Only checked files reach the tax advisor.
The pipeline does not change when the formats change. ZUGFeRD, XRechnung and the EN 16931 rule sets get new versions. The API applies each new version on its effective date. Your script sends the same files and reads the same JSON before and after.
What you need
- Python 3 with
requests. Everything else (smtplib,email,decimal,calendar) is in the standard library. - A source of received files: an IMAP mailbox such as
[email protected], a scan folder or an ERP export. The script runs on any server that reaches this source, your SMTP server andapi.invoicexml.com. - The DATEV numbers of the client, from the tax advisor: Beraternummer, Mandantennummer, Sachkontenlänge, Sachkontenrahmen (SKR03 or SKR04), the creditor account of each supplier, a default expense account and the tax keys.
- The column header line of the Buchungsstapel format. The DATEV Developer Portal has a sample file under File Interfaces, DATEV Format, Getting started. Line 2 of that sample holds all 125 column names. Save it as
buchungsstapel-columns.txt. - For the online route only: an app in the DATEV Developer Portal. Step 4 explains the registration.
- An InvoiceXML account. Create one. You get an API key and 100 free credits. No credit card is needed.
How the pipeline works
Every received file runs through the same four steps. The API does the format work. Your script only moves files and maps JSON to DATEV fields.
- Validate. The file goes to the validation report endpoint. Invalid e-invoices go back to the sender with the PDF report.
- Extract. A valid file goes to the extraction endpoint. The invoice comes back as JSON.
- Map. The script turns the JSON into one DATEV-Format booking line per VAT rate.
- Deliver. The original file and the booking batch go to DATEV.
| Purpose | Endpoint | Used for |
|---|---|---|
| Validate with PDF report | /v1/validate/zugferd/report, /v1/validate/xrechnung/report | The verdict in X-Invoice-Valid, the report for the reply |
| Validate as JSON | /v1/validate/zugferd | Telling a plain PDF from a broken e-invoice |
| Extract structured data | /v1/extract/json | The invoice data of a valid e-invoice |
| Parse plain PDF | /v1/parse/json | PDFs and scans without XML, with confidence scores |
| Extract attachments | /v1/extract/attachments | Delivery notes or timesheets embedded in the invoice, as a ZIP |
| Render XRechnung | /v1/render/xrechnung/to/pdf | A readable PDF of an XRechnung file |
The script is one module, datev_bridge.py. Part 1 holds the settings, the API helpers and the main function. The other parts follow in the steps below.
# datev_bridge.py (part 1: settings, API helpers, main function)
import json
import os
import uuid
from decimal import Decimal
import requests
API = "https://api.invoicexml.com"
API_HEADERS = {"Authorization": "Bearer " + os.environ["INVOICEXML_API_KEY"]}
# Mail settings for replies to suppliers
INBOX_ADDRESS = "[email protected]"
SMTP_HOST = "smtp.firma.de"
SMTP_USER = os.environ["SMTP_USER"]
SMTP_PASSWORD = os.environ["SMTP_PASSWORD"]
# DATEV settings for one client, agreed with the tax advisor
CONSULTANT_NUMBER = 29098 # Beraternummer
CLIENT_NUMBER = 55003 # Mandantennummer
ACCOUNT_LENGTH = 4 # Sachkontenlänge
CHART = "03" # Sachkontenrahmen: 03 = SKR03, 04 = SKR04
FISCAL_YEAR_START = "0101" # WJ-Beginn as MMDD (here: the calendar year)
TAX_KEYS = {Decimal("19"): "9", Decimal("7"): "8"} # BU-Schlüssel per VAT rate
# Supplier VAT id -> creditor account and default expense account
with open("creditors.json", encoding="utf-8") as f:
CREDITORS = json.load(f) # {"DE811230999": {"creditor": 70001, "expense": 4930}}
def post_file(endpoint, path, form=None):
"""Upload one file as multipart/form-data with the field name "file"."""
with open(path, "rb") as f:
return requests.post(API + endpoint, headers=API_HEADERS, data=form,
files={"file": (os.path.basename(path), f)}, timeout=120)
def extract(path):
response = post_file("/v1/extract/json", path)
response.raise_for_status()
return response.json() # {"invoice": {..., "totals": {...}}}
def process_file(path, sender):
"""Check one received file. Then batch it, return it or park it for review."""
is_xml = path.lower().endswith(".xml")
valid, report = validate(path, is_xml) # step 1
if valid:
data, review = extract(path), False # step 2
elif not is_xml and is_plain_pdf(path):
data, review = parse_plain_pdf(path) # a PDF without XML
else:
reply_with_report(path, sender, report) # step 1: send it back
return "returned to sender"
if is_xml:
render_readable_copy(path) # XRechnung only
guid = str(uuid.uuid4()) # one GUID per document
rows = booking_rows(data, guid) if data else None # step 3
if rows is None or review:
move_to_review(path, data)
return "review"
add_to_batch(rows, data, path, guid) # step 4
return "batched"
if __name__ == "__main__":
for path, sender in incoming_files(): # your mailbox or folder reader
print(path, process_file(path, sender))
for (year, month), rows in BATCHES.items():
print("written:", write_extf(year, month, rows))
incoming_files() is yours. It yields the path of each received PDF or XML file and the sender address. It can read an IMAP mailbox with imaplib, a scan folder or an ERP export. Keep the received files where they are: the pipeline copies them and never changes them.
Step 1: Validate and reply with the report
PDF files go to POST /v1/validate/zugferd/report. XML files go to POST /v1/validate/xrechnung/report. The API runs the EN 16931 rules and the German rules. The response body is a PDF validation report. The header X-Invoice-Valid is true or false. The form field validationLanguage with the value de gives a German report, which German suppliers read most easily.
# datev_bridge.py (part 2: validate and reply with the report)
import smtplib
from email.message import EmailMessage
def validate(path, is_xml):
endpoint = "/v1/validate/xrechnung/report" if is_xml else "/v1/validate/zugferd/report"
response = post_file(endpoint, path, form={"validationLanguage": "de"})
response.raise_for_status()
valid = response.headers.get("X-Invoice-Valid") == "true"
return valid, response.content # the body is the PDF report
def reply_with_report(path, sender, report):
name = os.path.basename(path)
msg = EmailMessage()
msg["From"] = INBOX_ADDRESS
msg["To"] = sender
msg["Subject"] = f"Ihre Rechnung {name}: Prüfbericht"
msg.set_content(
"Guten Tag,\n\n"
"Ihre E-Rechnung hat die Prüfung nicht bestanden.\n"
"Der Prüfbericht im Anhang nennt jeden Fehler mit dem betroffenen Feld.\n"
"Bitte senden Sie uns eine korrigierte Rechnung.\n\n"
"Freundliche Grüße\nBuchhaltung")
msg.add_attachment(report, maintype="application", subtype="pdf",
filename=f"Pruefbericht-{name}.pdf")
with smtplib.SMTP(SMTP_HOST, 587) as smtp:
smtp.starttls()
smtp.login(SMTP_USER, SMTP_PASSWORD)
smtp.send_message(msg)
The report names every violated rule, the field and a message. The supplier can fix the invoice without asking you what is wrong. Nothing from an invalid file reaches DATEV. If your code needs the findings as data instead, for a dashboard or a ticket, call /v1/validate/zugferd or /v1/validate/xrechnung without /report. These return { "valid": ..., "errors": [ { "rule", "message", ... } ], "warnings": [] }.
Step 2: Extract the data as JSON
A valid file goes to POST /v1/extract/json, the extract() helper from part 1. The API reads the XML inside the ZUGFeRD PDF, or the XRechnung XML itself. No AI is involved: the values are exactly the values the supplier sent. The JSON has the same shape as the request body of the create endpoints, plus a totals object:
{
"invoice": {
"invoiceNumber": "R-2026-0815",
"issueDate": "2026-11-03",
"dueDate": "2026-12-03",
"currency": "EUR",
"seller": {
"name": "Papier Krause GmbH",
"vatIdentifier": "DE811230999",
"postalAddress": { "line1": "Hafenstraße 12", "city": "Hamburg", "postCode": "20457", "country": "DE" }
},
"buyer": { "name": "Muster Maschinenbau GmbH", "vatIdentifier": "DE123456789" },
"paymentDetails": { "paymentAccountIdentifier": "DE02120300000000202051" },
"lines": [
{ "quantity": 20, "unitCode": "H87", "item": { "name": "Kopierpapier A4 500 Blatt" },
"priceDetails": { "netPrice": 4.50 }, "vatInformation": { "rate": 19 } },
{ "quantity": 2, "unitCode": "H87", "item": { "name": "Fachbuch Buchhaltung" },
"priceDetails": { "netPrice": 39.90 }, "vatInformation": { "rate": 7 } }
],
"totals": { "grandTotalAmount": 192.49, "duePayableAmount": 192.49 }
}
}
Three fields drive the booking: seller.vatIdentifier finds the creditor, lines[].vatInformation.rate splits the amounts by VAT rate, and totals.grandTotalAmount is the control total. The IBAN in paymentDetails is useful for your payment run. Some suppliers embed supporting files in the invoice, such as delivery notes or timesheets. POST /v1/extract/attachments returns them as one ZIP, so you can file them next to the invoice.
Step 3: Map the JSON to a DATEV booking record
DATEV imports bookings as a DATEV-Format file: a CSV file with the name prefix EXTF_. Line 1 is the header with the metadata of the batch. Line 2 holds the column names. Every line after that is one booking. A Buchungsstapel (posting batch) is format category 21. An incoming invoice with two VAT rates becomes two booking lines. These are the fields the script fills:
| # | DATEV-Format field | Value | Note |
|---|---|---|---|
| 1 | Umsatz (ohne Soll/Haben-Kz) | Net amount of the rate plus VAT | The gross amount. Positive, comma as decimal separator: 107,10 |
| 2 | Soll/Haben-Kennzeichen | "S" | Refers to field 7, so the expense account is debited |
| 3 | WKZ Umsatz | invoice.currency | ISO code, here always EUR |
| 7 | Konto | Expense account from creditors.json | The default account of the supplier |
| 8 | Gegenkonto (ohne BU-Schlüssel) | Creditor account, found by seller.vatIdentifier | The person account of the supplier |
| 9 | BU-Schlüssel | lines[].vatInformation.rate through TAX_KEYS | The tax key, for example 9 for 19% input VAT |
| 10 | Belegdatum | invoice.issueDate | Format DDMM. The year comes from the header |
| 11 | Belegfeld 1 | invoice.invoiceNumber | Up to 36 characters: letters, digits and $ & % * + - / |
| 14 | Buchungstext | invoice.seller.name | Up to 60 characters |
| 20 | Beleglink | BEDI "<GUID>" | Links the booking to the document in DATEV Unternehmen online |
The script checks its own sums. It adds up the line amounts per VAT rate and rounds each line to cents. Then it adds the VAT and compares the sum with grandTotalAmount. If they differ, the invoice goes to review instead of into the batch. An unknown supplier or a foreign currency goes to review too.
# datev_bridge.py (part 3: one booking line per VAT rate)
import re
from collections import defaultdict
from decimal import ROUND_HALF_UP
CENT = Decimal("0.01")
def money(value):
return Decimal(str(value)).quantize(CENT, rounding=ROUND_HALF_UP)
def booking_rows(data, guid):
inv = data["invoice"]
totals = inv.get("totals") or {}
supplier = CREDITORS.get(inv["seller"].get("vatIdentifier") or "")
if supplier is None or inv.get("currency") != "EUR":
return None # unknown supplier or currency
net = defaultdict(Decimal) # net amount per VAT rate
for line in inv["lines"]:
rate = Decimal(str(line["vatInformation"]["rate"]))
net[rate] += money(Decimal(str(line["quantity"])) * Decimal(str(line["priceDetails"]["netPrice"])))
rows, gross_sum = [], Decimal("0")
for rate, amount in sorted(net.items(), reverse=True):
gross = amount + money(amount * rate / 100)
gross_sum += gross
rows.append({
"Umsatz (ohne Soll/Haben-Kz)": gross,
"Soll/Haben-Kennzeichen": "S",
"WKZ Umsatz": "EUR",
"Konto": supplier["expense"],
"Gegenkonto (ohne BU-Schlüssel)": supplier["creditor"],
"BU-Schlüssel": TAX_KEYS.get(rate, ""),
"Belegdatum": inv["issueDate"][8:10] + inv["issueDate"][5:7], # DDMM
"Belegfeld 1": re.sub(r"[^A-Za-z0-9$&%*+\-/_]", "", inv["invoiceNumber"])[:36],
"Buchungstext": inv["seller"]["name"][:60],
"Beleglink": f'BEDI "{guid}"',
})
if abs(gross_sum - money(totals.get("grandTotalAmount", 0))) > CENT:
return None # does not match the invoice total
return rows
def move_to_review(path, data):
os.makedirs("review", exist_ok=True)
target = os.path.join("review", os.path.basename(path))
os.replace(path, target) # moved, never changed
with open(target + ".json", "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
For the invoice above, the batch looks like this. The header is line 1. The column line and the two booking lines are cut after field 20. The remaining 105 fields of each booking line stay empty.
"EXTF";700;21;"Buchungsstapel";13;20261106101500123;;"RE";"";"";29098;55003;20260101;4;20261101;20261130;"Eingangsrechnungen 11/2026";"";1;0;0;"EUR";;"";;;"03";;;"";""
Umsatz (ohne Soll/Haben-Kz);Soll/Haben-Kennzeichen;WKZ Umsatz;Kurs;Basis-Umsatz;WKZ Basis-Umsatz;Konto;Gegenkonto (ohne BU-Schlüssel);BU-Schlüssel;Belegdatum;Belegfeld 1;Belegfeld 2;Skonto;Buchungstext;Postensperre;Diverse Adressnummer;Geschäftspartnerbank;Sachverhalt;Zinssperre;Beleglink;...
107,10;"S";"EUR";;;;4930;70001;"9";0311;"R-2026-0815";;;"Papier Krause GmbH";;;;;;"BEDI ""8f3c2a1e-5b7d-4e2a-9c61-0d4b7f2e9a10""";...
85,39;"S";"EUR";;;;4930;70001;"8";0311;"R-2026-0815";;;"Papier Krause GmbH";;;;;;"BEDI ""8f3c2a1e-5b7d-4e2a-9c61-0d4b7f2e9a10""";...
The header starts with EXTF, version 700, category 21, the format name and format version 13. Then follow the creation time, the origin, the Beraternummer and the Mandantennummer. After them come WJ-Beginn, Sachkontenlänge, the period, a label, booking type 1 (financial accounting), Festschreibung 0 and the currency. The DATEV Developer Portal has the full field list, with a regular expression per field. Look under File Interfaces, DATEV Format, Format description (Header and Booking batch). Agree on the accounts and the tax keys with the tax advisor. For an account with automatic VAT (Steuerautomatik), the BU-Schlüssel stays empty.
Step 4: Deliver the file and the record to DATEV
DATEV needs two things for every invoice: the booking record and the original file as the document image (Belegbild). DATEV documents two ways to deliver them. Option A writes files for the import. Option B uses the DATEV online APIs. Both start from the same batch.
Option A: Write the EXTF file for the import
The script collects the booking lines per month, because DATEV recommends one file per posting period. It copies each original file, unchanged, into a folder named after its GUID. At the end of the run it writes one EXTF_ file per month. Fields are separated by semicolons, text fields are in quotes, and lines end with CR/LF. The file is UTF-8 with a byte order mark. The DATEV-Format documentation allows this encoding for the manual import and for the API.
# datev_bridge.py (part 4: collect the batch and write the EXTF file)
import calendar
import shutil
from datetime import datetime
BATCHES = defaultdict(list) # (year, month) -> booking lines
with open("buchungsstapel-columns.txt", encoding="utf-8") as f:
COLUMNS = f.read().strip().split(";") # line 2 of DATEV's sample file
UNQUOTED = {"Umsatz (ohne Soll/Haben-Kz)", "Konto", "Gegenkonto (ohne BU-Schlüssel)", "Belegdatum"}
def add_to_batch(rows, data, path, guid):
issue = data["invoice"]["issueDate"]
BATCHES[(int(issue[:4]), int(issue[5:7]))].extend(rows)
folder = os.path.join("belege", guid)
os.makedirs(folder, exist_ok=True)
shutil.copy2(path, folder) # the original file, byte for byte
def field(name, value):
if value is None or value == "":
return ""
if isinstance(value, Decimal):
return f"{value:.2f}".replace(".", ",") # 107,10
if name in UNQUOTED:
return str(value)
return '"' + str(value).replace('"', '""') + '"' # text in quotes, quotes doubled
def write_extf(year, month, rows):
last = calendar.monthrange(year, month)[1]
header = ['"EXTF"', "700", "21", '"Buchungsstapel"', "13",
datetime.now().strftime("%Y%m%d%H%M%S%f")[:17], "", '"RE"', '""', '""',
str(CONSULTANT_NUMBER), str(CLIENT_NUMBER), f"{year}{FISCAL_YEAR_START}",
str(ACCOUNT_LENGTH), f"{year}{month:02d}01", f"{year}{month:02d}{last}",
f'"Eingangsrechnungen {month:02d}/{year}"', '""', "1", "0", "0", '"EUR"',
"", '""', "", "", f'"{CHART}"', "", "", '""', '""']
lines = [";".join(header), ";".join(COLUMNS)]
lines += [";".join(field(n, row.get(n)) for n in COLUMNS) for row in rows]
name = f"EXTF_Eingangsrechnungen_{year}{month:02d}.csv"
with open(name, "w", encoding="utf-8-sig", newline="") as f:
f.write("\r\n".join(lines) + "\r\n")
return name
The tax firm imports the file in DATEV Rechnungswesen through the batch processing (Stapelverarbeitung). The belege folder goes to the tax firm with it, or into the document filing the firm uses. Festschreibung 0 in the header leaves the bookings open, so the tax advisor can still correct them after the import.
Option B: Upload through the DATEV online APIs
DATEV describes this route as the use case Buchungsdatenservice: bookings plus their document images, from a partner solution to DATEV Rechnungswesen. It needs three API products: accounting:clients, accounting:documents and accounting:extf-files. accounting:documents puts the original file into Belege online in DATEV Unternehmen online. accounting:extf-files puts the EXTF file into the DATEV data center. The tax firm takes the batch from there into DATEV Rechnungswesen.
Before the first call. Sign in to the DATEV Developer Portal with a DATEV account. Join or create an organization, create an app and receive a Client ID and a Client secret. Subscribe the app to the three products. You develop against the sandbox. To use an app in production, it must pass a technical review by DATEV. You book the review appointment in the portal. An organization is linked to a DATEV business partner: you enter your consultant number, or the portal creates a business partner for you. A listing on the DATEV-Marktplatz is a separate partner program for software vendors.
Authentication. DATEV uses the OpenID Connect Authorization Code Flow with PKCE (code_challenge_method S256). The user signs in with a DATEV login. An access token is valid for 15 minutes. Refresh tokens are single-use. With the scope offline_access plus datev:iam:client:<Beraternummer>-<Mandantennummer>, the refresh token is valid for two years for that one client. That fits an unattended pipeline. The scopes for the uploads are accounting:documents and datev:accounting:extf-files-import. Every request also sends the Client ID of your app in the header X-DATEV-Client-Id.
# datev_bridge.py (part 5: upload to the DATEV online APIs)
import time
DATEV_DOCUMENTS = "https://accounting-documents.api.datev.de/platform/v2"
DATEV_EXTF = "https://accounting-extf-files.api.datev.de/platform/v3"
# Sandbox: .../platform-sandbox/v2 and .../platform-sandbox/v3
DATEV_CLIENT = f"{CONSULTANT_NUMBER}-{CLIENT_NUMBER}" # the client-id in the path
DATEV_HEADERS = {
"Authorization": "Bearer " + os.environ["DATEV_ACCESS_TOKEN"], # from your OAuth flow
"X-DATEV-Client-Id": os.environ["DATEV_APP_CLIENT_ID"], # Client ID of your app
}
def upload_document(path, guid):
"""PUT the original file into Belege online under our own GUID."""
metadata = {"document_type": "Rechnungseingang", "note": "E-Rechnung, geprüft"}
with open(path, "rb") as f:
response = requests.put(
f"{DATEV_DOCUMENTS}/clients/{DATEV_CLIENT}/documents/{guid}",
headers=DATEV_HEADERS, timeout=120,
files={"file": (os.path.basename(path), f),
"metadata": (None, json.dumps(metadata), "application/json")})
if response.status_code == 409:
return # this GUID is already in Belege online
response.raise_for_status() # 201: the document was imported
def upload_extf(csv_path):
"""POST the EXTF file, then poll the job until DATEV has processed it."""
with open(csv_path, "rb") as f:
response = requests.post(
f"{DATEV_EXTF}/clients/{DATEV_CLIENT}/extf-files/import",
headers={**DATEV_HEADERS, "Filename": os.path.basename(csv_path),
"Content-Type": "application/octet-stream"},
data=f.read(), timeout=120)
response.raise_for_status() # 202: received, now processing
job_url = DATEV_EXTF + response.headers["Location"]
while True:
time.sleep(int(response.headers.get("Retry-After", 5)))
response = requests.get(job_url, headers=DATEV_HEADERS, timeout=60)
job = response.json()
if job["result"] != "pending":
return job # "succeeded" or "failed"
Upload the documents first, then the EXTF file. Then every Beleglink points to a document that is already in Belege online. The PUT with your own GUID makes a retry safe: a second upload with the same GUID returns 409. The document type must exist for the client. GET /clients/{client-id}/document-types lists them, and GET /clients/{client-id}/duo-version lists the permitted file extensions. A failed job carries validation_details with the DATEV error codes, for example for a wrong header field. The OpenAPI files of both products are public in the DATEV Developer Portal.
DATEV documents two more routes. The use case DATEV Rechnungsdatenservice (product accounting:dxso-jobs) sends document images plus structured invoice data in the format DATEV XML-Schnittstelle online. Tax firms that run DATEV in their own network can also use the DATEVconnect desktop APIs. These work only inside that local network. The batch and the original files from this guide stay the same on every route.
Plain PDFs and scans
Not every supplier sends e-invoices yet. A PDF without invoice XML inside fails validation. But it is not a broken e-invoice, so the script does not send a report back. It asks the JSON endpoint /v1/validate/zugferd once. If the only finding has the rule PDF-EMBED, the PDF has no XML inside. The script then sends it to POST /v1/parse/json. The AI reads typed, scanned and photographed invoices and returns the same JSON, plus a confidence object. Its scores run from 0.0 to 1.0.
# datev_bridge.py (part 6: plain PDFs and scans)
def is_plain_pdf(path):
"""True when the only finding is PDF-EMBED: the PDF carries no invoice XML."""
response = post_file("/v1/validate/zugferd", path)
response.raise_for_status()
rules = {error.get("rule") for error in response.json().get("errors", [])}
return rules == {"PDF-EMBED"}
def parse_plain_pdf(path):
response = post_file("/v1/parse/json", path)
if response.status_code == 400 and response.json().get("errorCode") == 4008:
return None, True # not an invoice: goes to review
response.raise_for_status()
data = response.json()
confidence = data["confidence"]
tax = confidence["areas"].get("taxCalculation")
review = confidence["overall"] < 0.9 or tax is None or tax < 0.9
return data, review
A high score sends the invoice into the batch like an e-invoice. A low score parks it in the review folder with the parsed JSON, so a person checks the data before booking. The score taxCalculation matters most here, because the amounts become the booking. A document that is not an invoice comes back with error code 4008, for example a contract or a letter. It goes to review as well.
XRechnung files
An XRechnung file is XML only. The pipeline handles it like a ZUGFeRD file: validate with /v1/validate/xrechnung/report, reply with the report or extract with /v1/extract/json. The JSON has the same shape, so the mapping from step 3 does not change.
People cannot read XML. The clerk who checks the review folder and the tax advisor who looks at the document both need a page. POST /v1/render/xrechnung/to/pdf turns the XML into a readable PDF:
# datev_bridge.py (part 7: a readable copy of an XRechnung file)
def render_readable_copy(xml_path):
response = post_file("/v1/render/xrechnung/to/pdf", xml_path)
response.raise_for_status()
pdf_path = os.path.splitext(xml_path)[0] + "-lesekopie.pdf"
with open(pdf_path, "wb") as f:
f.write(response.content)
return pdf_path
Use the PDF for the visual check and keep it in the archive next to the XML. The XML stays the invoice. It is the file you keep unchanged and the file you upload as the document. The XRechnung API toolkit explains the format, the Leitweg-ID and the KoSIT rules.
Outgoing invoices
The same company also sends invoices. From 2027 or 2028, depending on turnover, these must be e-invoices too. POST /v1/create/zugferd/email creates a ZUGFeRD invoice from JSON, validates it and emails it to the customer from your own domain. In Germany this is a complete delivery: there is no central platform for B2B e-invoices, and an email with a ZUGFeRD PDF meets the mandate. Verify your sending domain once in the dashboard: add the domain and publish the three CNAME records it shows.
invoice = build_invoice(order) # your data in the shape from step 2, without totals
response = requests.post(API + "/v1/create/zugferd/email", headers=API_HEADERS, timeout=120, json={
"invoice": invoice,
"email": {
"from": "[email protected]", # an address on your verified sending domain
"to": "[email protected]",
"subject": "Rechnung " + invoice["invoiceNumber"],
"message": "Guten Tag,\n\nim Anhang finden Sie unsere Rechnung als ZUGFeRD-PDF.\n\nFreundliche Grüße",
},
})
response.raise_for_status() # 200: created, validated and handed to delivery
You do not send totals. The API calculates the totals and the VAT. When something does not add up, it returns an error with the rule and the field. Use the /email route when the API should send. Use /v1/create/zugferd when your code needs the PDF itself. For example, upload it to Belege online with a document type for outgoing invoices.
Working with tax advisors
Who does what. The company receives the invoices and runs the pipeline. The tax firm books in DATEV Rechnungswesen. With option A, the firm imports the EXTF file itself. With option B, the documents arrive in Belege online and the batch arrives in the DATEV data center. The firm takes both over. DATEV calls these two cases "Mandant sendet Daten" (the client sends) and "Kanzlei sendet Daten" (the firm sends). The login used for the upload belongs to the side that sends.
What must exist in DATEV. For document images, DATEV lists these conditions in Help-Center document 1000731. The client has DATEV Unternehmen online, set up through the MyDATEV Mandantenregistrierung. There is a Belege online inventory (Bearbeitungsform Standard or Erweitert). The sending side has a login method with at least standard rights for Belege online. The consultant number and the client number are stored in the sending software, which here is the script.
What to agree on before the first batch.
- The chart of accounts (SKR03 or SKR04) and the account length.
- The creditor account of each supplier and the default expense account, for
creditors.json. - The tax keys per VAT rate, or which accounts carry automatic VAT.
- The document type for incoming invoices, for example
Rechnungseingang. - Festschreibung: 0 leaves the bookings open for the tax advisor's check.
Belegbild plus booking record. The booking line says what was booked. The document says why. The Beleglink field ties them together: BEDI stands for DATEV Unternehmen online, followed by the GUID of the document. One invoice with two VAT rates has two booking lines and one document, so both lines carry the same GUID.
GoBD: keep the original file unchanged. The received ZUGFeRD PDF or XRechnung XML is the invoice. Store it byte for byte as it arrived. Do not open and save it again, and do not convert it. The pipeline in this guide only copies and moves the file. The readable PDF of an XRechnung file and the parsed JSON are working copies. Keep the validation report too. It documents that the file was checked and what the result was.
Endpoint reference
| Purpose | Endpoint | Input | Output |
|---|---|---|---|
| Create ZUGFeRD | POST /v1/create/zugferd | Invoice JSON | Hybrid PDF/A-3 |
| Create and email ZUGFeRD | POST /v1/create/zugferd/email | Invoice JSON + email object | Email sent from your domain |
| Create XRechnung | POST /v1/create/xrechnung | Invoice JSON with Leitweg-ID | XRechnung XML |
| Create and email XRechnung | POST /v1/create/xrechnung/email | Invoice JSON + email object | Email sent from your domain |
| Create Factur-X | POST /v1/create/facturx | Invoice JSON | Hybrid PDF/A-3 |
| Validate with PDF report | POST /v1/validate/zugferd/report, /xrechnung/report | PDF or XML file | PDF report, X-Invoice-Valid header |
| Validate as JSON | POST /v1/validate/zugferd, /xrechnung | PDF or XML file | Findings per rule |
| Extract structured data | POST /v1/extract/json | Hybrid PDF or XML file | Invoice JSON |
| Parse plain PDF | POST /v1/parse/json | Any PDF or image | Invoice JSON + confidence |
| Extract attachments | POST /v1/extract/attachments | Hybrid PDF or XML file | ZIP of embedded files |
| Render XRechnung | POST /v1/render/xrechnung/to/pdf | XRechnung XML | Readable PDF |
Get started
The whole bridge is one Python module: validate, reply or extract, map to EXTF, deliver to DATEV. The API holds the format rules. The tax advisor holds the accounts. Your script connects the two and never touches the content of the original file. This guide is part of the integrations hub, which also covers SAP, Business Central, Odoo and other platforms.
Related guides:
- Reading PDF invoices in code: extraction, AI parsing, confidence scores and error codes in depth.
- ZUGFeRD and XRechnung in Python: every API call with requests, plus Django and Celery.
- XRechnung API toolkit: the Leitweg-ID, the KoSIT rules and the PDF preview.
- ZUGFeRD API toolkit: the format, the profiles and every endpoint for German B2B.