Automation MCP Server Features Blog Pricing Contact
Integration Factur-X Format

Create and Validate Factur-X Invoices in Python: Complete Guide

A practical guide to handling the full Factur-X lifecycle in Python: creating invoices from structured data, validating against EN 16931 with the official Schematron, parsing supplier PDFs with AI, extracting structured data, and rendering CII XML as human-readable PDFs. Includes Django, FastAPI, and Odoo integration patterns.

France's September 2026 B2B mandate makes Factur-X compliance a near-term requirement for every business that invoices French customers. For Python developers building Django apps, FastAPI services, Odoo modules, data pipelines, or automation scripts, the question is no longer whether to support Factur-X but how to do it without spending weeks on PDF/A-3b conformance and Schematron infrastructure.

The Python ecosystem has one notable open-source library: akretion/factur-x by Akretion. It generates Factur-X PDFs from existing PDF input plus structured XML, handles basic XSD validation, and is reasonably maintained. For a development prototype or a simple use case where you already have both a PDF and a CII XML in hand, it works.

What it does not do is generate the embedded XML from structured invoice data, run EN 16931 Schematron validation (200+ business rules where most real-world rejections occur), apply national CIUS overlays for cross-market support, or track standards updates ahead of effective dates. These are the layers where most production e-invoicing implementations spend the bulk of their engineering time.

This guide shows how to handle the full Factur-X lifecycle in Python using the InvoiceXML REST API: creating invoices from structured data, validating them against EN 16931 with the official Schematron, reading incoming documents (structured extraction for Factur-X, AI parsing for plain supplier PDFs), and rendering CII XML as human-readable PDFs.

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.


Extract structured data from Factur-X

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

OperationEndpointInputOutput
Create Factur-XPOST /v1/create/facturxJSON invoicePDF/A-3b binary
Parse PDF with AIPOST /v1/parse/jsonInvoice PDF (typed or scanned)Structured JSON + confidence
Extract attachmentsPOST /v1/extract/attachmentsFactur-X PDF or CII/UBL XMLZIP archive
Validate Factur-XPOST /v1/validate/facturxFactur-X PDFValidation JSON
Extract as JSONPOST /v1/extract/jsonFactur-X PDFStructured JSON
Extract CII XMLPOST /v1/extract/xmlFactur-X PDFCII XML
Render as PDFPOST /v1/render/cii/to/pdfCII XMLPDF 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.

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