Automation MCP Server Blog Pricing Contact
Automation ZUGFeRD API

PDF to ZUGFeRD: Automating the Conversion with a Human in the Loop

Plain PDF invoices are the output of a lot of systems that are not going away, and the e-invoicing mandates want structured documents instead. This guide builds the pipeline that converts them: AI extraction into JSON, a review form where a person verifies and completes the data, and one create call that returns a validated ZUGFeRD or XRechnung. Plain curl throughout, with the real response and request shapes, the fields no PDF carries, and why the one-shot transform endpoint is a shortcut that still needs the same review.

The question arriving more and more often is some version of "we have PDFs, we need ZUGFeRD, how do we automate that". The PDFs come from a billing module that will not be replaced this year, a legacy ERP, a web shop, an outsourced invoicing tool, or an archive of invoices that a customer now wants re-issued in a structured format. None of those systems produce a ZUGFeRD or an XRechnung, and the people asking would like a pipeline rather than a person retyping invoices.

This guide builds that pipeline with three plain API calls and one screen. The AI reads the PDF into structured JSON, a person reviews that JSON on a form and completes what the PDF never contained, and the create endpoint turns the reviewed JSON into a validated ZUGFeRD or XRechnung. Everything is shown in curl, with the real response and request shapes, so it maps onto any language with an HTTP client. It also explains, with specifics, why the review step is not optional, and where the one-shot transform endpoint fits.

Why the old PDFs are suddenly a problem

A plain PDF is a picture of an invoice. The German mandate wants the invoice itself as structured data: every business has had to accept e-invoices since January 2025, issuing them becomes mandatory from 2027, and public buyers have required XRechnung for years. Buyers with automated intake are already pushing back on PDF-only suppliers, because a PDF means someone on their side has to key it in.

ZUGFeRD is the format that softens the transition: a PDF/A-3 that looks like the invoice people are used to, with the structured XML embedded inside. The recipient's software reads the XML and books from it, the human reads the PDF. That is why the demand is "convert our PDFs to ZUGFeRD" rather than "replace our billing": the visible document can stay, the data has to be added.

The catch is that the data has to be correct and complete. The XML is the invoice as far as the recipient's system and the tax authority are concerned, and the XML is validated against the EN 16931 business rules. A pipeline that puts plausible-looking but wrong values into it produces legally wrong invoices that look fine.


The pipeline: extract, review, create

The working pattern has three stages, and the middle one is a screen rather than an API call:

  • Extract. POST /v1/parse/json reads the PDF (typed, scanned or photographed) and returns the invoice as EN 16931-shaped JSON with confidence scores. This is the AI step, and the only one.
  • Review. The JSON is loaded into a form next to the source PDF. A person verifies the extracted values, pays attention where the confidence is low, and fills in the fields a compliant e-invoice needs that the PDF never carried.
  • Create. The reviewed JSON is posted to POST /v1/create/zugferd or POST /v1/create/xrechnung. The API calculates the totals, generates the XML, validates it against the full rule set, and returns the finished file, or a 400 that names the field to fix.

The design trick that keeps this small is that the JSON the parser returns has the same shape as the JSON the create endpoints accept. There is one document model, the form binds to it, and nothing is remapped between the three stages.


Why a human stays in the loop

Teams sometimes plan to wire the parser straight into the create call and ship. Two things stop that from being a production design.

AI extraction makes mistakes on complex layouts. A clean single-page invoice with one VAT rate reads well. Line tables that run across pages, quantity and unit columns that swap places, a discount mentioned in a footnote, a mix of 19% and 7% lines, a deposit deducted below the total, a scan with a stamp over the IBAN: these produce wrong values, and the wrong values are well-formed numbers that nothing downstream can tell from right ones. The confidence scores in the response say where the model was unsure. They do not confirm that any value is correct, and a high score on a misread is possible.

The PDF does not contain everything a compliant e-invoice requires. This is the part that no better model fixes. An XRechnung needs the buyer's Leitweg-ID, a routing identifier the public buyer assigned during onboarding, which appears on no invoice. It needs electronic addresses for seller and buyer with their scheme codes, and a seller contact with name, phone and email. A ZUGFeRD needs an IBAN, which many PDFs show only partially or not at all, and a seller VAT identifier or legal registration where the PDF prints only a local tax number. A line the PDF labels "Reverse charge" in words needs the coded VAT category AE in the XML. Someone has to supply these, from the customer record, from company settings, or by knowing the business, and the form is where that happens.

