Zum Inhalt springen
LiveZUGFeRD / Factur-X

Dein Layout, unser XML

Du hast dein Rechnungs-PDF schon — aus dem ERP, aus der Druckerei, aus der eigenen Vorlage. Dieser Endpunkt nimmt es als Träger, erzeugt das CII-XML aus deinen Rechnungsdaten und gibt genau dieses PDF zurück, mit dem XML als factur-x.xml darin.

POST/api/v1/invoice/{countryCode}/{format}/embed
POST/api/v1/invoice/{countryCode}/{format}/embed/upload

Der Unterschied zu /generate

Beide Endpunkte erzeugen dieselbe E-Rechnung aus denselben Daten und laufen über dasselbe Entitlement. Der einzige Unterschied ist die Seite, die der Empfänger sieht.

  • • Bei /generate kommt das Sichtblatt aus unserer Vorlage — dann ist die PDF/A-3-Konformität unser Problem.
  • • Bei /embed kommt das Sichtblatt von dir und bleibt unverändert; wir legen nur das XML hinein und setzen die Kennungen.
  • • Gleiche Rechnungsdaten, gleiche Prüfkette, gleiche Fehlerbilder (422 mit denselben Regelcodes).

template und templateId gibt es auf dieser Route nicht. Der Rumpf ist strikt und weist sie mit 400 ab, statt sie still zu ignorieren — das Layout ist ja deins.

Zur Creator API (/generate)

Nur für die Zwitterformate

Ein Zwitterdokument braucht ein Sichtblatt, für das ein Träger-PDF einspringen kann. Reine XML-Formate wie xrechnung oder ubl haben keines, und das reine pdf trägt kein XML — beide antworten mit 400.

Pfad-Parameter

FeldTypPflichtBeschreibung
countryCodestringLand des Zwitterformats — de, ch, fr oder be.
formatstringDas Zwitterformat: zugferd für DE und CH, facturx für FR und BE.

Anfrage

FeldTypPflichtBeschreibung
pdfstring (base64)Dein Träger-PDF, Base64-kodiert. Es wird das Sichtblatt des Zwitterdokuments.
invoiceobjectDie Rechnungsdaten, aus denen das XML erzeugt wird — dieselbe Struktur wie bei /generate, mit denselben Pflichtfeldern.
validateboolean-Führt zusätzlich die XSD- und Schematron-Prüfung aus und gibt das Urteil im Block validation zurück. Kostet eine zweite Einheit, genau wie bei /generate.
enforcePendingboolean-Weist Regelbefunde ab, die noch in ihrer Warnphase stehen. Würde eine neu scharfe Regel eines gepinnten Regelwerks (EN 16931, Peppol BIS, ein nationales CIUS) ein bisher angenommenes Dokument künftig abweisen, kündigen wir das zuerst an: mindestens 28 Tage lang antwortet der Aufruf weiter mit 200, und der Befund kommt als Warnung SPEC_RULE_PENDING mit rule (oder pendingKey, wenn der Befund keine Regelkennung hat), enforceFrom und specId. true schaltet diese Phase für diesen Aufruf ab — dann antwortet jeder Befund mit 422, so wie ab enforceFrom ohnehin. Konform ist das Dokument in keinem der beiden Fälle.
formatOptions.profilestring-ZUGFeRD-/Factur-X-Profil, Standard EN16931.
formatOptions.versionstring-Fassung des Formats, Standard 2.4 für ZUGFeRD und 1.08 für Factur-X.

PDF/A-3 wird deklariert, nicht konvertiert

Wir setzen die Kennung an dem Träger, den du geschickt hast. Wir schreiben dein PDF nicht um. Bei einem beliebigen PDF kann diese Deklaration deshalb falsch sein.

  • • Am häufigsten, wenn dein PDF Schriften ohne eingebettete Schriftdatei verwendet — das verlangt PDF/A-3.
  • • Was wir tatsächlich geprüft haben, steht in carrier.checks; was auffiel, in carrier.warnings.
  • • Ein vollständiger PDF/A-3b-Nachweis (veraPDF) findet bei uns nicht statt. Abgelehnt wird deswegen nichts: Die Feststellung ist unsere, die Entscheidung ist deine.

Wenn dein Empfänger einen vollständigen Nachweis verlangt: Schick deinen Träger einmal durch einen PDF/A-Konverter, bevor er zu uns kommt — oder nutze /generate, wo das PDF unseres ist.

Beispiel

