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 term | UBL | CII (inside Factur-X) |
| BT-24 Specification identifier | cbc:CustomizationID | ram:GuidelineSpecifiedDocumentContextParameter/ram:ID |
| BT-1 Invoice number | cbc:ID | rsm:ExchangedDocument/ram:ID |
| BT-2 Issue date | cbc:IssueDate as 2026-11-03 | ram:IssueDateTime/udt:DateTimeString with format="102", as 20261103 |
| BT-3 Invoice type code | cbc:InvoiceTypeCode or cbc:CreditNoteTypeCode | rsm:ExchangedDocument/ram:TypeCode |
| BT-31 Seller VAT identifier | cac:PartyTaxScheme/cbc:CompanyID | ram:SpecifiedTaxRegistration/ram:ID with schemeID="VA" |
| BT-30 Seller legal registration | cac:PartyLegalEntity/cbc:CompanyID | ram:SpecifiedLegalOrganization/ram:ID |
| BT-49 Buyer electronic address | cbc:EndpointID with schemeID | ram:URIUniversalCommunication/ram:URIID with schemeID |
| BT-84 Payment account (IBAN) | cac:PayeeFinancialAccount/cbc:ID | ram:PayeePartyCreditorFinancialAccount/ram:IBANID |
| BT-112 Total with VAT | cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount | ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:GrandTotalAmount |
| BG-25 Invoice line | cac:InvoiceLine or cac:CreditNoteLine | ram: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.