There is also a contractual side. The InvoiceXML Terms of Service classify everything derived from a PDF by AI as AI output that must be reviewed by a human before it is used. The pipeline below is the shape that review takes when it is built into the process instead of left to chance.


Step 1: AI extraction with /v1/parse/json

The parser takes the PDF as multipart/form-data and returns JSON. One call, no template setup, no zone configuration; OCR runs when the PDF has no text layer:

curl -X POST https://api.invoicexml.com/v1/parse/json \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "[email protected]" \
  --output RE-2026-0917.json

The response carries the document under invoice, the scoring under confidence, and the language the invoice is written in. This is what a typical German supplier invoice comes back as, abbreviated to the fields the rest of the guide works with:

{
  "invoice": {
    "invoiceNumber": "RE-2026-0917",
    "issueDate": "2026-10-14",
    "dueDate": "2026-11-13",
    "typeCode": "380",
    "currency": "EUR",
    "buyerReference": null,
    "purchaseOrderReference": "BEST-4471",
    "seller": {
      "name": "Werkstatt Brandt GmbH",
      "vatIdentifier": "DE812345678",
      "taxRegistrationIdentifier": "27/123/45678",
      "postalAddress": {
        "line1": "Industriestr. 14",
        "city": "Hannover",
        "postCode": "30165",
        "country": "DE"
      },
      "contact": { "name": null, "phone": "+49 511 123456", "email": "[email protected]" },
      "electronicAddress": null,
      "legalRegistration": { "identifier": "HRB 204711", "schemeId": null }
    },
    "buyer": {
      "name": "Stadtwerke Musterstadt GmbH",
      "vatIdentifier": null,
      "postalAddress": {
        "line1": "Am Kraftwerk 2",
        "city": "Musterstadt",
        "postCode": "38100",
        "country": "DE"
      },
      "electronicAddress": null
    },
    "paymentDetails": {
      "paymentMeansCode": "58",
      "paymentAccountIdentifier": "DE44500105175407324931",
      "bic": "INGDDEFFXXX",
      "paymentTerms": "Zahlbar innerhalb von 30 Tagen ohne Abzug."
    },
    "lines": [
      {
        "lineId": "1",
        "quantity": 8,
        "unitCode": "HUR",
        "item": { "name": "Wartung Pumpenanlage, Monteurstunden" },
        "priceDetails": { "netPrice": 45.00 },
        "vatInformation": { "categoryCode": "S", "rate": 19 },
        "lineNetAmount": 360.00
      },
      {
        "lineId": "2",
        "quantity": 1,
        "unitCode": "C62",
        "item": { "name": "Ersatzteil Dichtungssatz DN80" },
        "priceDetails": { "netPrice": 120.00 },
        "vatInformation": { "categoryCode": "S", "rate": 19 },
        "lineNetAmount": 120.00
      }
    ],
    "vatBreakdowns": [
      { "categoryCode": "S", "rate": 19, "taxableAmount": 480.00, "taxAmount": 91.20 }
    ],
    "totals": {
      "sumOfLineNetAmounts": 480.00,
      "taxBasisTotalAmount": 480.00,
      "taxTotalAmount": 91.20,
      "grandTotalAmount": 571.20,
      "duePayableAmount": 571.20
    }
  },
  "confidence": {
    "overall": 0.93,
    "areas": {
      "sellerIdentification": 0.97,
      "buyerIdentification": 0.95,
      "taxCalculation": 0.93,
      "lineItems": 0.88
    }
  },
  "language": "de"
}

Three things in this response shape the next step. The null values are honest: the parser returns every field of the model and leaves what the PDF did not show empty, rather than guessing. buyerReference, both electronicAddress groups and the seller contact name are exactly the fields the XRechnung section below has to fill. And every area scores above 0.9 except lineItems at 0.88: a clean read overall, with the model pointing at the line table as the one place a reviewer should glance at first.

Two error codes matter at this stage: a file that is not an invoice at all is rejected with errorCode 4008 rather than force-fitted, and a PDF with several invoices in it with 4009. The invoice parser guide covers the parser in depth, including the deterministic route for PDFs that already carry embedded XML.


Step 2: the review form

The form is the human-in-the-loop step, and it is worth building properly once because every invoice goes through it. The principles that keep it simple:

Bind the form to the JSON as it is. Store the invoice object from the parse response and let the form edit it in place. When the reviewer saves, the form's output is the create request body. No second model, no mapping layer, and a field the API adds later is a form field away.