bash
1curl -X POST https://service.invoice-api.xhub.io/api/v1/invoice/de/zugferd/embed \
2 -H "Authorization: Bearer sk_live_abc123..." \
3 -H "Content-Type: application/json" \
4 -d '{
5 "pdf": "JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9UeXBlL1BhZ2UvTWVkaWFCb3...",
6 "validate": true,
7 "invoice": {
8 "invoiceNumber": "RE-2025-001",
9 "type": "invoice",
10 "issueDate": "2025-01-15",
11 "dueDate": "2025-02-15",
12 "currency": "EUR",
13 "seller": {
14 "name": "Meine Firma GmbH",
15 "street": "Musterstraße 1",
16 "city": "Berlin",
17 "postalCode": "10115",
18 "countryCode": "DE",
19 "vatId": "DE123456789",
20 "bankAccount": {
21 "iban": "DE89370400440532013000",
22 "bic": "COBADEFFXXX"
23 }
24 },
25 "buyer": {
26 "name": "Kunde AG",
27 "street": "Kundenweg 42",
28 "city": "München",
29 "postalCode": "80331",
30 "countryCode": "DE",
31 "vatId": "DE987654321"
32 },
33 "countrySpecific": {
34 "countryCode": "DE",
35 "buyerReference": "BUYER-REF-001"
36 },
37 "items": [
38 {
39 "position": 1,
40 "description": "Beratungsleistung",
41 "quantity": 10,
42 "unit": "HUR",
43 "unitPrice": 150.00,
44 "taxRate": 19,
45 "taxCategoryCode": "S",
46 "netAmount": 1500.00,
47 "taxAmount": 285.00,
48 "grossAmount": 1785.00
49 }
50 ],
51 "subtotal": 1500.00,
52 "total": 1785.00,
53 "taxSummary": [
54 {
55 "taxRate": 19,
56 "netAmount": 1500.00,
57 "taxAmount": 285.00
58 }
59 ]
60 }
61 }'

Große Träger: multipart statt Base64

Dieselbe Sache wie die JSON-Route, nur kommt der Träger als Dateiteil eines multipart/form-data-Requests statt Base64-kodiert im JSON — kein Base64-Aufschlag, weder bei dir noch bei uns. Gleiche Prüfungen, gleiches Entitlement, gleiche Abrechnung und dieselbe JSON-Antwort.

Die Teile des Requests

FeldTypPflichtBeschreibung
pdffileDer Träger als Dateiteil mit Inhaltstyp application/pdf — dein Layout, binär statt Base64.
invoiceJSON-TextDie Rechnungsdaten als JSON-Text (Inhaltstyp application/json) — dasselbe Objekt wie an der JSON-Route, nach denselben Regeln geprüft.
validatestring-true fährt dieselbe XSD- und Schematron-Prüfung wie /validate und zählt als zwei Einheiten. Weggelassen oder false: keine Prüfung, eine Einheit.
enforcePendingstring-true weist Befunde in der Warnphase mit 422 ab — derselbe Schalter wie an der JSON-Route.
formatOptionsJSON-Text-JSON-Text mit profile und/oder version — dasselbe strenge Objekt wie an der JSON-Route. template und templateId werden abgewiesen.
bash
1curl -X POST \
2 https://service.invoice-api.xhub.io/api/v1/invoice/de/zugferd/embed/upload \
3 -H "Authorization: Bearer sk_live_abc123..." \
4 -H "Accept: application/pdf" \
5 -F "pdf=@rechnung-RE-2025-001.pdf;type=application/pdf" \
6 -F "invoice=@invoice.json;type=application/json" \
7 -F "validate=true" \
8 -o rechnung-zugferd.pdf \
9 -D headers.txt
10 
11# X-Result-Rechecked: false heisst, das Ergebnis wurde nicht erneut geoeffnet
12# (Traeger > 15 MiB) — eine Aussage ueber den Weg, nicht ueber das Dokument.
13grep -i "^X-" headers.txt

Die Binärantwort

Mit dem Kopf Accept: application/pdf bekommst du das Zwitter-PDF selbst als Antwortkörper — aber nur, wenn es nichts zu melden gibt: die Prüfung bestanden oder nicht verlangt, warnings leer und carrier.warnings leer. Sonst kommt der JSON-Körper, damit kein Befund verloren geht.

Die Binärantwort trägt vier Kopfzeilen: X-Validation-Status (passed oder skipped), X-Result-Rechecked (true oder false), X-Hash (SHA-256 der zurückgegebenen Bytes) und X-Payload-Hash (derselbe payloadHash wie im JSON).

Was bei großen Trägern passiert

Ab 5 MiB bearbeitet ein eigener Dienst den Träger und hängt das XML an deine Datei an — deine Bytes bleiben unverändert. Über 15 MiB wird das Ergebnis nicht erneut geöffnet: warnings trägt PDF_RESULT_NOT_RECHECKED, carrier.checks bleibt leer, und die Binärantwort meldet X-Result-Rechecked: false. Das beschreibt den Weg, nicht das Dokument, und hält die Binärantwort nicht auf. Konnten die Schriften nicht geprüft werden, steht CARRIER_FONTS_NOT_CHECKED in carrier.warnings.

Die Grenzen

Der Request-Körper darf bis 96 MiB groß sein. Der Träger darf bis 64 MiB haben, wenn der Einbettungsdienst auf dem Server verfügbar ist; sonst gilt dieselbe Grenze wie an der JSON-Route, standardmäßig 15 MiB. Der 413 unterscheidet beides: PAYLOAD_TOO_LARGE für den Körper, CARRIER_TOO_LARGE für den Träger, je mit limit in Bytes.

Antwort

