Automation MCP Server Blog Pricing
Integration Factur-X Format

UBL to Factur-X: Converting Peppol Invoices with One API Call

One platform produces the invoice as Peppol UBL, the platform or customer that receives it wants Factur-X. This guide automates the step in between: what changes between the two formats, the one call that turns a UBL file into a validated Factur-X PDF/A-3, the two-call variant that keeps your own PDF layout, the BR-FR check for invoices bound for the French network, and the same call from six stacks and the no-code tools.

Integrations break at the handover more often than anywhere else. One platform produces the invoice: an ERP, a billing engine, a Peppol-first invoicing tool, a marketplace. Another platform delivers it: a French Plateforme de Dématérialisation Partenaire (PDP), a customer's procurement portal, an AP automation tool, an email inbox. When the producing platform speaks Peppol UBL and the delivering side expects Factur-X, someone has to convert in between, every invoice, every day, without anybody touching the files.

This guide builds that step. It explains what actually changes between a UBL invoice and a Factur-X PDF/A-3, shows the one API call that does the whole conversion with validation, the two-call variant for teams that want to keep the PDF their platform already renders, and the extra check for invoices bound for the French network. Everything is plain HTTP with curl first, followed by the same call in Python, Node.js, C#, PHP and Java, and the no-code tools.

The gap between the producing and the delivering platform

UBL is the language of the Peppol network and of a large share of invoicing software built for Belgium, the Netherlands, the Nordics and international billing. Factur-X is the language of French invoicing: a PDF that people read, with the structured CII invoice embedded inside for the software. Both are EN 16931 invoices, and the French mandate accepts UBL, CII and Factur-X as its core formats. The gap appears one level down, in what an individual platform or trading partner has set up to receive:

  • A PDP or an AP platform whose intake for your flow is Factur-X. The onboarding defines one format for your connection, and the producing platform cannot be told to emit it.
  • A French customer who wants a readable PDF with the data inside. A UBL file is pure XML; a Factur-X opens in any PDF viewer and still books automatically on the customer's side.
  • Email delivery to smaller customers. A PDF attachment that carries its own structured data is the one format every recipient can open and every accounting tool can read.
  • Archiving. A PDF/A-3 with the XML embedded keeps the human view and the legal data in one long-term archival file.

In all four cases the invoice content is already right. What the delivering side needs is the same invoice in another container, and that conversion is mechanical: it belongs in the pipeline, not on a person's desk.


What actually changes between UBL and Factur-X

A Factur-X file is a PDF/A-3 with an XML attachment named factur-x.xml, and that XML is always UN/CEFACT Cross Industry Invoice (CII), never UBL. Turning UBL into Factur-X is therefore four jobs, not one:

  • Syntax mapping. Every EN 16931 business term moves from UBL's flat cac:/cbc: component structure into CII's nested trade model. The semantics are identical; the element names, nesting and some value formats are not.
  • Profile declaration. The Peppol BIS Billing customization identifier in BT-24 becomes the Factur-X EN 16931 profile identifier, and the CII is validated against that profile's rules.
  • A visual layer. UBL carries no PDF. A Factur-X needs one, rendered from the same data so that what people read and what software books cannot disagree.
  • The PDF/A-3 container. The PDF is made archival, the XML is attached with the right relationship, and the XMP metadata declares the Factur-X document type, file name, version and conformance level. Receiving software checks this metadata before it ever reads the XML.

Each of the four has its own failure mode: a term mapped into the wrong CII element, a profile identifier the receiver does not recognise, a PDF that is not PDF/A-3 conformant, metadata that names the wrong conformance level. The API below does all four in one request and validates the result before it answers, so a 200 is a file the receiver can process.


Where the data moves inside the Factur-X

For readers who have to debug a conversion or explain one to a receiver, this is how a handful of the most visible business terms move from the UBL into the CII that the Factur-X embeds. The UBL and CII mapping reference covers every key term, from the parties to the lines, and the EN 16931 explainer walks through the model itself:

Business termUBLCII (inside Factur-X)
BT-24 Specification identifiercbc:CustomizationIDram:GuidelineSpecifiedDocumentContextParameter/ram:ID
BT-1 Invoice numbercbc:IDrsm:ExchangedDocument/ram:ID
BT-2 Issue datecbc:IssueDate as 2026-11-03ram:IssueDateTime/udt:DateTimeString with format="102", as 20261103
BT-3 Invoice type codecbc:InvoiceTypeCode or cbc:CreditNoteTypeCodersm:ExchangedDocument/ram:TypeCode
BT-31 Seller VAT identifiercac:PartyTaxScheme/cbc:CompanyIDram:SpecifiedTaxRegistration/ram:ID with schemeID="VA"
BT-30 Seller legal registrationcac:PartyLegalEntity/cbc:CompanyIDram:SpecifiedLegalOrganization/ram:ID
BT-49 Buyer electronic addresscbc:EndpointID with schemeIDram:URIUniversalCommunication/ram:URIID with schemeID
BT-84 Payment account (IBAN)cac:PayeeFinancialAccount/cbc:IDram:PayeePartyCreditorFinancialAccount/ram:IBANID
BT-112 Total with VATcac:LegalMonetaryTotal/cbc:TaxInclusiveAmountram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:GrandTotalAmount
BG-25 Invoice linecac:InvoiceLine or cac:CreditNoteLineram:IncludedSupplyChainTradeLineItem

Two differences in that table catch most hand-written mappings. Dates change format, from ISO dates to the compact 102 format with an explicit format attribute. And UBL has two document types, Invoice and CreditNote, with their own line elements, while CII has one root for both and tells them apart only by the type code. A credit note from the producing platform therefore becomes a CII document with type code 381, with its lines under the same element an invoice uses. The UBL and CII technical overviews cover both syntaxes in depth.


The one-call route: /v1/convert/ubl/to/facturx

The conversion endpoint takes the UBL file as multipart/form-data and answers with the finished Factur-X PDF/A-3. There is no AI involved anywhere: it reads the XML, maps it, validates it, renders it and packages it, deterministically, so the same UBL file always produces the same invoice.

curl -X POST https://api.invoicexml.com/v1/convert/ubl/to/facturx \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "[email protected]" \
  -F "language=fr" \
  -F "validationLanguage=fr" \
  --output INV-2026-0311.pdf

The input is a UBL Invoice or CreditNote, typically a Peppol BIS Billing document straight out of the producing platform. The beginning of such a file, abbreviated, looks like this:

<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" ...>
  <cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0</cbc:CustomizationID>
  <cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>
  <cbc:ID>INV-2026-0311</cbc:ID>
  <cbc:IssueDate>2026-11-03</cbc:IssueDate>
  <cbc:DueDate>2026-12-03</cbc:DueDate>
  <cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
  <cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
  <cbc:BuyerReference>PO-88213</cbc:BuyerReference>
  <cac:AccountingSupplierParty>
    <cac:Party>
      <cbc:EndpointID schemeID="0106">12345678</cbc:EndpointID>
      ...
  <cac:AccountingCustomerParty>
    <cac:Party>
      <cbc:EndpointID schemeID="0002">123456789</cbc:EndpointID>
      ...

What happens between request and response:

  • Syntax check. The root element is inspected before anything is parsed. A CII file sent to the UBL route is answered with a 400 that names the matching /v1/convert/cii/to/... route, and XML that is no invoice at all with errorCode 4008.
  • Mapping and profile. Every business term is carried into CII, and BT-24 is written as the Factur-X EN 16931 profile.
  • Default texts. Where the UBL carries no payment terms text (BT-20) and money is still due, the API adds one in the language you chose, so the PDF and the embedded XML print the same sentence. Texts the UBL already carries are kept exactly as they are.
  • Validation. The CII is validated against the Factur-X EN 16931 schema and business rules. If it fails, nothing is rendered and the response is a 400 with the findings (more on that below).
  • Rendering and packaging. A clean invoice PDF is rendered from the same data, with labels and dates in language (en, de or fr), converted to PDF/A-3, and the CII is embedded as factur-x.xml with Factur-X XMP metadata.

