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 requirement | Rule | In the typical hybrid file |
| Seller electronic address (BT-34) with its scheme | PEPPOL-EN16931-R020 | Often missing, or an email address under scheme EM |
| Buyer electronic address (BT-49) with its scheme | PEPPOL-EN16931-R010 | Often missing: the PDF went by email, so nobody needed it |
| Numeric EAS scheme codes only | PEPPOL-EN16931-CL008 | EM and other alphabetic schemes are common in ZUGFeRD |
| Buyer reference (BT-10) or purchase order reference (BT-13) | PEPPOL-EN16931-R003 | Usually present when the customer issues order numbers, absent otherwise |
| Peppol specification identifier (BT-24) | Peppol BIS customization ID | Declares the Factur-X or ZUGFeRD profile instead |
| Peppol business process (BT-23) | PEPPOL-EN16931-R001 | Missing, or a French billing framework code such as A1 |
| Payment instructions when seller and buyer are both in Germany | DE-R-001 | Present 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.