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
Invoices from /v1/create/facturx are validated before the API hands them back, so this endpoint is for documents that came from somewhere else: a supplier's Factur-X, output from a legacy generator, or a partner's file you are about to forward to a Plateforme Agréée. It runs the full EN 16931 Schematron plus PDF/A-3b conformance on any hybrid, whoever produced it.
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": []
}
Invoices from /v1/create/facturx are validated before the API returns them, so this call is for the files you did not generate: supplier invoices, output from a legacy system, or a partner's PDF you are about to forward. Via pytest or unittest it also checks test fixtures and hand-assembled documents on every build; the endpoint is fast enough (typically under a second) for pre-commit hooks.
Full validation example on GitHub →
Parse supplier PDFs with AI
A French AP pipeline built for Factur-X still has to survive the suppliers who send plain PDFs, and there will be many of them well past the mandate dates. For documents with no embedded XML, whether generated, scanned, or photographed, /v1/parse/json reads the invoice visually with OCR plus AI field extraction and returns structured JSON in the same InvoiceDocument shape as the deterministic extract endpoint below. Each response includes a confidence block scoring the read overall and per area (seller, buyer, tax calculation, line items) on a 0.0 to 1.0 scale, which is your handle for deciding between automatic booking and a review queue:
import os
import requests
def parse_supplier_pdf(pdf_path: str) -> dict:
with open(pdf_path, "rb") as f:
response = requests.post(
"https://api.invoicexml.com/v1/parse/json",
headers={
"Authorization": f"Bearer {os.environ['INVOICEXML_API_KEY']}",
},
files={"file": (os.path.basename(pdf_path), f, "application/pdf")},
)
response.raise_for_status()
return response.json()
result = parse_supplier_pdf("supplier-invoice.pdf")
invoice = result["invoice"]
confidence = result["confidence"]["overall"]
if confidence >= 0.7:
book_invoice(invoice)
else:
review_queue.put(invoice, result["confidence"])
Documents the AI classifies as not-an-invoice come back as a 400 with error code 4008, and multi-invoice files as 4009, so the endpoint doubles as a filter in front of your booking logic. The JSON envelope matches the /v1/create request body, which means a parsed supplier invoice can be piped straight back into invoice creation if you re-issue or archive in a structured format. For supporting documents embedded inside e-invoices (BG-24 delivery notes, timesheets), /v1/extract/attachments returns them as a ZIP; see the attachment extraction reference.
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:
# generate() raises on a 4xx, and the API only returns a file when the
# document passed the rules, so there is nothing to re-check here.
pdf = self.generate(invoice_data)
path = f"invoices/facturx/{invoice_number}.pdf"
default_storage.save(path, ContentFile(pdf))
return path
def store_incoming(self, pdf_bytes: bytes, reference: str) -> str:
"""Gate a supplier's Factur-X, or output from a system you do not control."""
validation = self.validate(pdf_bytes)
if not validation["valid"]:
errors = "\n".join(
f"[{e['rule']}] {e['message']}"
for e in validation["errors"]
)
raise ValueError(f"Incoming invoice failed validation:\n{errors}")
path = f"invoices/incoming/{reference}.pdf"
default_storage.save(path, ContentFile(pdf_bytes))
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 |
| Parse PDF with AI | POST /v1/parse/json | Invoice PDF (typed or scanned) | Structured JSON + confidence |
| Extract attachments | POST /v1/extract/attachments | Factur-X PDF or CII/UBL XML | ZIP archive |
| 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.