Show the source PDF next to it. The reviewer's job is to compare, so the PDF belongs on screen, not in another tab. Group the fields by the four confidence areas and colour the group header by its score: anything under 0.9 gets attention, anything under 0.7 is treated as unverified until a person has looked. The line table deserves line-by-line comparison whenever lineItems is the area below your bar, as it is above.

Prefill what you already know. When the PDFs are your own outgoing invoices, the seller is you: overwrite the seller block from company settings (VAT identifier, legal registration, contact, electronic address, IBAN) and lock it. That removes the most consequential fields from the review in one move. The buyer side comes from the customer record keyed by name or VAT identifier: Leitweg-ID, electronic address, buyer VAT identifier. What remains for the person is the invoice-specific data: number, dates, lines, totals.

Make the target format's gaps explicit. A section at the bottom lists the fields the chosen output requires that are still empty, so the reviewer sees them before the create call does. This is the reviewer's checklist:

Form sectionJSON pathWhat the reviewer checks or adds
HeaderinvoiceNumber, issueDate, dueDate, typeCodeNumber and dates against the PDF. A document headed "Gutschrift" or "Storno" needs typeCode 381 or 384 and the original invoice in precedingInvoiceReferences; the parser cannot decide that for you.
Sellerseller.*Name, address, VAT identifier (BT-31). Prefilled and locked when the seller is you. ZUGFeRD accepts a VAT identifier or a legal registration; XRechnung requires the VAT identifier plus a full contact.
Buyerbuyer.*Name and address against the PDF; VAT identifier when the PDF shows one. Electronic address from the customer record for XRechnung.
RoutingbuyerReferenceThe Leitweg-ID for a German public buyer, from the customer record. Never on the PDF.
PaymentpaymentDetails.paymentAccountIdentifier, bic, paymentTermsThe IBAN, character by character, against the PDF or your own bank details. ZUGFeRD requires it. A misread IBAN is the most expensive error in the whole pipeline.
Lineslines[]Quantity, unit, net price, VAT rate and category per line. A "reverse charge" or "steuerfrei" line needs category AE or E and, for E, an exemption reason in vatBreakdowns.
Totalstotals, vatBreakdownsRead-only display. If any line, rate or document charge was edited, clear both objects so the API recalculates them from the lines.

That last row is a practical rule worth building into the form. The parser returns the totals it read, and the create endpoint accepts totals as overrides. If a reviewer corrects a quantity and the totals from the PDF stay in the request, the generated XML fails the arithmetic rules (BR-CO-15 and its relatives) and the call returns a 400. Dropping totals and vatBreakdowns after any edit to the lines lets the API compute them, and they will be right by construction.


Step 3: generate the ZUGFeRD with /v1/create/zugferd

The reviewed document goes back as JSON under invoice, with an optional options object for the PDF. For the invoice above, after the reviewer confirmed the lines, locked the seller from company settings, and cleared the totals, the request body is:

{
  "invoice": {
    "invoiceNumber": "RE-2026-0917",
    "issueDate": "2026-10-14",
    "dueDate": "2026-11-13",
    "typeCode": "380",
    "currency": "EUR",
    "purchaseOrderReference": "BEST-4471",
    "seller": {
      "name": "Werkstatt Brandt GmbH",
      "vatIdentifier": "DE812345678",
      "postalAddress": { "line1": "Industriestr. 14", "city": "Hannover", "postCode": "30165", "country": "DE" },
      "contact": { "name": "Jana Brandt", "phone": "+49 511 123456", "email": "[email protected]" },
      "legalRegistration": { "identifier": "HRB 204711" }
    },
    "buyer": {
      "name": "Stadtwerke Musterstadt GmbH",
      "postalAddress": { "line1": "Am Kraftwerk 2", "city": "Musterstadt", "postCode": "38100", "country": "DE" }
    },
    "paymentDetails": {
      "paymentMeansCode": "58",
      "paymentAccountIdentifier": "DE44500105175407324931",
      "bic": "INGDDEFFXXX",
      "paymentTerms": "Zahlbar innerhalb von 30 Tagen ohne Abzug."
    },
    "lines": [
      {
        "quantity": 8,
        "unitCode": "HUR",
        "item": { "name": "Wartung Pumpenanlage, Monteurstunden" },
        "priceDetails": { "netPrice": 45.00 },
        "vatInformation": { "categoryCode": "S", "rate": 19 }
      },
      {
        "quantity": 1,
        "unitCode": "C62",
        "item": { "name": "Ersatzteil Dichtungssatz DN80" },
        "priceDetails": { "netPrice": 120.00 },
        "vatInformation": { "categoryCode": "S", "rate": 19 }
      }
    ]
  },
  "options": {
    "language": "de",
    "validationLanguage": "de"
  }
}