The response keeps your file name and swaps the extension, so INV-2026-0311.xml comes back as INV-2026-0311.pdf, ready to hand to the delivering platform. validationLanguage words any rejection for whoever reads the logs. The same route family has siblings for the neighbouring cases: /v1/convert/ubl/to/zugferd when the receiver is German and asks for ZUGFeRD, and /v1/convert/cii/to/facturx when the producing platform already emits CII.


Keeping your own PDF: convert to CII, then embed

Many producing platforms render a branded PDF next to the UBL, and the customer is used to that layout. In that case, skip the rendering and put the data into the PDF you already have, in two calls:

# 1. UBL to CII: the same mapping, XML out
curl -X POST https://api.invoicexml.com/v1/convert/ubl/to/cii \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "[email protected]" \
  --output INV-2026-0311-cii.xml

# 2. Your PDF plus that CII: a Factur-X PDF/A-3 with your layout
curl -X POST https://api.invoicexml.com/v1/embed/facturx \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "[email protected]" \
  -F "[email protected]" \
  --output INV-2026-0311.pdf

The first call returns the CII as a standalone file, carrying over exactly what the UBL declares. The second validates that XML with the full Factur-X rule set, promotes your PDF to PDF/A-3 in place, embeds the XML and writes the XMP metadata. Your page layout, fonts and branding stay untouched. One thing is now your responsibility rather than the API's: the visible page and the embedded data come from two outputs of the producing platform, so they should be the same invoice. The embed guide covers the PDF/A-3 side, fonts and metadata in depth.


Invoices for the French network: the BR-FR check

Domestic French B2B invoices travel through the PDPs, and the PDPs apply the French rule set of the mandate, BR-FR, on top of EN 16931. A UBL file that is a perfect Peppol BIS invoice can still lack what France requires, because the Peppol rules were written for a different network. Typical findings are the SIREN as the seller's legal registration (BR-FR-10, BT-30), the three mandatory Code de commerce mentions as coded notes for late-payment penalties, the recovery-cost indemnity and the early-payment discount clause (BR-FR-05), and VAT rates from the authorised French list (BR-FR-16).

The rules=br-fr form field adds that rule set to a check, and it fits the conversion at three points:

  • Before converting: POST /v1/validate/ubl with rules=br-fr checks the source file as the producing platform emitted it. This is the best place for the check, because every finding is a value the producing platform has to supply, and the report tells you exactly which.
  • After converting: POST /v1/validate/facturx with rules=br-fr checks the finished PDF/A-3 as the PDP will see it.
  • While embedding: on the two-call route, POST /v1/embed/facturx with rules=br-fr runs the French rules before it embeds, so a file that leaves the step has passed both rule sets.
curl -X POST https://api.invoicexml.com/v1/validate/ubl \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "[email protected]" \
  -F "rules=br-fr"

The response is the usual validation report: valid, and an errors array whose entries name the rule, the business terms in btCodes and a readable message. A file that passes here converts into a Factur-X that passes the same French rules, because the mapping carries every term over. The French mandate checklist puts the rule set in the context of the whole mandate.


The same call from your stack

The conversion is one multipart request, so any HTTP client does it. The official Factur-X client libraries wrap it in one method each, with the retries, timeouts and typed errors already in place. The examples write the result to disk; in a pipeline, pass the bytes on to the delivering platform instead.

Python

# pip install invoicexml-facturx
from pathlib import Path
from invoicexml_facturx import FacturXClient

client = FacturXClient()  # reads INVOICEXML_API_KEY
pdf = client.convert_ubl_to_facturx("INV-2026-0311.xml", language="fr")
Path("INV-2026-0311.pdf").write_bytes(pdf)

Node.js

// npm install @invoicexml/facturx
import { readFile, writeFile } from "node:fs/promises";
import { FacturXClient } from "@invoicexml/facturx";

const client = new FacturXClient({ apiKey: process.env.INVOICEXML_API_KEY });
const pdf = await client.convertUblToFacturX(await readFile("INV-2026-0311.xml"), { language: "FR" });
await writeFile("INV-2026-0311.pdf", pdf);

