Why Peppol UBL is genuinely hard in Python
Three compounding technical realities make UBL particularly awkward in Python despite the language being well-suited to invoice automation.
Two layers of Schematron validation, both requiring XSLT 2.0. Peppol BIS 3.0 validation requires both the EN 16931 base Schematron (200+ rules) and the Peppol BIS 3.0 overlay (50+ additional rules). Both are XSLT 2.0 artefacts. Python's lxml library is excellent but built on libxslt, which is XSLT 1.0 only. Running Peppol Schematron in pure Python is not possible. The workaround is saxonche (Saxonica's Python bindings to Saxon-HE), which works but introduces a heavy native dependency that complicates deployment in serverless environments. Without Schematron, your validation accepts invoices that real Peppol access points reject.
Quarterly Peppol BIS updates. OpenPeppol releases new Peppol BIS Schematron artefacts roughly every quarter. Each release requires downloading the new XSLT files, regression-testing against your invoice samples (because rule semantics occasionally change), and redeploying. Miss a quarterly release and your validation silently accepts invoices that will be rejected at the network layer. The maintenance cost compounds.
National CIUS profile fragmentation. A single UBL implementation needs to handle Peppol BIS 3.0 for Belgium and cross-border, NLCIUS for Dutch public sector, EHF for Norway, PINT variants for Singapore, Australia, Japan, Malaysia, and New Zealand, and XRechnung UBL for German B2G. Each has its own Schematron overlay and slightly different mandatory fields. Implementing this routing in Python is rarely worth the engineering investment for a non-core feature.
Setting up the InvoiceXML client
All examples below use requests, the de facto standard Python HTTP client. Install:
pip install requests
Set your API key as an environment variable. Sign up for a free InvoiceXML account and you will receive 100 credits to get started, no credit card required:
export INVOICEXML_API_KEY="your_key_here"
Create Peppol UBL invoices in Python
Send your invoice as JSON. Totals and the VAT breakdown are auto-calculated from the line items.
import os
import requests
invoice = {
"invoice": {
"invoiceNumber": "INV-2026-001",
"issueDate": "2026-02-15",
"currency": "EUR",
"seller": {
"name": "Belga Solutions BVBA",
"vatIdentifier": "BE0123456789",
"postalAddress": {
"line1": "Rue Royale 12",
"city": "Brussels",
"postCode": "1000",
"country": "BE",
},
},
"buyer": {
"name": "Antwerp Industries NV",
"postalAddress": {
"line1": "Meir 45",
"city": "Antwerp",
"postCode": "2000",
"country": "BE",
},
},
"paymentDetails": {
"paymentAccountIdentifier": "BE68539007547034",
},
"lines": [{
"quantity": 100,
"item": {"name": "Software development services"},
"priceDetails": {"netPrice": 50.00},
"vatInformation": {"rate": 21},
}],
},
}
response = requests.post(
"https://api.invoicexml.com/v1/create/ubl",
headers={
"Authorization": f"Bearer {os.environ['INVOICEXML_API_KEY']}",
"Content-Type": "application/json",
},
json=invoice,
)
with open("invoice-peppol.xml", "wb") as f:
f.write(response.content)
That is the whole request. The API computes totals, builds the VAT breakdown, stamps the Peppol BIS Billing 3.0 CustomizationID, and validates against EN 16931 plus the Peppol overlay before returning the UBL 2.1 XML. /v1/create/ubl emits Peppol BIS 3.0 by default. To pin a different CIUS, set invoice.specificationId to the target CustomizationID URI (for example urn:cen.eu:en16931:2017#compliant#urn:fdc:nen.nl:nlcius:v1.0 for NLCIUS).
Full create example on GitHub →
Validate incoming UBL invoices in Python
When suppliers send you UBL invoices through Peppol or via email attachment, validate them before importing into your ERP or accounting system. The validation endpoint runs the EN 16931 Schematron plus the appropriate Peppol overlay automatically based on the CustomizationID declared in the document.
import os
import requests
def validate_ubl(xml_path: str) -> dict:
with open(xml_path, "rb") as f:
response = requests.post(
"https://api.invoicexml.com/v1/validate/ubl",
headers={
"Authorization": f"Bearer {os.environ['INVOICEXML_API_KEY']}",
},
files={"file": (os.path.basename(xml_path), f, "application/xml")},
)
return response.json()
result = validate_ubl("supplier-invoice.xml")
if result["valid"]:
print(f"Valid {result['data']['conformanceLevel']} invoice")
# proceed with ERP import
else:
print(f"Validation failed with {len(result['errors'])} errors:")
for err in result["errors"]:
print(f" [{err['rule']}] {err['message']}")
if err.get("fields"):
print(f" Fields: {', '.join(err['fields'])}")
# reject or route to review queue
Both valid and invalid invoices return HTTP 200. Branch on result["valid"] rather than the HTTP status code.
The response carries a data block describing which validation layers passed (XSD and Schematron), plus flat errors and warnings arrays. Each finding is self-contained: the human-readable message, the business term codes (btCodes), the JSON paths into the document (fields), and the verbatim validator output (raw).
{
"valid": false,
"detail": "Validation failed with 2 error(s)",
"data": {
"schemaValid": true,
"schematronValid": false,
"conformanceLevel": "UBL 2.1"
},
"errors": [
{
"rule": "BR-CO-14",
"line": null,
"message": "Invoice total VAT amount does not match the sum of VAT breakdown amounts.",
"btCodes": ["BT-110"],
"fields": ["totals.taxTotalAmount"],
"raw": "[BR-CO-14] EN16931: ... (at /...)"
},
{
"rule": "PEPPOL-EN16931-R004",
"line": null,
"message": "Invoice type code should be 380 for a standard invoice.",
"btCodes": ["BT-3"],
"fields": ["typeCode"],
"raw": "[PEPPOL-EN16931-R004] Peppol-BIS: ... (at /...)"
}
],
"warnings": []
}
The raw string identifies the rule's origin (EN 16931 base vs. Peppol BIS overlay) by its prefix. Rules with BR- codes come from the EN 16931 base; rules with PEPPOL- codes come from the Peppol overlay. This matters operationally: an EN 16931 finding means the invoice data is wrong and goes back to the supplier, a Peppol finding means the document declares an incorrect network profile and goes to your access point provider.
Full validation example on GitHub →
Convert between CII and UBL syntax
The two EN 16931 syntax bindings carry the same data in different XML shapes. Converting between them is needed when bridging ZUGFeRD-based and Peppol-based ecosystems in a single workflow:
import os
import requests
def ubl_to_cii(xml_path: str) -> bytes:
with open(xml_path, "rb") as f:
response = requests.post(
"https://api.invoicexml.com/v1/convert/ubl/to/cii",
headers={
"Authorization": f"Bearer {os.environ['INVOICEXML_API_KEY']}",
},
files={"file": (os.path.basename(xml_path), f, "application/xml")},
)
return response.content
def cii_to_ubl(xml_path: str) -> bytes:
with open(xml_path, "rb") as f:
response = requests.post(
"https://api.invoicexml.com/v1/convert/cii/to/ubl",
headers={
"Authorization": f"Bearer {os.environ['INVOICEXML_API_KEY']}",
},
files={"file": (os.path.basename(xml_path), f, "application/xml")},
)
return response.content
This is the bridge between ZUGFeRD / Factur-X (CII-based) and Peppol UBL. A German supplier sending ZUGFeRD to a Belgian customer who can only accept Peppol UBL needs the data converted on the way through. Most Python UBL libraries cannot do this conversion at all.
Render UBL as a human-readable PDF
UBL XML is not designed for human reading. For AP approval workflows where finance teams need to review invoices before payment, render the XML as a clean PDF:
import os
import requests
def render_ubl_as_pdf(xml_path: str) -> bytes:
with open(xml_path, "rb") as f:
response = requests.post(
"https://api.invoicexml.com/v1/render/ubl/to/pdf",
headers={
"Authorization": f"Bearer {os.environ['INVOICEXML_API_KEY']}",
},
files={"file": (os.path.basename(xml_path), f, "application/xml")},
)
return response.content
pdf_data = render_ubl_as_pdf("peppol-invoice.xml")
with open("invoice-preview.pdf", "wb") as f:
f.write(pdf_data)
The rendered PDF shows the detected Peppol profile, all line items, the VAT breakdown, payment details, and IBAN. Everything the approver needs without ever opening the underlying XML.
For ingestion into ERP or AP systems that prefer JSON over XML, extract the full invoice data:
import os
import requests
def extract_ubl_to_json(xml_path: str) -> dict:
with open(xml_path, "rb") as f:
response = requests.post(
"https://api.invoicexml.com/v1/extract/json",
headers={
"Authorization": f"Bearer {os.environ['INVOICEXML_API_KEY']}",
},
files={"file": (os.path.basename(xml_path), f, "application/xml")},
)
return response.json()
data = extract_ubl_to_json("incoming-invoice.xml")
print(f"Invoice {data['invoiceNumber']} from {data['seller']['name']}")
print(f"Total: {data['totals']['grandTotalAmount']} {data['currency']}")
print(f"Lines: {len(data['lines'])}")
The structured JSON output makes it straightforward to insert into a relational database via SQLAlchemy or the Django ORM, or pass to downstream services without XML parsing in your Python code.
Django integration: service with Celery worker
For Django applications, wrap the Peppol UBL operations in a service class and combine with Celery for async generation of large invoice batches:
# invoicing/services/peppol.py
import requests
from django.conf import settings
class PeppolUblService:
def __init__(self, api_key: str = None):
self.api_key = api_key or settings.INVOICEXML_API_KEY
self.base_url = "https://api.invoicexml.com"
self.session = requests.Session()
self.session.headers["Authorization"] = f"Bearer {self.api_key}"
def generate(self, invoice_data: dict) -> bytes:
response = self.session.post(
f"{self.base_url}/v1/create/ubl",
headers={"Content-Type": "application/json"},
json={"invoice": invoice_data},
timeout=30,
)
response.raise_for_status()
return response.content
def validate(self, ubl_xml: bytes) -> dict:
response = self.session.post(
f"{self.base_url}/v1/validate/ubl",
files={"file": ("invoice.xml", ubl_xml, "application/xml")},
timeout=30,
)
return response.json()
Async generation via Celery for bulk processing:
# invoicing/tasks.py
import requests
from celery import shared_task
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from .models import Invoice
from .services.peppol import PeppolUblService
@shared_task(
autoretry_for=(requests.exceptions.RequestException,),
retry_backoff=True,
max_retries=3,
)
def generate_peppol_ubl(invoice_id: int):
invoice = Invoice.objects.get(pk=invoice_id)
service = PeppolUblService()
ubl_xml = service.generate(invoice.to_peppol_dict())
validation = service.validate(ubl_xml)
if not validation["valid"]:
errors = [f"[{e['rule']}] {e['message']}" for e in validation["errors"]]
raise ValueError(f"UBL validation failed: {chr(10).join(errors)}")
path = f"peppol/{invoice.number}.xml"
default_storage.save(path, ContentFile(ubl_xml))
invoice.peppol_ubl_path = path
invoice.peppol_status = "ready"
invoice.save()
# Submit to Peppol access point as a follow-up task
submit_to_peppol_access_point.delay(invoice_id)
For bulk monthly invoicing runs (hundreds or thousands of invoices), this pattern scales by enqueuing one Celery task per invoice and letting workers process them in parallel.
FastAPI integration: async batch endpoint
For FastAPI applications handling concurrent UBL generation requests, use httpx.AsyncClient to maximise throughput:
# main.py
import os
import asyncio
import httpx
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
class InvoiceLine(BaseModel):
quantity: float
item: dict
priceDetails: dict
vatInformation: dict
class InvoiceData(BaseModel):
invoiceNumber: str
issueDate: str
currency: str
seller: dict
buyer: dict
paymentDetails: dict
lines: list[InvoiceLine]
class BulkRequest(BaseModel):
invoices: list[InvoiceData]
@app.post("/peppol/ubl/bulk")
async def bulk_generate_peppol(request: BulkRequest):
async with httpx.AsyncClient(
base_url="https://api.invoicexml.com",
headers={
"Authorization": f"Bearer {os.environ['INVOICEXML_API_KEY']}",
},
timeout=30,
) as client:
tasks = [generate_one_ubl(client, invoice) for invoice in request.invoices]
results = await asyncio.gather(*tasks, return_exceptions=True)
return [
{"invoice": inv.invoiceNumber, "status": "error", "error": str(result)}
if isinstance(result, Exception)
else {"invoice": inv.invoiceNumber, "status": "ok", "xml_length": len(result)}
for inv, result in zip(request.invoices, results)
]
async def generate_one_ubl(client: httpx.AsyncClient, invoice: InvoiceData) -> bytes:
response = await client.post(
"/v1/create/ubl",
headers={"Content-Type": "application/json"},
json={"invoice": invoice.model_dump()},
)
response.raise_for_status()
return response.content
asyncio.gather processes multiple invoices concurrently rather than sequentially, dramatically reducing total time for batch operations. A batch of 100 invoices typically completes in roughly the time of a single sequential request rather than 100x that.
Production validation pattern
Generate the invoice, validate it, then only proceed to network transmission. This catches data errors before reaching the Peppol access point:
def create_validated_peppol_invoice(invoice_data: dict) -> dict:
service = PeppolUblService()
# Step 1: generate
ubl_xml = service.generate(invoice_data)
# Step 2: validate
validation = service.validate(ubl_xml)
if not validation["valid"]:
errors = [
f"[{e['rule']}] {e['message']}"
for e in validation["errors"]
]
raise ValueError(
"Generated UBL failed validation:\n" + "\n".join(errors)
)
return {
"xml": ubl_xml,
"conformance_level": validation["data"]["conformanceLevel"],
"warnings": validation["warnings"],
}
The validation cost is minimal and the integration reliability improvement is significant. This is what differentiates a production-grade Peppol implementation from a prototype.
Why use a REST API instead of building UBL in Python
The Python UBL ecosystem is genuinely underdeveloped. A few libraries exist for basic XML generation but none implement the full Peppol BIS 3.0 compliance pipeline.
What InvoiceXML handles that Python libraries do not:
EN 16931 Schematron validation for all 200+ base business rules. Python's lxml is XSLT 1.0 only. Running EN 16931 Schematron in pure Python is not possible without heavy native dependencies like Saxon-HE via saxonche, which complicates deployment in containerised and serverless environments.
Peppol BIS 3.0 overlay validation. The Peppol-specific rules update quarterly from OpenPeppol. Updates are deployed before each new specification's effective date with no integration change required on your side. Your Python application keeps generating valid invoices through every standard revision.
National CIUS detection on validation. The validate endpoint reads the CustomizationID declared in the incoming UBL and applies the matching overlay automatically: Peppol BIS 3.0, NLCIUS, EHF, PINT variants, or XRechnung UBL. No routing logic needed on your side.
Bidirectional CII and UBL syntax conversion. Lossless, validated, and essential when your Python application bridges ZUGFeRD-based and Peppol-based ecosystems. No Python UBL library handles this conversion.
Stateless processing. Documents are processed in memory and purged immediately on response delivery. Nothing is written to disk, nothing is logged, no invoice data is used for model training. GDPR and HIPAA compliant by architecture.
Complete UBL endpoint reference for Python
| Operation | Endpoint | Input | Output |
| Create UBL | POST /v1/create/ubl | JSON invoice | UBL 2.1 XML |
| PDF to UBL (AI) | POST /v1/transform/to/ubl | PDF, image, CII XML | UBL 2.1 XML |
| Validate UBL | POST /v1/validate/ubl | UBL XML | Validation JSON |
| UBL to CII | POST /v1/convert/ubl/to/cii | UBL XML | CII XML |
| CII to UBL | POST /v1/convert/cii/to/ubl | CII XML | UBL XML |
| Extract as JSON | POST /v1/extract/json | UBL XML | Structured JSON |
| Render as PDF | POST /v1/render/ubl/to/pdf | UBL XML | PDF binary |
Full OpenAPI 3.1 schema: api.invoicexml.com/v1/openapi. Interactive API explorer: api.invoicexml.com/v1/scalar.
Get started
Every Python snippet in this guide is available as a runnable example on GitHub. Clone the repo, set INVOICEXML_API_KEY, and you have a working integration in under a minute:
InvoiceXML/facturx-api-examples/python →
Create a free InvoiceXML account: includes 100 credits to get started, no credit card required.
Related resources:
InvoiceXML is a REST API for Peppol-ready e-invoice compliance covering UBL, CII, ZUGFeRD, Factur-X, and XRechnung. Stateless processing, GDPR compliant by architecture, and integration support from any Python application: Django, FastAPI, Flask, Odoo, Celery workers, or asyncio-based services.