Save that as a file and post it. The response body is the finished PDF, so curl writes it straight to disk:

curl -X POST https://api.invoicexml.com/v1/create/zugferd \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d @RE-2026-0917-reviewed.json \
  --output RE-2026-0917-zugferd.pdf

A 200 means the invoice was generated, the embedded XML was validated against the full EN 16931 rule set, and the file is ready to send. The totals, the VAT breakdown and the line identifiers were calculated server-side from the lines; the PDF was rendered from the same data in German because options.language says so, and the language the parser reported is the natural value to pass here. validationLanguage words any rejection in German for the reviewer.

Two options change the visual layer. options.pdfUrl keeps your original PDF: the API downloads it from a public URL and embeds the generated XML into it, so the layout, fonts and branding stay exactly as the customer knows them. options.brandColor and options.logoUrl style the built-in template instead. Keeping the original PDF is attractive for exactly this use case, with one consequence: the visible page and the embedded XML are then produced from different sources, and the recipient books from the XML. It raises the stakes of the review rather than lowering them.


The XRechnung variant: Leitweg-ID and friends

When the buyer is a German public body, the target is XRechnung rather than ZUGFeRD: pure XML, validated against the KoSIT rules on top of EN 16931, submitted through a portal or Peppol. The pipeline is identical up to the create call. What changes is the list of required fields, and nearly all of the additions are things the PDF never had:

  • buyerReference: the Leitweg-ID (BT-10), assigned by the buyer during onboarding. Rule BR-DE-15 rejects the document without it. Store it on the customer record as text; Leitweg-IDs carry leading zeros and dashes.
  • seller.vatIdentifier: required, where ZUGFeRD also accepted a legal registration.
  • seller.contact: name, phone and email, all three.
  • seller.electronicAddress and buyer.electronicAddress: an identifier plus an EAS scheme code, for example the VAT identifier with scheme 9930, or the Leitweg-ID with scheme 0204 on the buyer side.
  • Full postal addresses on both sides: street line and post code are no longer optional.

In the form these are the "missing for XRechnung" section, prefilled from the customer record and company settings. The request is the same JSON with those fields present, posted to the XRechnung endpoint; options.syntax picks UBL (the default) or CII, whichever the receiving portal prefers:

{
  "invoice": {
    "invoiceNumber": "RE-2026-0917",
    "issueDate": "2026-10-14",
    "dueDate": "2026-11-13",
    "currency": "EUR",
    "buyerReference": "04011000-12345-34",
    "purchaseOrderReference": "BEST-4471",
    "seller": {
      "name": "Werkstatt Brandt GmbH",
      "vatIdentifier": "DE812345678",
      "postalAddress": { "line1": "Industriestr. 14", "city": "Hannover", "postCode": "30165", "country": "DE" },
      "contact": { "name": "Jana Brandt", "phone": "+49 511 123456", "email": "[email protected]" },
      "electronicAddress": { "identifier": "DE812345678", "schemeId": "9930" }
    },
    "buyer": {
      "name": "Stadtwerke Musterstadt GmbH",
      "postalAddress": { "line1": "Am Kraftwerk 2", "city": "Musterstadt", "postCode": "38100", "country": "DE" },
      "electronicAddress": { "identifier": "04011000-12345-34", "schemeId": "0204" }
    },
    "paymentDetails": {
      "paymentMeansCode": "58",
      "paymentAccountIdentifier": "DE44500105175407324931",
      "bic": "INGDDEFFXXX",
      "paymentTerms": "Zahlbar innerhalb von 30 Tagen ohne Abzug."
    },
    "lines": [ "... as above ..." ]
  },
  "options": {
    "syntax": "ubl",
    "validationLanguage": "de"
  }
}
curl -X POST https://api.invoicexml.com/v1/create/xrechnung \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d @RE-2026-0917-reviewed.json \
  --output RE-2026-0917-xrechnung.xml

The response is the XRechnung XML, validated against the current KoSIT rule set before delivery. Since pure XML gives an approver nothing to look at, POST /v1/render/xrechnung/to/pdf turns it into a readable PDF for a final glance before submission; the XRechnung API toolkit covers the B2G side end to end.


When the create call says no

The create endpoints never return a non-compliant invoice. When the reviewed data is incomplete or inconsistent, the response is a 400 with a findings list, and the findings are built to go back onto the form. Two cases cover almost everything.

A required field is missing (errorCode 4002). Generation never started. For the XRechnung above with the Leitweg-ID left empty:

{
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errorCode": 4002,
  "valid": false,
  "errors": [
    {
      "rule": "BR-DE-15",
      "message": "Der Käufer benötigt eine Referenz (Leitweg-ID). Für Rechnungen an deutsche öffentliche Auftraggeber ist sie Pflicht.",
      "btCodes": ["BT-10"],
      "fields": ["buyerReference"]
    }
  ],
  "warnings": []
}

The generated XML failed a business rule (errorCode 4001). The request was well-formed, the XML was built, and a Schematron rule rejected it. The classic case in this pipeline is the stale totals from step 2: a reviewer changed a quantity and the totals read from the PDF came along unchanged:

{
  "title": "XML Validation Failed",
  "status": 400,
  "errorCode": 4001,
  "errors": [
    {
      "rule": "BR-CO-15",
      "message": "Der Rechnungsgesamtbetrag mit Umsatzsteuer stimmt nicht. Erwartet: 525.00 + 99.75 = 624.75.",
      "btCodes": ["BT-112"],
      "fields": ["totals.grandTotalAmount"]
    }
  ],
  "warnings": []
}

In both cases fields is the JSON path of the input to fix, in the same model the form is bound to, so the form highlights that input, shows message next to it, and the reviewer corrects and resubmits. The rule identifier is stable across languages, which is what to log and match on; BR-CO-15 and the other rules have reference pages explaining what they check. Resubmitting unchanged data reproduces the same findings, so retries belong to network errors and rate limits, never to validation failures. The complete list of codes is in the error handling documentation.


The one-shot alternative: /v1/transform/to/zugferd

There is an endpoint that does the whole thing in one call. POST /v1/transform/to/zugferd takes the PDF, runs the same AI extraction, generates and validates the XML, and returns the ZUGFeRD; POST /v1/transform/to/xrechnung does the same for XRechnung and takes the Leitweg-ID as a form field, because it knows the PDF will not have it:

curl -X POST https://api.invoicexml.com/v1/transform/to/zugferd \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "[email protected]" \
  -F "language=auto" \
  -F "preserveOriginalPdf=true" \
  --output RE-2026-0917-zugferd.pdf

curl -X POST https://api.invoicexml.com/v1/transform/to/xrechnung \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "[email protected]" \
  -F "buyerReference=04011000-12345-34" \
  --output RE-2026-0917-xrechnung.xml

It is the right tool for a demo, a proof of concept, or a one-off batch that someone will check afterwards. It is not a way around the review, for three reasons:

  • The review still has to happen, and it happens later. The output is AI output under the same Terms. Checking it now means opening the embedded XML (the ZUGFeRD viewer on GetZUGFeRD, or /v1/extract/xml followed by /v1/render/xml/to/pdf) and comparing it against the source PDF, value by value. That is the same work as the form, done without a form.
  • There is no step where a person can add or fix anything. A seller contact the PDF lacks, an IBAN the scan obscured, a reverse-charge line read as standard-rated: the transform call has no input for them apart from the Leitweg-ID. The only fix is a different PDF, or switching to the parse-and-create flow after all.
  • It fails exactly where the pipeline above succeeds. When a required field is missing or the extracted numbers violate a business rule, the call ends with the same 400 findings as the create endpoint, except that nobody is sitting in front of a form to act on them.

So the one-shot endpoint and the three-step pipeline share the AI, the validation and the output. The pipeline adds the one thing that makes the result dependable in production: the person, positioned where the fix is possible, before the invoice goes out.


Get started

The fastest way to evaluate this is your own ugliest PDF. POST it to /v1/parse/json, look at the JSON and the scores, and post the invoice object to /v1/create/zugferd as it is: a clean PDF often round-trips with nothing to fix, and the ones that do not show you exactly what the review form has to handle. Create a free InvoiceXML account → and get 100 credits for free, no credit card required.

Processing is stateless throughout: the PDF and the JSON are handled in memory, purged when the response ships, and never used to train any model.

Related resources:


InvoiceXML is a REST API for European e-invoice compliance covering ZUGFeRD, Factur-X, XRechnung, Peppol UBL, and CII. Stateless processing, GDPR compliant by architecture, and callable from any stack: C#, Node.js, Python, PHP, Java, Go, or anything else with an HTTP client.

Start free today

Ready to automate your invoices?

Validate, convert and embed compliant e-invoices through one API. Start your 30-day free trial. No credit card required.

GDPR Compliant No credit card required Setup in minutes
Peppol UBL
Factur-X
EN 16931
142 / 142 passed
Compliant
PDF/A-3 embedded