C# / .NET

// dotnet add package InvoiceXml.FacturX
using InvoiceXml.FacturX;

using var client = new FacturXClient(Environment.GetEnvironmentVariable("INVOICEXML_API_KEY")!);
var pdf = await client.ConvertUblToFacturXAsync(
    File.ReadAllBytes("INV-2026-0311.xml"),
    new ConvertToPdfOptions { Language = PdfLanguage.Fr });
await pdf.SaveAsync("INV-2026-0311.pdf");

PHP

// composer require invoicexml/facturx
use InvoiceXml\FacturX\Client;

$client = new Client(getenv('INVOICEXML_API_KEY'));
$pdf = $client->convertUblToFacturx('INV-2026-0311.xml', 'fr');
$pdf->save('INV-2026-0311.pdf');

Java

// Maven: com.invoicexml:invoicexml-facturx
var client = FacturXClient.create(System.getenv("INVOICEXML_API_KEY"));
byte[] pdf = client.convertUblToFacturX(Path.of("INV-2026-0311.xml"));
Files.write(Path.of("INV-2026-0311.pdf"), pdf);

The language guides go further for each stack, from creating Factur-X invoices from your own data to validation and embedding: Python, Node.js, C#, PHP, Java and Ruby.


Running it unattended: n8n, Zapier, Make

When the two platforms are SaaS tools, the conversion often lives in an automation rather than in code. The shape is the same everywhere: the producing platform fires a trigger with the UBL file (a webhook, a new file in a folder, an email attachment), one step converts it, and the next step hands the PDF to the delivering platform.

  • Zapier. The Factur-X by InvoiceXML app has a Convert UBL XML to Factur-X action: map the file from the trigger, and the output is the Factur-X PDF for the next step. The Zapier tutorial builds a complete Zap end to end.
  • n8n. One HTTP Request node: POST to /v1/convert/ubl/to/facturx, Body Content Type Form-Data, a parameter file of type n8n Binary File, and the response format set to File. The n8n guide shows the credential setup and the retry settings for month-end volumes.
  • Make, Power Automate and the rest. Any HTTP module that sends multipart form data works with the same three settings: the URL, the bearer header, and the file part named file.

Because the conversion is deterministic, the automation needs no approval step: a file that converts is a valid Factur-X, and a file that does not comes back with a reason. Route the 400s to a channel a person watches, and retry only the 429 and 5xx responses.


When the conversion says no

The endpoint never returns a Factur-X that fails its rules. When the UBL carries something the Factur-X EN 16931 profile does not accept, typically totals that do not add up or a code outside its code list, the answer is a 400 with errorCode 4001 and the findings, abbreviated here:

{
  "title": "XML validation failed",
  "status": 400,
  "errorCode": 4001,
  "errors": [
    {
      "rule": "BR-CO-10",
      "message": "La somme des montants nets des lignes ne correspond pas au total des lignes de la facture.",
      "btCodes": ["BT-106"]
    }
  ],
  "warnings": [],
  "invoiceData": { "invoiceNumber": "INV-2026-0311", "...": "the invoice as read from the UBL" }
}

Three things make that answer useful in a pipeline. The rule identifier is stable across languages, so log and match on it, and let message follow validationLanguage for the person who reads it. invoiceData is the invoice as read from the UBL, in the same JSON shape the /v1/create/facturx endpoint accepts: when the source cannot be fixed at the producing platform quickly, correct that JSON and create the Factur-X from it directly. And the finding points at the producing platform: the conversion carries every value over, so the fix is a value in the source, and resubmitting the same file reproduces the same findings. Retries belong to network errors and rate limits only. The full list of codes is in the error handling documentation, and every rule has a reference page, such as BR-CO-15.


Get started

Take one UBL file your producing platform emitted last week and send it through /v1/convert/ubl/to/facturx; then run the result through /v1/validate/facturx to see the report a receiver would see. Create a free InvoiceXML account → and get 100 credits for free, no credit card required.

Processing is stateless throughout: the UBL, the PDF and the CII 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