Why Factur-X is harder in Python than it looks
Three technical realities make this format awkward in Python specifically, despite the language being well-suited to invoice automation work.
PDF/A-3b conformance is fragile. Factur-X is not just CII XML and not just a PDF. It is CII XML embedded inside a PDF/A-3b container with specific requirements around embedded fonts, ICC colour profiles, XMP metadata declaring the Factur-X version and profile, and the AFRelationship="Alternative" declaration on the XML attachment. Python's PDF libraries (reportlab, pypdf, pikepdf, fpdf) handle PDF generation well but PDF/A-3b conformance requires careful manipulation across XMP namespaces, font subsetting, and ICC profile injection. The akretion/factur-x library handles this when given an existing PDF/A-3b input, but creating compliant PDF/A-3b from scratch in pure Python is non-trivial. Files often pass visual inspection in PDF viewers but fail validation at Plateformes Agréées.
XSLT 2.0 is awkward in Python. EN 16931 Schematron validation requires XSLT 2.0. Python's lxml library is excellent but uses libxslt, which is XSLT 1.0 only. The official Schematron artefacts cannot be executed in pure Python. The workaround is saxonpy or saxonche (Saxonica's official Python bindings to Saxon-HE), which work but require a heavy native dependency that complicates deployment, especially in serverless environments (AWS Lambda, Google Cloud Functions) where binary dependencies are limited.
Version maintenance is real work. ZUGFeRD 2.4 (Factur-X 1.08) shipped with new mandatory fields and deprecated NIL elements. The French September 2026 mandate brings additional CIUS requirements through the certified Plateformes Agréées. Tracking updates across multiple Schematron families and integrating them into a Python project is ongoing work that compounds quietly over time.
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 Factur-X 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-2025-001",
"issueDate": "2025-09-01",
"currency": "EUR",
"seller": {
"name": "Dupont Conseil SARL",
"vatIdentifier": "FR32123456789",
"postalAddress": {
"line1": "12 Rue de Rivoli",
"city": "Paris",
"postCode": "75001",
"country": "FR",
},
},
"buyer": {
"name": "Martin Industries SAS",
"postalAddress": {
"line1": "45 Avenue des Champs-Elysees",
"city": "Paris",
"postCode": "75008",
"country": "FR",
},
},
"paymentDetails": {
"paymentAccountIdentifier": "FR7630006000011234567890189",
},
"lines": [{
"quantity": 10,
"item": {"name": "Conseil en transformation digitale"},
"priceDetails": {"netPrice": 250.00},
"vatInformation": {"rate": 20},
}],
},
}
response = requests.post(
"https://api.invoicexml.com/v1/create/facturx",
headers={
"Authorization": f"Bearer {os.environ['INVOICEXML_API_KEY']}",
"Content-Type": "application/json",
},
json=invoice,
)
with open("invoice-facturx.pdf", "wb") as f:
f.write(response.content)
That is the whole request. The API computes totals and the VAT breakdown, embeds the factur-x.xml attachment, applies the PDF/A-3b conformance layer with all required XMP metadata, and validates against EN 16931 Schematron before returning the binary PDF.
Full create example on GitHub →
Validate Factur-X invoices in Python
Before sending an invoice to a customer or submitting it to a French Plateforme Agréée, validate that it passes all EN 16931 Schematron rules and PDF/A-3b conformance checks.
import os
import requests
def validate_facturx(pdf_path: str) -> dict:
with open(pdf_path, "rb") as f:
response = requests.post(
"https://api.invoicexml.com/v1/validate/facturx",
headers={
"Authorization": f"Bearer {os.environ['INVOICEXML_API_KEY']}",
},
files={"file": (os.path.basename(pdf_path), f, "application/pdf")},
)
return response.json()
result = validate_facturx("invoice-facturx.pdf")
if result["valid"]:
print(f"Valid Factur-X invoice ({result['data']['conformanceLevel']})")
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'])}")
Both valid and invalid invoices return HTTP 200. Branch on result["valid"] rather than the HTTP status code.
errors is a flat array of findings. Each entry carries a plain-language message, the business term codes (btCodes), the JSON paths into the document (fields), and the verbatim validator output (raw). warnings uses the same shape for non-blocking issues:
{
"valid": false,
"detail": "Validation failed with 1 error(s)",
"data": {
"schemaValid": true,
"schematronValid": false,
"conformanceLevel": "EN16931"
},
"errors": [
{
"rule": "BR-CO-14",
"line": null,
"message": "The invoice total VAT amount does not match the sum of VAT breakdown amounts.",
"btCodes": ["BT-110"],
"fields": ["totals.taxTotalAmount"],
"raw": "[BR-CO-14] EN16931: Invoice total VAT amount = sum of VAT category tax amounts. (at /...)"
}
],
"warnings": []
}
Use this in CI/CD pipelines via pytest or unittest to validate generated output on every build. The validation API is fast enough (typically under a second) to include in pre-commit hooks or automated test suites.
Full validation example on GitHub →
Convert PDF invoices to Factur-X
If you receive PDF invoices from suppliers and need to ingest them as Factur-X for archival or further processing, use the transform endpoint. The AI pipeline extracts every EN 16931 field from the source PDF, including scanned and photographed documents.
import os
import requests
def pdf_to_facturx(pdf_path: str) -> bytes:
with open(pdf_path, "rb") as f:
response = requests.post(
"https://api.invoicexml.com/v1/transform/to/facturx",
headers={
"Authorization": f"Bearer {os.environ['INVOICEXML_API_KEY']}",
},
files={"file": (os.path.basename(pdf_path), f, "application/pdf")},
data={"language": "en"},
)
return response.content
facturx_pdf = pdf_to_facturx("supplier-invoice.pdf")
with open("supplier-facturx.pdf", "wb") as f:
f.write(facturx_pdf)
The optional language parameter (default en) localises the rendered PDF layer (labels, dates, currency formatting). The embedded CII XML is always emitted under the EN 16931 (Comfort) profile. The endpoint also accepts JPEG, PNG, TIFF, WEBP, HEIC, DOCX, and XLSX inputs in addition to PDF.
Full transform example on GitHub →
For incoming Factur-X invoices that need to be imported into your accounting system, ERP, or AP database, the extract endpoint returns either the raw CII XML or a fully parsed JSON representation:
import os
import requests
def extract_facturx(pdf_path: str) -> dict:
with open(pdf_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(pdf_path), f, "application/pdf")},
)
return response.json()
data = extract_facturx("incoming-invoice.pdf")
print(f"Invoice {data['invoiceNumber']} from {data['seller']['name']}")
print(f"Total: {data['totals']['grandTotalAmount']} {data['currency']}")
print(f"Lines: {len(data['lines'])}")
To extract just the embedded CII XML rather than parsed JSON, use /v1/extract/xml instead. The response is the raw XML as application/xml, which you can parse with lxml.etree for further processing.
Full extract example on GitHub →
Render Factur-X as a human-readable PDF
The XML data inside a Factur-X file is not designed for human reading. For standalone CII XML files (extracted from a Factur-X PDF or transmitted via EDI), render as a clean PDF for review workflows:
import os
import requests
def render_facturx_pdf(xml_path: str) -> bytes:
with open(xml_path, "rb") as f:
response = requests.post(
"https://api.invoicexml.com/v1/render/cii/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_facturx_pdf("invoice-cii.xml")
with open("invoice-preview.pdf", "wb") as f:
f.write(pdf_data)
The rendered PDF is for visual review only. It has no legal standing and should not be submitted as an invoice. The original Factur-X file remains the authoritative document.
Django integration: service class with dependency injection
For Django applications, wrap the InvoiceXML client in a service class. Add it to a dedicated services module:
# invoicing/services/facturx.py
import requests
from django.conf import settings
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
class FacturXService:
def __init__(self, api_key: str = None):
self.api_key = api_key or settings.INVOICEXML_API_KEY
self.base_url = "https://api.invoicexml.com"
def _headers(self) -> dict:
return {"Authorization": f"Bearer {self.api_key}"}
def generate(self, invoice_data: dict) -> bytes:
response = requests.post(
f"{self.base_url}/v1/create/facturx",
headers={**self._headers(), "Content-Type": "application/json"},
json={"invoice": invoice_data},
timeout=30,
)
response.raise_for_status()
return response.content
def validate(self, pdf_bytes: bytes) -> dict:
response = requests.post(
f"{self.base_url}/v1/validate/facturx",
headers=self._headers(),
files={"file": ("invoice.pdf", pdf_bytes, "application/pdf")},
timeout=30,
)
return response.json()
def generate_and_store(self, invoice_data: dict, invoice_number: str) -> str:
pdf = self.generate(invoice_data)
validation = self.validate(pdf)
if not validation["valid"]:
errors = "\n".join(
f"[{e['rule']}] {e['message']}"
for e in validation["errors"]
)
raise ValueError(f"Generated invoice failed validation:\n{errors}")
path = f"invoices/facturx/{invoice_number}.pdf"
default_storage.save(path, ContentFile(pdf))
return path
Usage in a Django view or DRF viewset:
# invoicing/views.py
from rest_framework.decorators import api_view
from rest_framework.response import Response
from django.core.files.storage import default_storage
from .models import Invoice
from .services.facturx import FacturXService
@api_view(["POST"])
def generate_invoice(request, pk):
invoice = Invoice.objects.get(pk=pk)
service = FacturXService()
path = service.generate_and_store(
invoice_data=invoice.to_facturx_dict(),
invoice_number=invoice.number,
)
return Response({"path": path, "url": default_storage.url(path)})
FastAPI integration: async endpoint with httpx
For FastAPI applications, use httpx for async HTTP calls so the framework can handle other requests while waiting on the InvoiceXML response:
# main.py
import os
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
app = FastAPI()
class Address(BaseModel):
line1: str
city: str
postCode: str
country: str
class Party(BaseModel):
name: str
vatIdentifier: str | None = None
postalAddress: Address
class PaymentDetails(BaseModel):
paymentAccountIdentifier: str
class Item(BaseModel):
name: str
class PriceDetails(BaseModel):
netPrice: float
class VatInformation(BaseModel):
rate: float
class InvoiceLine(BaseModel):
quantity: float
item: Item
priceDetails: PriceDetails
vatInformation: VatInformation
class InvoiceData(BaseModel):
invoiceNumber: str
issueDate: str
currency: str
seller: Party
buyer: Party
paymentDetails: PaymentDetails
lines: list[InvoiceLine]
@app.post("/invoices/facturx")
async def create_facturx(invoice: InvoiceData):
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
"https://api.invoicexml.com/v1/create/facturx",
headers={
"Authorization": f"Bearer {os.environ['INVOICEXML_API_KEY']}",
"Content-Type": "application/json",
},
json={"invoice": invoice.model_dump(exclude_none=True)},
)
if response.status_code != 200:
raise HTTPException(
status_code=response.status_code,
detail=response.json().get("detail", "Generation failed"),
)
return Response(
content=response.content,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={invoice.invoiceNumber}.pdf"
},
)
The Pydantic models double as request validation and documentation. FastAPI generates an OpenAPI spec automatically that matches the InvoiceXML schema.
Odoo integration: extending the account module
For Odoo deployments serving French customers, replace or supplement Odoo's built-in Factur-X module with InvoiceXML for full Schematron validation and CIUS overlay support. Add to a custom Odoo module:
# models/account_move.py
import requests
from odoo import models, fields
from odoo.exceptions import UserError
class AccountMove(models.Model):
_inherit = "account.move"
facturx_pdf = fields.Binary(string="Factur-X PDF", attachment=True)
facturx_validation_status = fields.Selection([
("pending", "Pending"),
("valid", "Valid"),
("invalid", "Invalid"),
], default="pending")
def action_generate_facturx(self):
for move in self:
invoice_data = move._build_facturx_payload()
api_key = self.env["ir.config_parameter"].sudo().get_param(
"invoicexml.api_key"
)
response = requests.post(
"https://api.invoicexml.com/v1/create/facturx",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={"invoice": invoice_data},
timeout=30,
)
if response.status_code != 200:
raise UserError(
f"Factur-X generation failed: {response.json().get('detail')}"
)
move.facturx_pdf = response.content
move.facturx_validation_status = "valid"
def _build_facturx_payload(self) -> dict:
return {
"invoiceNumber": self.name,
"issueDate": self.invoice_date.isoformat(),
"currency": self.currency_id.name,
"seller": {
"name": self.company_id.name,
"vatIdentifier": self.company_id.vat,
"postalAddress": {
"line1": self.company_id.street,
"city": self.company_id.city,
"postCode": self.company_id.zip,
"country": self.company_id.country_id.code,
},
},
"buyer": {
"name": self.partner_id.name,
"postalAddress": {
"line1": self.partner_id.street,
"city": self.partner_id.city,
"postCode": self.partner_id.zip,
"country": self.partner_id.country_id.code,
},
},
"paymentDetails": {
"paymentAccountIdentifier": self.partner_bank_id.acc_number,
},
"lines": [{
"quantity": line.quantity,
"item": {"name": line.name},
"priceDetails": {"netPrice": line.price_unit},
"vatInformation": {
"rate": line.tax_ids[0].amount if line.tax_ids else 20,
},
} for line in self.invoice_line_ids if line.product_id],
}
This replaces Odoo's native Factur-X output (which lacks full Schematron validation) with InvoiceXML's validated output, while keeping the rest of Odoo's invoicing workflow intact.
Error handling pattern for production
Wrap API calls with retry logic and structured error handling. Python applications often run in long-lived workers (Celery tasks, RQ jobs, Django Q clusters), and transient failures should not surface as fatal errors:
import os
import time
import requests
class InvoiceXMLError(Exception):
pass
class InvoiceXMLClient:
def __init__(
self,
api_key: str = None,
max_retries: int = 2,
timeout: int = 30,
):
self.api_key = api_key or os.environ["INVOICEXML_API_KEY"]
self.max_retries = max_retries
self.timeout = timeout
self.session = requests.Session()
self.session.headers["Authorization"] = f"Bearer {self.api_key}"
def post(self, endpoint: str, **kwargs) -> requests.Response:
url = f"https://api.invoicexml.com{endpoint}"
for attempt in range(self.max_retries + 1):
try:
response = self.session.post(url, timeout=self.timeout, **kwargs)
if response.status_code >= 500 and attempt < self.max_retries:
time.sleep(2 ** attempt)
continue
if response.status_code == 401:
raise InvoiceXMLError("API key is invalid")
if response.status_code == 400:
detail = response.json().get("detail", "Bad request")
raise InvoiceXMLError(f"Validation error: {detail}")
response.raise_for_status()
return response
except requests.exceptions.Timeout:
if attempt < self.max_retries:
continue
raise InvoiceXMLError("Request timed out")
raise InvoiceXMLError("Maximum retries exceeded")
Why use a REST API instead of building it in Python
The akretion/factur-x library is functional and well-maintained for what it covers: PDF/A-3b generation when given an existing PDF input plus CII XML, and basic XSD validation. For Odoo deployments and similar setups where Factur-X is a small piece of a larger invoicing flow, it works.
What it does not do, and what InvoiceXML handles:
EN 16931 Schematron validation across all 200+ business rules. The Python library validates against XSD only. XSD catches structural errors but not the business rule violations where most real-world rejections occur (VAT calculation mismatches, conditional field requirements, code list violations). Without Schematron, invoices can pass library validation while being rejected by Plateformes Agréées.
CII XML generation from structured data. The Python library accepts pre-built CII XML as input but does not generate it from a Pythonic data structure. You either need a second library or hand-write the XML, both of which are error-prone.
National CIUS validation overlays. The same InvoiceXML endpoint handles Peppol BIS 3.0 for Belgian and Dutch B2B, XRechnung CIUS-REC-DE for German public sector, NLCIUS for Netherlands public sector, EHF for Norway, and PINT variants for APAC markets. If your Python application serves customers in multiple European markets, switching profiles is a single parameter change.
Version maintenance. Updates for new ZUGFeRD versions, new Schematron releases, and new CIUS profile changes are deployed before the new specification's effective date with no integration change required on your side.
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 endpoint reference for Python
| Operation | Endpoint | Input | Output |
| Create Factur-X | POST /v1/create/facturx | JSON invoice | PDF/A-3b binary |
| PDF to Factur-X (AI) | POST /v1/transform/to/facturx | PDF, image, DOCX, XLSX | PDF/A-3b binary |
| Validate Factur-X | POST /v1/validate/facturx | Factur-X PDF | Validation JSON |
| Extract as JSON | POST /v1/extract/json | Factur-X PDF | Structured JSON |
| Extract CII XML | POST /v1/extract/xml | Factur-X PDF | CII XML |
| Render as PDF | POST /v1/render/cii/to/pdf | CII 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 European e-invoice compliance covering Factur-X, ZUGFeRD, XRechnung, Peppol UBL, and CII. Stateless processing, GDPR compliant by architecture, and integration support from any Python application: Django, FastAPI, Flask, Odoo, Celery workers, data pipelines, or automation scripts.