200 OK
json
1{
2 "success": true,
3 "format": "zugferd",
4 "filename": "RE-2025-001.pdf",
5 "mimeType": "application/pdf",
6 "hash": "9f2c4e1b7a0d5836f4b2c9e18a7d3045c6b1e8f29a4d70b3c5e81f6a2d9c4b70",
7 "payloadHash": "3a7f1e9c2b8d4056a1c7e3f9b2d84e6015c9a7f3e1b8d2460c5a9f7e3b1d8460",
8 "data": "JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9UeXBlL1BhZ2Uv...",
9 "embeddedXml": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><rsm:CrossIndustryInvoice ...",
10 "carrier": {
11 "pdfA3Declared": true,
12 "checks": [
13 { "code": "PDF-001", "severity": "error", "passed": true },
14 { "code": "XMP-003", "severity": "warning", "passed": true }
15 ],
16 "warnings": [
17 {
18 "code": "CARRIER_FONTS_NOT_EMBEDDED",
19 "message": "The carrier uses 2 fonts without an embedded font file."
20 }
21 ]
22 },
23 "validation": {
24 "valid": true,
25 "status": "passed",
26 "results": [
27 { "format": "zugferd-2.4-en16931", "valid": true, "errors": [] }
28 ]
29 },
30 "appliedFormatOptions": {
31 "version": "2.4",
32 "profile": "EN16931"
33 }
34}
FeldTypBeschreibung
successbooleanOb das Zwitterdokument entstanden ist.
formatstringDas verwendete Ausgabeformat.
filenamestringVorgeschlagener Dateiname.
mimeTypestringMIME-Typ des zurückgegebenen Dokuments.
hashstringSHA-256 der zurückgegebenen Bytes, hexadezimal. Nicht stabil über Aufrufe hinweg, weil das PDF einen Erzeugungszeitstempel trägt — für idempotente Namen payloadHash nehmen.
payloadHashstringSHA-256 über das kanonische JSON deiner Rechnungsdaten — der stabile Schlüssel für idempotente Aufrufe. hash wechselt bei jedem Aufruf, weil das PDF einen Zeitstempel trägt.
datastring (base64)Das Zwitterdokument, Base64-kodiert: dein Layout mit dem erzeugten XML darin.
embeddedXmlstringDas eingebettete CII-XML im Klartext, zum Nachsehen.
carrierobjectWas wir an dem erzeugten Dokument gemessen haben — ein Befund, keine Behauptung.
validationobjectDas Prüfurteil — nur vorhanden, wenn die Anfrage validate: true gesetzt hat.
appliedFormatOptionsobjectFassung und Profil, in denen das Dokument wirklich geschrieben wurde: deine Angaben oder die Vorgaben.

Der carrier-Block

FeldTypBeschreibung
pdfA3Declaredbooleantrue heißt: Wir haben die PDF/A-3-Kennung auf deinem Träger gesetzt (XMP pdfaid:part=3, Stufe B, sRGB-OutputIntent). Es heißt nicht, dass dein Träger konform ist.
checksarrayDie Einzelprüfungen, je mit code, severity (warning, error, fatal), passed und, wenn es einen gibt, message.
warningsarrayFeststellungen zu deinem Träger, die wir melden, aber nicht ablehnen — etwa CARRIER_FONTS_NOT_EMBEDDED.

Der validation-Block

Nur vorhanden, wenn du validate: true geschickt hast. status sagt, was passiert ist: passed — die Regelwerke liefen und fanden nichts; failed — sie fanden etwas, valid ist dann false und errors nennt es; not_run — sie liefen nicht vollständig, valid ist null und reason nennt den Grund (missing, load-failed, transform-failed oder not-bound). Ein not_run ist kein Freibrief: Über das Dokument ist damit nichts bewiesen.

Abrechnung

Der Aufruf läuft auf dem vorhandenen create-Entitlement des Formats und kostet eine Einheit — zwei, wenn du validate: true setzt. Kein neues Entitlement, kein Zuschlag.

Fehler

StatusCodeBeschreibung
400Bad RequestDas Land-Format-Paar ist kein Zwitterformat — ein reines XML-Format oder pdf hat kein Sichtblatt.
400Bad Requesttemplate oder templateId wurde geschickt. Auf dieser Route gibt es beides nicht.
400INVALID_PDFDer Träger ist kein lesbares PDF.
401UNAUTHORIZEDAPI-Key fehlt oder ist ungültig.
403FORBIDDENDer Key hält das create-Entitlement des Formats nicht.
413PAYLOAD_TOO_LARGEDas Träger-PDF ist größer als das konfigurierte Limit.
422UNPROCESSABLEAus den Rechnungsdaten lässt sich keine konforme E-Rechnung bauen — derselbe Rumpf wie bei /generate, mit errors und complianceErrors und ohne data.
429QUOTA_EXCEEDEDDas Kontingent des Formats ist erschöpft.
503EMBED_UNAVAILABLEEMBED_UNAVAILABLE — der Einbettungsdienst ist nicht verfügbar und der Träger ist größer als 15 MiB. Retry-After: 60, und es wird nichts abgerechnet. Nur an der Upload-Route.