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.
/api/v1/invoice/{countryCode}/{format}/embed/api/v1/invoice/{countryCode}/{format}/embed/uploadDer 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
| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
countryCode | string | Land des Zwitterformats — de, ch, fr oder be. | |
format | string | Das Zwitterformat: zugferd für DE und CH, facturx für FR und BE. |
Anfrage
| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
pdf | string (base64) | Dein Träger-PDF, Base64-kodiert. Es wird das Sichtblatt des Zwitterdokuments. | |
invoice | object | Die Rechnungsdaten, aus denen das XML erzeugt wird — dieselbe Struktur wie bei /generate, mit denselben Pflichtfeldern. | |
validate | boolean | - | 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. |
enforcePending | boolean | - | 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.profile | string | - | ZUGFeRD-/Factur-X-Profil, Standard EN16931. |
formatOptions.version | string | - | 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
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.0049 }50 ],51 "subtotal": 1500.00,52 "total": 1785.00,53 "taxSummary": [54 {55 "taxRate": 19,56 "netAmount": 1500.00,57 "taxAmount": 285.0058 }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
| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
pdf | file | Der Träger als Dateiteil mit Inhaltstyp application/pdf — dein Layout, binär statt Base64. | |
invoice | JSON-Text | Die Rechnungsdaten als JSON-Text (Inhaltstyp application/json) — dasselbe Objekt wie an der JSON-Route, nach denselben Regeln geprüft. | |
validate | string | - | 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. |
enforcePending | string | - | true weist Befunde in der Warnphase mit 422 ab — derselbe Schalter wie an der JSON-Route. |
formatOptions | JSON-Text | - | JSON-Text mit profile und/oder version — dasselbe strenge Objekt wie an der JSON-Route. template und templateId werden abgewiesen. |
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.txt10 11# X-Result-Rechecked: false heisst, das Ergebnis wurde nicht erneut geoeffnet12# (Traeger > 15 MiB) — eine Aussage ueber den Weg, nicht ueber das Dokument.13grep -i "^X-" headers.txtDie 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 OK1{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}| Feld | Typ | Beschreibung |
|---|---|---|
success | boolean | Ob das Zwitterdokument entstanden ist. |
format | string | Das verwendete Ausgabeformat. |
filename | string | Vorgeschlagener Dateiname. |
mimeType | string | MIME-Typ des zurückgegebenen Dokuments. |
hash | string | SHA-256 der zurückgegebenen Bytes, hexadezimal. Nicht stabil über Aufrufe hinweg, weil das PDF einen Erzeugungszeitstempel trägt — für idempotente Namen payloadHash nehmen. |
payloadHash | string | SHA-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. |
data | string (base64) | Das Zwitterdokument, Base64-kodiert: dein Layout mit dem erzeugten XML darin. |
embeddedXml | string | Das eingebettete CII-XML im Klartext, zum Nachsehen. |
carrier | object | Was wir an dem erzeugten Dokument gemessen haben — ein Befund, keine Behauptung. |
validation | object | Das Prüfurteil — nur vorhanden, wenn die Anfrage validate: true gesetzt hat. |
appliedFormatOptions | object | Fassung und Profil, in denen das Dokument wirklich geschrieben wurde: deine Angaben oder die Vorgaben. |
Der carrier-Block
| Feld | Typ | Beschreibung |
|---|---|---|
pdfA3Declared | boolean | true 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. |
checks | array | Die Einzelprüfungen, je mit code, severity (warning, error, fatal), passed und, wenn es einen gibt, message. |
warnings | array | Feststellungen 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
| Status | Code | Beschreibung |
|---|---|---|
| 400 | Bad Request | Das Land-Format-Paar ist kein Zwitterformat — ein reines XML-Format oder pdf hat kein Sichtblatt. |
| 400 | Bad Request | template oder templateId wurde geschickt. Auf dieser Route gibt es beides nicht. |
| 400 | INVALID_PDF | Der Träger ist kein lesbares PDF. |
| 401 | UNAUTHORIZED | API-Key fehlt oder ist ungültig. |
| 403 | FORBIDDEN | Der Key hält das create-Entitlement des Formats nicht. |
| 413 | PAYLOAD_TOO_LARGE | Das Träger-PDF ist größer als das konfigurierte Limit. |
| 422 | UNPROCESSABLE | Aus den Rechnungsdaten lässt sich keine konforme E-Rechnung bauen — derselbe Rumpf wie bei /generate, mit errors und complianceErrors und ohne data. |
| 429 | QUOTA_EXCEEDED | Das Kontingent des Formats ist erschöpft. |
| 503 | EMBED_UNAVAILABLE | EMBED_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. |