Factur-X 2026 Implementation Guide for SMB Construction: From Spec to Invoice in Python
Introduction
If you're building software for construction SMBs in France, you've probably heard "Factur-X" mentioned in hushed tones by compliance teams. Starting January 2026, French invoicing regulations are tightening: all B2B invoices must include a machine-readable Factur-X (also called ZUGFeRD 3.0) component embedded within the PDF.
This isn't optional. This isn't coming "eventually." The deadline is real.
In this guide, I'll walk you through what Factur-X actually is, why it matters for construction SMBs, and how to implement it in your invoicing pipeline—complete with Python code examples and the gotchas I've encountered building this at scale for 50+ construction sites.
What Is Factur-X 2026?
Factur-X is an XML-based structured format that sits inside your PDF invoice. From the user's perspective, they still see a normal PDF. But underneath, there's a machine-readable XML file containing invoice data in a standardized format recognized by French tax authorities.
Why does this exist?
- Tax authorities want to fight invoice fraud and cash-in-hand payments
- EDI (electronic data interchange) between businesses becomes automatic and auditable
- Suppliers can submit to French tax authorities automatically without manual re-entry
The format includes:
- Invoice header (number, date, issuer, recipient)
- Line items (description, quantity, unit price, tax rate)
- Tax summary (VAT per rate, total)
- Payment terms and bank details
- Structured codes (UNECE, ISO 20022) for clarity across borders
From a developer's perspective: it's XML embedded in a PDF. That's actually simpler than you'd think.
The 2026 Mandate: What You Need to Know
Starting January 6, 2026, the French government will require:
- All B2B invoices (business-to-business) must include Factur-X
- Invoices issued by French companies to other French companies are in scope
- B2C (business-to-consumer) invoices are still optional but recommended
- Invoices for intra-EU sales can use simpler formats, but French->French = Factur-X mandatory
For construction SMBs, this is critical because:
- General contractors invoice subcontractors constantly
- Material suppliers invoice contractors
- The audit trail now becomes automatic
- If you're not generating Factur-X, your customers will have compliance issues
Technical Architecture: How We Approach It
Here's the high-level flow we use at Anodos for our construction clients:
Invoice Data (DB)
↓
XML Generator (Factur-X schema validation)
↓
PDF Generator (embed XML + visual PDF)
↓
Signed PDF (optional: legal signature)
↓
Archive + Tax Authority Submission
Step 1: Build the XML Structure
The Factur-X spec defines exact XML schemas. You have two options:
- Parse the spec yourself (14 PDF pages, easy to misinterpret)
- Use an existing library (safer, recommended)
For Python, the best library is pycountry + lxml + a hand-rolled generator, OR use zugferd (German library, very solid):
# Option A: Hand-rolled with lxml (more control)
from lxml import etree
from datetime import datetime
def generate_factur_x_xml(invoice_data):
"""
Generate a minimal valid Factur-X XML document.
invoice_data = {
'invoice_number': 'INV-2024-001',
'issue_date': '2024-12-15',
'issuer': {'name': 'My Construction LLC', 'vat_id': 'FR...'},
'recipient': {'name': 'Client Corp', 'vat_id': 'FR...'},
'line_items': [
{'description': 'Labor – site excavation', 'quantity': 40,
'unit_price': 75.00, 'tax_rate': 0.20}
],
'payment_due_date': '2025-01-15'
}
"""
# XML root (UN/CEFACT Cross Industry Invoice)
root = etree.Element('rsm:CrossIndustryInvoice',
nsmap={
'rsm': 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100',
'qdt': 'urn:un:unece:uncefact:data:standard:QualifiedDataType:100',
'ram': 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100',
'xsi': 'http://www.w3.org/2001/XMLSchema-instance'
})
# Header (ExchangedDocumentContext)
context = etree.SubElement(root, 'rsm:ExchangedDocumentContext')
spec_id = etree.SubElement(context, 'ram:GuidelineSpecifiedDocumentContextParameter')
spec_val = etree.SubElement(spec_id, 'ram:ID')
spec_val.text = 'urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended'
# Exchanged Document (metadata)
doc = etree.SubElement(root, 'rsm:ExchangedDocument')
doc_id = etree.SubElement(doc, 'ram:ID')
doc_id.text = invoice_data['invoice_number']
doc_type = etree.SubElement(doc, 'ram:TypeCode')
doc_type.text = '380' # 380 = Commercial invoice
doc_date = etree.SubElement(doc, 'ram:IssueDateTime')
date_elem = etree.SubElement(doc_date, 'udt:DateTimeString',
format='102') # YYYYMMDD
date_elem.text = invoice_data['issue_date'].replace('-', '')
# Supplier Party (issuer)
trade_settlement = etree.SubElement(root, 'rsm:SupplyChainTradeTransaction')
applicable_header_trade_agreement = etree.SubElement(
trade_settlement, 'ram:ApplicableHeaderTradeAgreement')
seller_trade_party = etree.SubElement(
applicable_header_trade_agreement, 'ram:SellerTradeParty')
seller_name = etree.SubElement(seller_trade_party, 'ram:Name')
seller_name.text = invoice_data['issuer']['name']
seller_tax_id = etree.SubElement(seller_trade_party, 'ram:SpecifiedTaxRegistration')
seller_tax_id_id = etree.SubElement(seller_tax_id, 'ram:ID', schemeID='VA')
seller_tax_id_id.text = invoice_data['issuer']['vat_id']
# Buyer Party (recipient)
buyer_trade_party = etree.SubElement(
applicable_header_trade_agreement, 'ram:BuyerTradeParty')
buyer_name = etree.SubElement(buyer_trade_party, 'ram:Name')
buyer_name.text = invoice_data['recipient']['name']
buyer_tax_id = etree.SubElement(buyer_trade_party, 'ram:SpecifiedTaxRegistration')
buyer_tax_id_id = etree.SubElement(buyer_tax_id, 'ram:ID', schemeID='VA')
buyer_tax_id_id.text = invoice_data['recipient']['vat_id']
# Line items (simplified)
for item in invoice_data['line_items']:
line = etree.SubElement(
trade_settlement, 'ram:IncludedSupplyChainTradeLineItem')
line_doc = etree.SubElement(line, 'ram:AssociatedDocumentLineDocument')
line_num = etree.SubElement(line_doc, 'ram:LineID')
line_num.text = str(invoice_data['line_items'].index(item) + 1)
trade_line_item = etree.SubElement(line, 'ram:SpecifiedTradeProduct')
prod_name = etree.SubElement(trade_line_item, 'ram:Name')
prod_name.text = item['description']
# Pricing
line_agreement = etree.SubElement(line, 'ram:SpecifiedLineTradeAgreement')
line_price = etree.SubElement(line_agreement, 'ram:NetPriceProductTradePrice')
price_amount = etree.SubElement(line_price, 'ram:ChargeAmount')
price_amount.text = str(item['unit_price'])
# Quantity
line_delivery = etree.SubElement(line, 'ram:SpecifiedLineTradeDelivery')
qty = etree.SubElement(line_delivery, 'ram:BilledQuantity')
qty.text = str(item['quantity'])
qty.set('unitCode', 'HUR') # Hours
# Settlement (totals & tax)
line_settlement = etree.SubElement(line, 'ram:SpecifiedLineTradeSettlement')
line_total = etree.SubElement(line_settlement, 'ram:SpecifiedTradeSettlementMonetarySummation')
total_amt = etree.SubElement(line_total, 'ram:DuePayableAmount')
total_amt.text = str(item['quantity'] * item['unit_price'])
# VAT
vat_breakdown = etree.SubElement(line_settlement, 'ram:ApplicableTradeTax')
vat_type = etree.SubElement(vat_breakdown, 'ram:TypeCode')
vat_type.text = 'VAT'
vat_rate = etree.SubElement(vat_breakdown, 'ram:RateApplicablePercent')
vat_rate.text = str(item['tax_rate'] * 100)
# Payment terms
payment_terms = etree.SubElement(
trade_settlement, 'ram:ApplicableHeaderTradeSettlement')
due_date = etree.SubElement(payment_terms, 'ram:PaymentDueDateTime')
due_date_str = etree.SubElement(due_date, 'udt:DateTimeString', format='102')
due_date_str.text = invoice_data['payment_due_date'].replace('-', '')
return etree.tostring(root, pretty_print=True, xml_declaration=True,
encoding='UTF-8')
This generates a valid Factur-X XML. It's verbose, but that's the spec—every field is mandatory.
Step 2: Embed XML in PDF
Once you have the XML, you need to embed it inside a PDF. Use PyPDF2 or pypdf (formerly PyPDF2):
from PyPDF2 import PdfWriter
from io import BytesIO
import PyPDF2
def embed_factur_x_in_pdf(pdf_bytes, factur_x_xml):
"""
Embed Factur-X XML as an attachment within a PDF.
"""
writer = PdfWriter()
# Read the base PDF
pdf_input = BytesIO(pdf_bytes)
reader = PyPDF2.PdfReader(pdf_input)
for page in reader.pages:
writer.add_page(page)
# Add the Factur-X XML as an embedded file
writer.add_attachment(
filename='factur-x.xml',
data=factur_x_xml,
description='Factur-X Invoice'
)
# Write to bytes
output = BytesIO()
writer.write(output)
output.seek(0)
return output.getvalue()
Step 3: Validation and Compliance Checks
Before sending, validate against the official schema:
from xmlschema import XMLSchema
def validate_factur_x(xml_bytes):
"""
Validate Factur-X XML against the official UN/CEFACT schema.
Download the schema from: https://www.unece.org/fileadmin/DAM/cefact/xml/schemas/
"""
schema_url = (
'http://www.unece.org/fileadmin/DAM/cefact/xml/schemas/'
'D16B/CrossIndustryInvoice_100pD16B.xsd'
)
schema = XMLSchema(schema_url)
try:
schema.validate(xml_bytes)
return True, "Valid"
except Exception as e:
return False, str(e)
Common Pitfalls (We Hit Them So You Don't)
- Tax rates must match French standards (0%, 5.5%, 10%, 20%). Don't use 19.6%.
- Dates are YYYYMMDD strings in XML, not ISO 8601.
- VAT ID format matters: French VAT IDs start with 'FR' + 2 check digits + 9 SIRET digits.
- Decimal precision: Use Decimal() not float() for amounts. Floating-point rounding = audit failures.
- Namespace prefixes must match across the document. One typo = validation failure.
What We're Doing at Anodos
At Anodos, we generate Factur-X invoices automatically for construction SMBs. When a site manager confirms a job on the app, the quote becomes an invoice, and within 2 seconds the user downloads a PDF with embedded Factur-X XML inside. No extra steps. No compliance headaches.
By January 2026, every invoice out of our system will be Factur-X-compliant by default.
Conclusion
Factur-X isn't rocket science—it's structured XML in a PDF. Yes, the spec is dense, and yes, there are edge cases. But if you're building invoicing for French construction businesses, implementing it now gives you:
- A 3-month head start before the January 2026 deadline
- Customer trust ("we're compliant by design")
- Automatic EDI with French tax authorities
- A competitive moat vs. competitors rushing in December
Start with the XML generator, test with the official validator, and embed into your PDF pipeline. Your construction SMBs will thank you in Q1 2026.
Olivier Ebrahim, founder of Anodos — building voice-first invoicing for construction crews in France.
Top comments (0)