Automation MCP Server Blog Pricing

Factur-X and ZUGFeRD to Peppol UBL: Getting PDF Invoices onto the Peppol Network

Your invoicing platform produces Factur-X or ZUGFeRD, and the customer receives over Peppol. This guide automates the step in between: what Peppol requires on top of the invoice data you already have, the one API call that turns a hybrid PDF into a validated Peppol BIS Billing invoice, how to complete the invoices whose Peppol data is missing, and the code for curl, Python, Node.js and the no-code tools.

The handover between two platforms is where invoices get stuck. The producing side is an invoicing tool, ERP or billing engine that issues Factur-X in France or ZUGFeRD in Germany: a PDF with the structured invoice embedded inside. The receiving side is a customer whose accounts payable runs on Peppol, in Belgium since the 2026 mandate, in the Netherlands, the Nordics, Singapore or Australia, or a group that has standardised its intake on one Peppol access point. The PDF cannot travel over Peppol. A Peppol BIS Billing UBL document can.

This guide builds the conversion step. It explains what Peppol requires on top of the data the hybrid file already carries, shows the one API call that turns a Factur-X or ZUGFeRD into a validated Peppol BIS Billing invoice, and how to complete the invoices whose Peppol data the producing platform never wrote, with the code and the no-code setups at the end. No AI is involved anywhere: the data comes from the XML embedded in the PDF.

Why a Factur-X has to become a Peppol invoice

A Factur-X or ZUGFeRD file and a Peppol invoice are built on the same European standard. Both carry an EN 16931 invoice: the same business terms, the same VAT logic, the same totals. They differ in two places:

  • The syntax. The hybrid PDF embeds UN/CEFACT CII. Peppol BIS Billing is UBL. Every business term has to move from CII's nested trade model into UBL's flat component structure; the UBL and CII mapping reference shows where each one lands.
  • The network rules. Peppol is a delivery network, and its specification adds rules that make delivery work: who sends, who receives, under which identifiers, and which process the document belongs to. EN 16931 leaves most of these optional, so a perfectly valid Factur-X often leaves them out.

The syntax is mechanical and the API does it for you. The network data is the part that needs attention, because some of it may not be in the hybrid file at all. The next section lists exactly what.


Same invoice, more rules: what Peppol adds

These are the Peppol requirements that most often separate a valid Factur-X or ZUGFeRD from a valid Peppol invoice, with the rule that enforces each one:

Peppol requirementRuleIn the typical hybrid file
Seller electronic address (BT-34) with its schemePEPPOL-EN16931-R020Often missing, or an email address under scheme EM
Buyer electronic address (BT-49) with its schemePEPPOL-EN16931-R010Often missing: the PDF went by email, so nobody needed it
Numeric EAS scheme codes onlyPEPPOL-EN16931-CL008EM and other alphabetic schemes are common in ZUGFeRD
Buyer reference (BT-10) or purchase order reference (BT-13)PEPPOL-EN16931-R003Usually present when the customer issues order numbers, absent otherwise
Peppol specification identifier (BT-24)Peppol BIS customization IDDeclares the Factur-X or ZUGFeRD profile instead
Peppol business process (BT-23)PEPPOL-EN16931-R001Missing, or a French billing framework code such as A1
Payment instructions when seller and buyer are both in GermanyDE-R-001Present on most ZUGFeRD invoices that request a transfer

