ZUGFeRD and XRechnung: the two German formats
ZUGFeRD carries the B2B mandate. Receiving e-invoices has been mandatory for all German businesses since January 2025; issuing becomes mandatory from January 2027 for companies above EUR 800k turnover and from January 2028 for everyone else. ZUGFeRD is a PDF/A-3 the recipient can read with embedded CII XML their software can parse. Profiles EN 16931 and above qualify as e-invoices under the mandate; MINIMUM and BASIC WL are booking aids and do not. The current version is ZUGFeRD 2.3, technically identical to France's Factur-X 1.0.7, and the embedded attachment is named factur-x.xml in both brandings.
XRechnung carries the B2G side. Invoices to German federal authorities have had to be XRechnung since November 2020, with state and municipal buyers following. It is Germany's national CIUS of EN 16931, maintained by KoSIT, with version 3.0 in force since February 2024. It adds the German BR-DE business rules, makes several optional EN 16931 fields mandatory, and is submitted through portals such as ZRE and OZG-RE that enforce the KoSIT rule set on acceptance. Because it has no PDF layer, a rendering step for human review becomes part of a serious pipeline.
GoBD archiving frames both. German bookkeeping rules require invoices to be archived as received, immutable and machine-evaluable. ZUGFeRD is convenient there (one file is both the visual record and the data); XRechnung archives as the XML itself.
Underneath sits the engineering that makes "just attach XML to a PDF" deceptively deep: PDF/A-3 containers with embedded fonts and XMP profile declarations, CII XML satisfying roughly 200 EN 16931 business rules, German overlays on top, and Schematron to prove all of it. The technical overview of ZUGFeRD automation walks the layers in detail.
| 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 consumes the same invoice JSON, so supporting all four in JavaScript is a ternary over endpoints, not four integrations. This guide works the first two rows; Factur-X and Peppol UBL have Node.js guides of their own.
The npm library landscape
Unlike most ecosystems, JavaScript has a live open-source scene around ZUGFeRD, and it deserves a fair look before the API route:
node-zugferd is the most complete package: TypeScript, profile-aware, and able to generate ZUGFeRD/Factur-X XML and embed it into PDF/A files. It is the closest thing npm has to Java's Mustangproject, which makes its boundaries the important part of the story.
Lighter generators such as zugferd-generator produce the invoice XML and leave PDF handling to you, and a few invoice-generator packages bundle XRechnung XML output alongside PDF rendering.
Now the boundaries, which are structural rather than a matter of maturity:
Validation is not in the box. The official EN 16931 and KoSIT rule sets are Schematron compiled to XSLT 2.0. JavaScript's mainstream XML tooling stops at XSLT 1.0; Saxon-JS can execute XSLT 2.0+, but nobody ships a maintained npm package that wires the EN 16931 and XRechnung pipelines onto it and tracks the yearly KoSIT releases. Generating an invoice locally tells you nothing about whether a portal will accept it.
PDF/A-3 conformance of arbitrary input is the sharp edge. Embedding XML into a PDF you generated for the purpose is one thing. Taking the PDF your existing billing system already produces (Puppeteer, PDFKit, a template engine) and making it genuinely PDF/A-3 conformant, fonts embedded, color spaces declared, is not solved on npm, and defects surface as recipient-side rejections.
Unstructured input is out of scope entirely. No library reads an ordinary supplier PDF, a scan, or a photo into structured invoice data. That is document understanding, not XML plumbing.
The honest conclusion: generation-side npm tooling is real and improving, but generation is the easy fifth of the problem. Validation, PDF/A conformance, XRechnung, intake, and the yearly KoSIT release cycle stay yours, and every specification update means waiting for a package release, bumping the dependency, and redeploying. The rest of this guide takes the route where none of that exists: the full lifecycle behind one API, with the current rule sets always live server-side.
Client setup with fetch
Node.js 18+ ships fetch, FormData, and Blob globally, so there is nothing to install and no HTTP client to choose. Examples use ES modules with top-level await.
Sign up for a free InvoiceXML account and you will receive 100 free credits, no credit card required. Keep the key in an environment variable or your secrets manager, never in source, and never in client-side code:
const BASE_URL = "https://api.invoicexml.com";
const API_KEY = process.env.INVOICEXML_API_KEY;
if (!API_KEY) throw new Error("INVOICEXML_API_KEY is not set");
Create ZUGFeRD invoices in Node.js
The payload is the invoice as JSON. Send net prices, quantities, and rates; totals and the VAT breakdown are computed server-side, and the document is validated against the full EN 16931 rule set before it comes back. The sample mixes Germany's 19% standard and 7% reduced rates so the breakdown does real work:
import { writeFile } from "node:fs/promises";
const invoice = {
invoice: {
invoiceNumber: "RE-2026-1105",
issueDate: "2026-07-28",
currency: "EUR",
seller: {
name: "Spreewerk Solutions GmbH",
vatIdentifier: "DE811234567",
postalAddress: {
line1: "Köpenicker Str. 154",
city: "Berlin",
postCode: "10997",
country: "DE"
}
},
buyer: {
name: "Main Handel GmbH",
postalAddress: {
line1: "Zeil 65",
city: "Frankfurt am Main",
postCode: "60313",
country: "DE"
}
},
paymentDetails: {
paymentAccountIdentifier: "DE02120300000000202051"
},
lines: [
{
quantity: 60,
item: { name: "SaaS-Lizenz Team, Juli 2026" },
priceDetails: { netPrice: 12.50 },
vatInformation: { rate: 19 }
},
{
quantity: 10,
item: { name: "Benutzerhandbuch (Druckausgabe)" },
priceDetails: { netPrice: 18.00 },
vatInformation: { rate: 7 }
}
]
}
};
const response = await fetch(`${BASE_URL}/v1/create/zugferd`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify(invoice)
});
if (!response.ok) throw new Error(await response.text());
await writeFile("rechnung-zugferd.pdf", Buffer.from(await response.arrayBuffer()));
The response is a finished 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. If the data cannot produce a compliant invoice (a breakdown that does not reconcile, a missing mandatory field), the API answers HTTP 400 with the violated rules as structured findings, so an invalid document never leaves your system.
Runnable Node.js examples on GitHub →
Validate ZUGFeRD invoices in Node.js
Validate outgoing documents before customers see them, and incoming supplier PDFs that claim to be ZUGFeRD. The endpoint extracts the embedded XML, detects the declared profile from BT-24, and runs the matching XSD plus Schematron rules. Uploads are standard FormData; one gotcha worth knowing is that you must not set the Content-Type header yourself, because fetch generates the multipart boundary for you:
import { readFile } from "node:fs/promises";
async function validateZugferd(path) {
const form = new FormData();
form.append(
"file",
new Blob([await readFile(path)], { type: "application/pdf" }),
"rechnung.pdf"
);
const response = await fetch(`${BASE_URL}/v1/validate/zugferd`, {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}` },
body: form
});
return response.json();
}
const report = await validateZugferd("rechnung-zugferd.pdf");
if (report.valid) {
console.log(`Valid ZUGFeRD, profile ${report.data.profile}`);
} else {
for (const finding of report.errors) {
console.log(`[${finding.rule}] ${finding.message}`);
console.log(` fields: ${(finding.fields ?? []).join(", ")}`);
}
}
Both valid and invalid invoices answer HTTP 200; the verdict lives in valid, and non-2xx statuses mean transport problems such as a bad API key or an unreadable upload. Findings carry the rule id, a plain-language message, business term codes, and field paths, ready for a UI or a supplier email as-is.
The same call is a natural CI guard. One Vitest (or Jest) test pins the mapping from your data model to the API payload:
import { expect, test } from "vitest";
test("billing payload produces valid ZUGFeRD", async () => {
const pdf = await createZugferd(sampleInvoice());
const report = await validateZugferd(pdf);
expect(report.valid, JSON.stringify(report.errors)).toBe(true);
});
Create XRechnung invoices in Node.js
Same JSON shape, different endpoint, and two additions that XRechnung enforces while base EN 16931 does not:
buyerReference carries the Leitweg-ID. It maps to BT-10 and is checked by rule BR-DE-15. The public sector buyer assigns it during onboarding; store it per buyer and inject it into every request. It is routing infrastructure, not invoice data, so no source document will ever contain it.
Seller contact and electronic addresses are mandatory. XRechnung requires the full seller.contact group (name, phone, email) and electronic addresses for both parties.
const xrechnung = {
invoice: {
invoiceNumber: "RE-2026-1106",
issueDate: "2026-07-28",
dueDate: "2026-08-27",
currency: "EUR",
buyerReference: "11302-00567-22",
seller: {
name: "Spreewerk Solutions GmbH",
vatIdentifier: "DE811234567",
postalAddress: {
line1: "Köpenicker Str. 154",
city: "Berlin",
postCode: "10997",
country: "DE"
},
contact: {
name: "Lena Hoffmann",
phone: "+49 30 5557890",
email: "[email protected]"
},
electronicAddress: { identifier: "DE811234567", schemeId: "0204" }
},
buyer: {
name: "Bezirksamt Friedrichshain-Kreuzberg von Berlin",
postalAddress: {
line1: "Frankfurter Allee 35",
city: "Berlin",
postCode: "10247",
country: "DE"
},
electronicAddress: { identifier: "11302-00567-22", schemeId: "0204" }
},
paymentDetails: {
paymentMeansCode: 58,
paymentAccountIdentifier: "DE02120300000000202051"
},
lines: [
{
quantity: 95,
unitCode: "HUR",
item: { name: "Softwareentwicklung Fachverfahren" },
priceDetails: { netPrice: 110.00 },
vatInformation: { rate: 19, categoryCode: "S" }
}
]
}
};
const response = await fetch(`${BASE_URL}/v1/create/xrechnung`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify(xrechnung)
});
if (!response.ok) throw new Error(await response.text());
await writeFile("rechnung-xrechnung.xml", Buffer.from(await response.arrayBuffer()));
The response is XRechnung 3.0 in CII syntax, already checked against the EN 16931 Schematron and the KoSIT rules. XRechnung also permits a UBL binding, which matters if your stack serves the Peppol network too: create it via POST /v1/create/ubl with options: { profile: "xrechnung" } and the same invoice JSON. Both outputs are legally equivalent.
Validate XRechnung invoices in Node.js
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, the same rule set ZRE and OZG-RE enforce on submission. It accepts XML from any producer, including node-zugferd output or hand-built documents:
async function validateXrechnung(path) {
const form = new FormData();
form.append(
"file",
new Blob([await readFile(path)], { type: "application/xml" }),
"rechnung.xml"
);
const response = await fetch(`${BASE_URL}/v1/validate/xrechnung`, {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}` },
body: form
});
return response.json();
}
const report = await validateXrechnung("rechnung-xrechnung.xml");
if (!report.valid) {
for (const finding of report.errors) {
// finding.layer is "xsd", "en16931", or "cius" (the German BR-DE rules)
console.log(`[${finding.rule}] (${finding.layer}) ${finding.message}`);
}
}
The layer tag is worth surfacing to users: a cius failure means the document is a perfectly fine EN 16931 invoice that specifically misses a German requirement, and the classic case is BR-DE-15 firing on a missing Leitweg-ID. Note this endpoint accepts XML only; a ZUGFeRD hybrid PDF declaring the XRechnung reference profile goes to /v1/validate/zugferd, and its embedded XML is routed to the same KoSIT rules automatically.
Receiving is the half of the mandate that arrived first: since January 2025 you cannot refuse a compliant e-invoice, so your intake pipeline has to cope with whatever suppliers send. In practice that inbox holds three kinds of documents, and each has its own endpoint: structured e-invoices (ZUGFeRD PDFs or XRechnung XML), old-school PDFs with no structured data at all, and invoices carrying embedded supporting documents you also need.
Deterministic JSON from ZUGFeRD and XRechnung
For documents that already carry structured data, POST /v1/extract/json is pure XML parsing, no AI involved: upload a ZUGFeRD or Factur-X hybrid PDF (the embedded XML is pulled out automatically) or a standalone XRechnung, CII, or UBL XML file, and get back a normalized InvoiceDocument JSON whose field names mirror the /v1/create request bodies:
async function extractInvoice(path) {
const form = new FormData();
form.append("file", new Blob([await readFile(path)]), "invoice");
const response = await fetch(`${BASE_URL}/v1/extract/json`, {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}` },
body: form
});
if (!response.ok) throw new Error(await response.text());
return response.json();
}
const data = await extractInvoice("lieferant-zugferd.pdf");
console.log(`Rechnung ${data.invoiceNumber} von ${data.seller.name}`);
Because the parsing is deterministic, the output is an exact mapping of the source XML: what the supplier declared is what you get, byte-for-byte faithful, which is what an ERP import wants. If you prefer the raw embedded XML itself, /v1/extract/xml streams it out of the PDF container untouched.
AI parsing for old-school invoice PDFs
Not every supplier sends e-invoices yet. For typed, scanned, or photographed PDFs with no embedded XML, POST /v1/parse/json reads the document with AI and returns the same InvoiceDocument shape, plus a confidence object that makes the non-determinism explicit: an overall score and four area scores (seller identification, buyer identification, tax calculation, line items), each from 0.0 to 1.0. That lets you automate the confident reads and route the shaky ones to a human:
async function parseInvoicePdf(path) {
const form = new FormData();
form.append(
"file",
new Blob([await readFile(path)], { type: "application/pdf" }),
"eingang.pdf"
);
const response = await fetch(`${BASE_URL}/v1/parse/json`, {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}` },
body: form
});
if (!response.ok) throw new Error(await response.text());
return response.json();
}
const { invoice, confidence } = await parseInvoicePdf("lieferant-scan.pdf");
if (confidence.overall < 0.7) {
await queueForHumanReview(invoice, confidence);
} else {
await importIntoErp(invoice);
}
The two endpoints compose into one intake function: try the deterministic route first, and fall back to AI only when the API reports error code 4006 (no embedded XML):
async function readIncomingInvoice(path) {
const form = new FormData();
form.append("file", new Blob([await readFile(path)]), "invoice");
const response = await fetch(`${BASE_URL}/v1/extract/json`, {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}` },
body: form
});
if (response.ok) {
return { source: "embedded-xml", invoice: await response.json() };
}
const error = await response.json();
if (error.errorCode !== 4006) throw new Error(JSON.stringify(error));
// No embedded XML: an old-school PDF, hand it to the AI parser
const parsed = await parseInvoicePdf(path);
return { source: "ai", ...parsed };
}
E-invoices can carry their supporting documents inside the invoice itself: delivery notes, timesheets, or the original order, embedded base64 in the EN 16931 attachment group (BG-24). XRechnung makes this routine, because German B2G portals transmit supporting documents inside the XML rather than as separate files. POST /v1/extract/attachments collects every embedded payload and returns the original files as one ZIP:
async function extractAttachments(path, zipPath) {
const form = new FormData();
form.append("file", new Blob([await readFile(path)]), "invoice");
const response = await fetch(`${BASE_URL}/v1/extract/attachments`, {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}` },
body: form
});
if (response.status === 404) return null; // invoice carries no attachments
if (!response.ok) throw new Error(await response.text());
await writeFile(zipPath, Buffer.from(await response.arrayBuffer()));
return zipPath;
}
await extractAttachments("rechnung-xrechnung.xml", "belege.zip");
It accepts a standalone XML e-invoice (XRechnung in either syntax, CII, or UBL) or a ZUGFeRD / Factur-X hybrid PDF, and the attachment bytes are decoded into the ZIP verbatim, never parsed or rendered server-side. An invoice without embedded attachments answers 404 rather than an empty archive, which is why the snippet treats that status as a normal outcome.
Render XRechnung previews
XRechnung has no visual layer, so before submission (or when an incoming one needs human review) render it as a readable PDF:
async function renderXrechnung(xmlPath, pdfPath) {
const form = new FormData();
form.append(
"file",
new Blob([await readFile(xmlPath)], { type: "application/xml" }),
"rechnung.xml"
);
const response = await fetch(`${BASE_URL}/v1/render/xrechnung/to/pdf`, {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}` },
body: form
});
await writeFile(pdfPath, Buffer.from(await response.arrayBuffer()));
}
await renderXrechnung("rechnung-xrechnung.xml", "rechnung-vorschau.pdf");
The preview is for human eyes only; the XML remains the legal document, and the rendered PDF should never be submitted to a portal.
Serverless and edge deployments
Everything above used only fetch, FormData, and Blob, which are Web APIs, not Node.js APIs. That is what makes this integration serverless-native: no native modules to compile, no headless browser, no filesystem requirement, no bundle-size fight, and no long-running process. The library route struggles on exactly those points, because PDF tooling tends to assume a full Node.js environment.
A complete Cloudflare Worker that turns JSON into a ZUGFeRD PDF, with the key held in a Worker secret:
export default {
async fetch(request, env) {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const payload = await request.json();
const upstream = await fetch("https://api.invoicexml.com/v1/create/zugferd", {
method: "POST",
headers: {
Authorization: `Bearer ${env.INVOICEXML_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ invoice: payload })
});
if (!upstream.ok) {
return new Response(await upstream.text(), { status: 400 });
}
return new Response(upstream.body, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": 'attachment; filename="rechnung-zugferd.pdf"'
}
});
}
};
Note the last line of the happy path: upstream.body is a stream, so the PDF flows from the API through the Worker to the client without ever being buffered in memory. The same shape works as a Next.js route handler, where it doubles as the correct answer to "can the browser call the API directly?" (it should not, because client-side code would expose your key):
// app/api/invoices/route.js (Next.js App Router, also fine on Vercel Edge)
export async function POST(request) {
const { invoice } = await request.json();
const upstream = await fetch("https://api.invoicexml.com/v1/create/zugferd", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INVOICEXML_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ invoice })
});
if (!upstream.ok) {
return new Response(await upstream.text(), { status: 400 });
}
return new Response(upstream.body, {
headers: { "Content-Type": "application/pdf" }
});
}
Deno and Bun need no changes at all beyond how they read environment variables. For retries in any of these runtimes, retry transport failures and 5xx responses only; an HTTP 400 carries validation findings about your data, and resending the same payload produces the same findings.
npm library vs REST API
The fair summary for JavaScript teams:
The library route is a standing commitment, not a dependency. node-zugferd is free and actively developed, and building on it still means owning the gap on validation (without an in-process path to the official Schematron, you are trusting your mapping, not verifying it), the PDF/A conformance of your output, XRechnung, intake, and the KoSIT calendar. Every specification release, and KoSIT ships a new XRechnung version yearly with patches between, means waiting for the package to catch up, bumping it, retesting, and redeploying every service that touches invoices.
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 npm release to wait for, no redeploy, which matters double in serverless deployments where you would otherwise be re-bundling workers on someone else's schedule. The domain expertise sits on our side with professional support behind it, missing features get integrated on request, and both German formats plus Factur-X and Peppol UBL run behind one integration that deploys anywhere JavaScript runs.
The deciding question is who owns the moving target. Compliance rules change on a government calendar, not yours. Build on a package and that calendar sets your release cadence indefinitely; build on the API and your integration is finished the day it works, because staying compliant 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 only for the duration of a single HTTP exchange.
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 |
| Render XRechnung as PDF | POST /v1/render/xrechnung/to/pdf | XRechnung XML | PDF binary |
| 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 |
| 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 runs on a stock Node.js 18+ with zero dependencies: paste it into an .mjs file, set INVOICEXML_API_KEY, and run it. Runnable examples live in the examples repository:
InvoiceXML/facturx-api-examples/nodejs →
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 JavaScript runtime: Node.js, Next.js, Vercel, Cloudflare Workers, Deno, or Bun.