Factur-X 2026 Implementation: 6 Gotchas We Hit at Scale
Factur-X is France's mandatory e-invoicing standard starting 2026 for all B2B invoices. If you're building a French SaaS or integrating e-invoicing for contractors, you'll encounter these gotchas. Here's what we learned shipping Factur-X at Anodos across 200+ job sites in the French construction industry.
1. XML Schema Versioning: The Silent Breakage
The Problem: Factur-X uses UBL 2.1 XML under the hood, but France's FNFE-MPE (the standards body) releases patch updates without major version bumps. A schema you validated against in January 2025 might differ from the June 2025 release.
What breaks in production:
- A "valid" invoice from your old validator fails in 2025 if you cached the XSD locally
- Payment term codes (
PaymentMeansCode) expand; old enums no longer match - Namespace URIs changed between v1.2 and v1.3 (and yes, a trailing slash matters)
How we fixed it:
// WRONG: Hardcoded schema URL, cached locally
const schema = fs.readFileSync('./facturx-1.2.xsd');
// RIGHT: Fetch fresh on each invoice validation
const schemaUrl = "https://www.fnfe-mpe.org/factur-x/downloads/latest/";
const validator = new XMLValidator(schemaUrl, {cache: false});
Store schema versions in your database with fetch timestamps. When a validator rejects a "known good" invoice, check the schema age first—it's probably the culprit.
2. PDF Attachment Encoding Breaks Outlook & Sage
The Problem: Factur-X is a hybrid format: an XML file embedded inside a PDF. The PDF 1.7 spec says to use EmbeddedFile, but:
-
Outlook silently strips the attachment if you use
Content-Transfer-Encoding: base64instead ofbinary -
Sage (France's #1 accounting software) silently fails to read the attachment if your PDF
/EmbeddedFilestream doesn't declareFlateDecodecompression
Real case from production:
We shipped invoices with Content-Transfer-Encoding: base64 for months. Outlook users never saw the XML attachment in their inbox. Sage customers got "invalid file" errors. Root cause took 2 weeks of support escalation to isolate.
How we fixed it:
from PyPDF import PdfWriter
from io import BytesIO
pdf = PdfWriter()
# ... build PDF ...
# Embed XML with correct settings
xml_bytes = invoice_xml.encode('utf-8')
pdf.add_attachment(
filename="factur-x.xml",
data=xml_bytes,
compress=True, # FlateDecode
transfer_encoding='binary' # NOT base64
)
# Test with real Outlook account before shipping
Checklist:
- ✓ Use
binaryencoding, neverbase64 - ✓ Compress EmbeddedFile stream with FlateDecode
- ✓ Set MIME type
/application#2Fxml - ✓ Forward test invoice to your @outlook.com account before production
3. Line-Item VAT: Multi-Country Nightmare
The Problem: Factur-X VAT rates must match a predefined list. France allows:
- Standard: 20%
- Reduced: 5.5%, 2.1%
- Reverse charge: 0%
But here's the trap: if your French contractor works on a cross-border job (France + Belgium + Luxembourg), Factur-X refuses custom rates like Belgium's 6% VAT on a single invoice. The spec is strict: one VAT treatment per invoice, or rigid line-level mapping.
Even worse: reverse-charge invoices (0% VAT, buyer pays) require a TaxExemptionReason code from the official UNTDID list. Use the wrong code (e.g., VAT_EXEMPTION instead of REVERSE_CHARGE_EU), and French tax software rejects it silently.
How we fixed it:
<!-- RIGHT: Explicit reverse charge with correct code -->
<cac:TaxSubtotal>
<cbc:TaxableAmount>1000.00</cbc:TaxableAmount>
<cbc:TaxAmount>0.00</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>Z</cbc:ID>
<cbc:Percent>0.00</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
<cbc:Name>Reverse Charge</cbc:Name>
<cbc:TaxExemptionReason>REVERSE_CHARGE_EU</cbc:TaxExemptionReason>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
Best practice: For multi-country jobs, emit separate Factur-X invoices per country/VAT regime, not one invoice with mixed rates.
4. Timestamps & Timezone: One Character Kills the Invoice
The Problem: Factur-X demands ISO 8601 timestamps in UTC. But:
- If you emit
2026-01-15T14:30:00+02:00(with timezone offset), some validators reject it -
InvoiceIssueDatemust beYYYY-MM-DD(date only), not datetime—one extra character breaks parsing - Milliseconds are tricky: the schema accepts 0 or 1 decimal place, not 3
Real case: We emitted timestamps as 2026-01-15T14:30:00.000Z (3 decimal places). A customer's ERP validator rejected it because the schema definition was strict: exactly 0-1 decimal, not 3. We traced the bug by comparing 20 invoices byte-by-byte.
How we fixed it:
from datetime import datetime, timezone
# RIGHT
issue_date = datetime.now(timezone.utc).strftime("%Y-%m-%d") # 2026-01-15
issue_datetime = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") # ISO 8601 UTC
# WRONG
issue_date = datetime.now().isoformat() # Includes time, breaks schema
issue_datetime = datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ") # 6 decimal places, rejected
Checklist:
- ✓ Always use UTC (Z suffix), never timezone offsets
- ✓ Date fields:
YYYY-MM-DD(no time) - ✓ DateTime fields:
YYYY-MM-DDTHH:MM:SSZ(zero or one decimal for seconds) - ✓ Test your timestamp format against an online Factur-X validator before shipping
5. Buyer/Seller Identification: SIREN vs. GLN vs. VAT ID
The Problem: French Factur-X invoices identify buyer and seller. You can use:
- SIREN (9 digits, French business ID) — most common
- GLN (13 digits, barcode) — rare, mainly retail
- VAT ID (FR + 2 digits + SIREN) — required for EU cross-border
But you cannot mix formats on the same invoice. If you use SIREN for the seller, use SIREN (or a VAT ID derived from it) for the buyer—never a mix of SIREN + GLN.
Also: SIREN validation. Many SaaS skip this. An invoice with a typo SIREN like 12345678X will pass your XML schema but fail Chorus Pro (France's official tax e-invoicing portal). The Luhn algorithm catches typos; most implementations skip it.
How we fixed it:
def validate_siren(siren: str) -> bool:
"""Validate French SIREN with Luhn algorithm."""
if len(siren) != 9 or not siren.isdigit():
return False
# Luhn check (similar to credit card validation)
total = 0
for i in range(9):
digit = int(siren[i])
if i % 2 == 0: # Even positions: multiply by 2
digit *= 2
if digit > 9:
digit -= 9
total += digit
return total % 10 == 0
# Usage
assert validate_siren("732829823") # Valid Anodos SIREN
assert not validate_siren("12345678X") # Typo, fails Luhn
6. Pre-Launch Testing & Validation Checklist
Before you ship Factur-X to production, test exhaustively:
- ✓ Validate against official FNFE-MPE XSD (not a local copy > 3 months old)
- ✓ Test PDF attachment rendering in Outlook, Sage, and Chorus Pro
- ✓ Verify all timestamps are ISO 8601 UTC with correct decimal places (0-1)
- ✓ Check VAT rates and exemption codes against official UNTDID lists
- ✓ Validate SIREN/SIRET with Luhn algorithm before invoice creation
- ✓ Generate a test invoice, emit as Factur-X, then re-import it into your own system (round-trip test catches schema mismatches)
- ✓ Submit 5 real invoices to Chorus Pro test environment pre-launch (France's tax portal has a sandbox)
Pro tip: Automate these checks. We added validation gates to our CI/CD pipeline—invoices fail build if they don't meet the checklist above. Caught 3 gotchas before they reached customers.
Conclusion
Factur-X compliance is non-negotiable starting 2026. These 6 gotchas represent ~200 hours of production support, debugging, and rework at Anodos. Learn from them: schema version your XSD, test encodings with real clients, split multi-country invoices, validate timestamps and SIRENs, and test exhaustively before launch.
The Factur-X spec is dense, but the payoff is real: once you ship correctly, invoicing compliance stops being a liability and becomes a feature.
Olivier Ebrahim is founder of Anodos, a French SaaS for construction SMEs featuring real-time job site management, AI voice-to-quote, and Factur-X 2026 invoicing. He ships Factur-X invoices for 200+ contractors.
Top comments (0)