The two identifiers are declarations, and the API stamps them for you. The German rule is satisfied by the payment means the ZUGFeRD already carries. The first four are data: values only the producing side knows. When the producing platform writes them into the hybrid file, the conversion below is a single call. When it does not, they live in your customer record (the customer's Peppol ID and the order or reference they asked you to quote) and in your company settings (your own Peppol ID), and the completion step adds them.

Electronic addresses deserve one more sentence, because they are what Peppol routes on. The value is the identifier the participant is registered under on the network, with its EAS code: 0208 and the enterprise number for Belgian companies, 0106 (KvK) or 0190 for Dutch ones, 0009 (SIRET) or 0002 (SIREN) for French ones, 9930 and the VAT number for German ones. The API never invents these: an address that validates but is not registered would route the invoice nowhere. The Peppol integration guide covers identifiers and participant lookup in depth.


One call: /v1/convert/facturx/to/ubl with profile=peppol-bis-3

The conversion endpoints take the hybrid PDF as multipart/form-data, one route per source format, and the profile field names the UBL profile the result must be:

curl -X POST https://api.invoicexml.com/v1/convert/facturx/to/ubl \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "[email protected]" \
  -F "profile=peppol-bis-3" \
  --output FA-2026-0418.xml

curl -X POST https://api.invoicexml.com/v1/convert/zugferd/to/ubl \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "[email protected]" \
  -F "profile=peppol-bis-3" \
  --output RE-2026-0917.xml

What happens between request and response:

  • Read. The CII embedded in the PDF is extracted and read into the invoice model. Every Factur-X and ZUGFeRD profile with invoice lines is accepted.
  • Declare. The invoice is stamped with the Peppol BIS Billing customization ID (BT-24) and the Peppol billing process (BT-23), replacing the Factur-X or ZUGFeRD declarations.
  • Check. The Peppol requirements are checked before anything is built: both electronic addresses, and a buyer or order reference.
  • Build and validate. The UBL is generated, a credit note as a UBL CreditNote document, and validated against the full Peppol rule set, including the national rules that apply to the seller's country.

A 200 is a Peppol BIS Billing invoice with your file name and an .xml extension, ready to hand to your access point; the Peppol integration guide covers that last hop. Producing platforms that already write electronic addresses into their Factur-X or ZUGFeRD, which more of them do as the mandates spread, need nothing else. The same field takes nlcius for the Dutch public sector, ehf for Norway and pint for Peppol International, and /v1/convert/cii/to/ubl takes it too when the producing platform exports plain CII.


When the Peppol data is missing: complete and create

For a Factur-X from a French billing tool, invoicing a Belgian customer, the electronic addresses are typically what is missing. The conversion says so precisely, with errorCode 4002, the Peppol rules and the JSON paths of the fields:

{
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errorCode": 4002,
  "errors": [
    {
      "rule": "PEPPOL-EN16931-R020",
      "message": "Peppol needs the seller electronic address. Build with profile=en16931 if the invoice is not for Peppol.",
      "btCodes": ["BT-34"],
      "fields": ["seller.electronicAddress.identifier"]
    },
    {
      "rule": "PEPPOL-EN16931-R010",
      "message": "Peppol needs the buyer electronic address. Build with profile=en16931 if the invoice is not for Peppol.",
      "btCodes": ["BT-49"],
      "fields": ["buyer.electronicAddress.identifier"]
    }
  ],
  "warnings": [],
  "invoiceData": {
    "invoiceNumber": "FA-2026-0418",
    "specificationId": "urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0",
    "businessProcessType": "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0",
    "purchaseOrderReference": "PO-55120",
    "seller": { "name": "Atelier Lumière SAS", "vatIdentifier": "FR32123456789", "electronicAddress": null },
    "buyer": { "name": "Brouwerij De Linde NV", "vatIdentifier": "BE0123456789", "electronicAddress": null },
    "...": "the rest of the invoice as read from the Factur-X"
  }
}

The response already holds everything needed to finish. invoiceData is the invoice as read from the PDF, with the Peppol identifiers stamped, in the same JSON shape /v1/create/ubl accepts. Completing it means filling exactly the fields the findings name, from your company settings (your own Peppol ID) and the customer record keyed by the buyer's VAT number (theirs):

{
  "invoice": {
    "...": "invoiceData from the 400, unchanged, plus:",
    "seller": { "...": "as read", "electronicAddress": { "identifier": "12345678900014", "schemeId": "0009" } },
    "buyer": { "...": "as read", "electronicAddress": { "identifier": "0123456789", "schemeId": "0208" } }
  },
  "options": { "profile": "peppol-bis-3" }
}
curl -X POST https://api.invoicexml.com/v1/create/ubl \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d @FA-2026-0418-completed.json \
  --output FA-2026-0418.xml

The create call runs the same Peppol checks and rule set and returns the same Peppol BIS Billing invoice the conversion would have. The fix lives in data you keep anyway, so after the first invoice for a customer the lookup fills it every time, and the pipeline never needs a person. A missing buyer or order reference (PEPPOL-EN16931-R003) is completed the same way, with buyerReference from the customer record.

When the request is complete but a value breaks a rule, for example an alphabetic scheme code carried over from the ZUGFeRD (PEPPOL-EN16931-CL008), the answer is errorCode 4001 with the same findings shape. The rule identifier is stable across languages; log and match on it, and send validationLanguage as de or fr when the people maintaining the customer records read those better. The error handling documentation lists every code.


The full flow in Python and Node.js

The whole pipeline is the conversion, plus the completion when the conversion names missing Peppol data. Plain HTTP keeps it short; lookup_peppol_ids stands for your own customer and company records.

Python

import os
import requests

API = "https://api.invoicexml.com/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['INVOICEXML_API_KEY']}"}

def to_peppol(pdf_path: str) -> bytes:
    with open(pdf_path, "rb") as pdf:
        r = requests.post(f"{API}/convert/facturx/to/ubl", headers=HEADERS,
                          files={"file": pdf}, data={"profile": "peppol-bis-3"})
    if r.ok:
        return r.content

    problem = r.json()
    if r.status_code != 400 or problem.get("errorCode") != 4002:
        r.raise_for_status()

    invoice = problem["invoiceData"]
    seller_id, buyer_id = lookup_peppol_ids(invoice)  # from your records
    invoice["seller"]["electronicAddress"] = seller_id
    invoice["buyer"]["electronicAddress"] = buyer_id

    r = requests.post(f"{API}/create/ubl", headers=HEADERS,
                      json={"invoice": invoice, "options": {"profile": "peppol-bis-3"}})
    r.raise_for_status()
    return r.content

Node.js

import { readFile } from "node:fs/promises";

const API = "https://api.invoicexml.com/v1";
const auth = { Authorization: `Bearer ${process.env.INVOICEXML_API_KEY}` };

export async function toPeppol(pdfPath) {
  const form = new FormData();
  form.append("file", new Blob([await readFile(pdfPath)]), pdfPath);
  form.append("profile", "peppol-bis-3");

  let res = await fetch(`${API}/convert/facturx/to/ubl`, { method: "POST", headers: auth, body: form });
  if (res.ok) return res.text();

  const problem = await res.json();
  if (res.status !== 400 || problem.errorCode !== 4002) throw new Error(problem.title);

  const invoice = problem.invoiceData;
  const { seller, buyer } = await lookupPeppolIds(invoice); // from your records
  invoice.seller.electronicAddress = seller;
  invoice.buyer.electronicAddress = buyer;

  res = await fetch(`${API}/create/ubl`, {
    method: "POST",
    headers: { ...auth, "Content-Type": "application/json" },
    body: JSON.stringify({ invoice, options: { profile: "peppol-bis-3" } }),
  });
  if (!res.ok) throw new Error((await res.json()).title);
  return res.text();
}

For ZUGFeRD, swap the route for /convert/zugferd/to/ubl; everything else is identical. The language guides for C#, Java, PHP, Python and Node.js cover creating Peppol UBL in depth.


Running it unattended: n8n and Zapier

When both platforms are SaaS tools, the conversion fits in an automation. The trigger is the producing platform's new invoice (a webhook, a file in a folder, an email attachment), and the steps mirror the code:

  • n8n. One HTTP Request node posts the PDF to /v1/convert/facturx/to/ubl as Form-Data, with the binary file parameter and a text parameter profile set to peppol-bis-3, response format File. Set the node to continue on error, and route a 4002 through a Code node that fills the electronic addresses into invoiceData and a second HTTP Request node that posts it to /v1/create/ubl. The n8n guide shows the credential and retry setup.
  • Zapier. Factur-X by InvoiceXML has an Extract Invoice JSON action, and CII & UBL by InvoiceXML has Create UBL Invoice. Map the extracted fields into the create action, and fill the electronic addresses from a lookup step (a Google Sheet or your CRM keyed by the buyer's VAT number). The Zapier tutorial builds a complete Zap.

Every step is deterministic, so the automation needs no approval step: an invoice that comes out is valid for Peppol, and one that does not comes back with the field to fix. Route the remaining 400s to a channel a person watches, and retry only the 429 and 5xx responses.


The default profile: EN 16931 UBL for imports

Not every UBL consumer is a Peppol access point. ERPs, AP automation tools, data warehouses and archives often import UBL as a data format, and for those the hybrid PDF only needs the syntax change. The profile field defaults to en16931, so leave it out and the UBL declares plain EN 16931 and is validated against the EN 16931 rules, whichever Factur-X or ZUGFeRD profile the source used, with no Peppol data required:

curl -X POST https://api.invoicexml.com/v1/convert/facturx/to/ubl \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "[email protected]" \
  --output FA-2026-0418.xml

When the importing system wants CII instead, /v1/convert/facturx/to/cii and /v1/convert/zugferd/to/cii return the embedded XML as a standalone, validated document.


PDFs without embedded XML

Sooner or later a "Factur-X" turns out to be a plain PDF. The conversion answers it with errorCode 4006, which is the signal to switch routes rather than retry. POST /v1/parse/json reads any PDF invoice, typed or scanned, with AI into the same JSON shape, with confidence scores per area, and from there the completion and create steps are identical.

The difference is the review. AI output must be checked by a person before it is used, as the InvoiceXML Terms of Service require, so that branch of the pipeline puts a review step between the parser and the create call. The PDF to ZUGFeRD guide shows how to build that review form; the same form works for Peppol, with the electronic addresses as two more fields.


Get started

Take one Factur-X or ZUGFeRD your producing platform issued last week and send it to /v1/convert/facturx/to/ubl with profile=peppol-bis-3. Either it comes back as a Peppol invoice, or the 400 shows you exactly which Peppol data your platform's invoices are missing, and the same small lookup covers every invoice after it. Create a free InvoiceXML account → and get 100 credits for free, no credit card required.

Processing is stateless throughout: the PDF, the JSON and the UBL are handled in memory, purged when the response ships, and never stored.

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