Automate Factur-X and ZUGFeRD e-Invoices in Python with PDF.co
If you invoice businesses in France or Germany, you've probably heard the words Factur-X and ZUGFeRD more in the past year than in the decade before it. France's B2B e-invoicing mandate started phasing in on 1 September 2026, and Germany's issuing requirement arrives in 2027.
The good news: if your billing system already produces EN 16931 XML, you're most of the way there. A Factur-X invoice is just your existing PDF with that XML tucked inside it, in a format called PDF/A-3. Your customer opens it and sees a normal invoice. Their accounting software reads the XML and books it automatically. One file, two readers.
The less good news is that "tuck the XML inside" involves more than it sounds like, and an invoice that doesn't quite conform gets bounced by the system on the other end.
So in this tutorial you'll build a small Python script that takes a PDF and its XML, produces a Factur-X or ZUGFeRD file, and tells you clearly whether it's good to send. Then you'll run it over a whole folder of invoices, which is where it actually earns its keep.
What you'll need
- A PDF.co account and API key, from your dashboard
- Python 3.8 or later, and
requests:pip install requests - An invoice PDF and its matching EN 16931 XML, both reachable at public URLs. If yours sit behind authentication, generate presigned URLs first.
The XML is the part people trip over. PDF.co embeds and validates the invoice data you send; it doesn't generate that data. If your invoicing platform or ERP already exports EN 16931 XML, that's what you want here. If it doesn't, that's a problem to solve upstream before this tutorial will be much use.
Keep your key out of the script:
export PDFCO_API_KEY="your_key_here"Step 1: Send the invoice and its XML
The endpoint is POST /v1/pdf/convert/to/e-invoice, and it takes three things: where the PDF is, where the XML is, and which format you're producing.
For profile, use exactly facturx or zugferd. They're the same underlying format, with different names in France and Germany. Pick the one your customer's country expects.
import os
import requests
API_KEY = os.environ["PDFCO_API_KEY"]
BASE_URL = "https://api.pdf.co/v1"
HEADERS = {"x-api-key": API_KEY, "Content-Type": "application/json"}
def submit_invoice(pdf_url, xml_url, profile="facturx", output_name=None):
"""Queue an e-invoice conversion and return its job ID."""
payload = {
"url": pdf_url,
"xml_url": xml_url,
"profile": profile,
}
if output_name:
payload["name"] = output_name
response = requests.post(
f"{BASE_URL}/pdf/convert/to/e-invoice",
json=payload,
headers=HEADERS,
timeout=30,
)
response.raise_for_status()
data = response.json()
if data.get("error"):
raise RuntimeError(data.get("message", "Submission was rejected"))
return data["jobId"]This endpoint is asynchronous and has no synchronous mode, so what comes back is a job ID rather than a file:
{
"error": false,
"status": 200,
"jobId": "EXAMPLEJOBID0123456789ABCDEFGHIJ--167-300",
"pageCount": 1,
"duration": 120
}One thing worth knowing now rather than later: the conformance level of your invoice (MINIMUM, BASIC WL, BASIC, EN16931 or EXTENDED) comes from a guideline identifier inside your XML, not from anything you send here. If that identifier isn't one PDF.co recognises, the job is rejected rather than quietly falling back to something simpler. That's deliberate: a silent downgrade would be worse than an error.
Step 2: Wait for the result
Poll POST /v1/job/check with your job ID until the status stops being working. Watch the spelling: the field is jobid, all lowercase, which catches people out.
Each poll costs credits, so give it a few seconds between checks rather than hammering it.
import time
TERMINAL = {"success", "failed", "aborted"}
RETRYABLE = {429, 500, 502, 503, 504}
def wait_for_result(job_id, interval=3, timeout=300):
"""Poll until the job finishes, then return whatever it says."""
deadline = time.time() + timeout
while time.time() < deadline:
response = requests.post(
f"{BASE_URL}/job/check",
json={"jobid": job_id},
headers=HEADERS,
timeout=30,
)
# Rate limits and server hiccups are temporary. Wait and ask the
# same job again — don't resubmit, that's a second billable job.
if response.status_code in RETRYABLE:
time.sleep(interval)
continue
response.raise_for_status()
result = response.json()
if result.get("status") in TERMINAL:
return result
time.sleep(interval)
raise TimeoutError(f"Job {job_id} is taking longer than {timeout}s")If polling isn't your thing, pass a callback URL on the original request instead and PDF.co will post the result to you when it's done. And if your script times out, the job keeps running. Keep the ID and check again later.
Step 3: Handle the two outcomes
Here's where e-invoices differ from most conversion APIs, and it's worth understanding before you write the branch.
Before handing anything back, PDF.co runs three checks on the file. veraPDF tests the PDF/A-3 container, including fonts, colors, metadata, and the embedded attachment. Mustang tests your invoice XML against EN 16931: required fields present, totals that add up, tax rates that match. A third check confirms the attachment survived conversion intact.
They come back as a single verdict. isCompliant is true only when all three pass.
And if they don't all pass, the job fails. You don't get a file with a warning attached. Instead, you get status: "failed" and a message explaining why.
That's a deliberate choice, and it's the opposite of how PDF.co's PDF/A endpoint behaves. PDF/A hands you the converted file alongside a non-compliant flag, because an imperfect archive copy is still something you can look at and diagnose. An invoice is different. A Factur-X file that doesn't validate can be rejected by your customer's system. Better to find out here than three days later.
So your branch is simpler than you might expect:
def handle(result):
"""Work out what happened and what to do about it."""
if result.get("status") == "success" and result.get("isCompliant"):
return "ready", "Validated as both a PDF/A-3 and an EN 16931 invoice"
# Anything else needs a human. The message says what went wrong.
return "failed", result.get("message", "Conversion did not complete")One caution. A failed response can still include file links for diagnosis. Don't send one to a customer. If you want to pull the validation report for a failed job, check that validationReportUrl is actually present first, because failure responses can leave it out.
Step 4: Save the invoice and its report
Both the output and the report come back as temporary signed links. The expiration field sets how long they live, in minutes, capped at seven days. If you need to prove later that an invoice validated when you sent it, download and store both. An expiring link isn't a lasting record.
from pathlib import Path
def download(url, destination):
destination.parent.mkdir(parents=True, exist_ok=True)
with requests.get(url, stream=True, timeout=120) as response:
response.raise_for_status()
with open(destination, "wb") as handle:
for chunk in response.iter_content(chunk_size=8192):
handle.write(chunk)
return destinationDon't send your API key when fetching from those storage URLs. They're already signed, and the key doesn't belong in the request.
There's also a warnings array, which is separate from the validation result. It tells you what changed during conversion: documentModified on almost everything, nonEmbeddedFonts if your source referenced a font it didn't include, attachmentMetadataRepaired if we had to fix the attachment's metadata to make it conform.
for warning in result.get("warnings", []):
print(f" {warning['code']}: {warning['message']}")These aren't failures. An invoice can carry several warnings and still be perfectly valid. The warnings record what changed during conversion.
Step 5: The complete script
"""Turn invoice PDFs and their XML into Factur-X or ZUGFeRD e-invoices.
Usage: python make_einvoice.py <pdf-url> <xml-url> [facturx|zugferd]
"""
import os
import sys
import time
from pathlib import Path
import requests
API_KEY = os.environ["PDFCO_API_KEY"]
BASE_URL = "https://api.pdf.co/v1"
HEADERS = {"x-api-key": API_KEY, "Content-Type": "application/json"}
READY_DIR = Path("ready_to_send")
FAILED_DIR = Path("needs_attention")
TERMINAL = {"success", "failed", "aborted"}
RETRYABLE = {429, 500, 502, 503, 504}
def submit_invoice(pdf_url, xml_url, profile="facturx", output_name=None):
payload = {"url": pdf_url, "xml_url": xml_url, "profile": profile}
if output_name:
payload["name"] = output_name
response = requests.post(
f"{BASE_URL}/pdf/convert/to/e-invoice",
json=payload,
headers=HEADERS,
timeout=30,
)
response.raise_for_status()
data = response.json()
if data.get("error"):
raise RuntimeError(data.get("message", "Submission was rejected"))
return data["jobId"]
def wait_for_result(job_id, interval=3, timeout=300):
deadline = time.time() + timeout
while time.time() < deadline:
response = requests.post(
f"{BASE_URL}/job/check",
json={"jobid": job_id},
headers=HEADERS,
timeout=30,
)
if response.status_code in RETRYABLE:
time.sleep(interval)
continue
response.raise_for_status()
result = response.json()
if result.get("status") in TERMINAL:
return result
time.sleep(interval)
raise TimeoutError(f"Job {job_id} is taking longer than {timeout}s")
def download(url, destination):
destination.parent.mkdir(parents=True, exist_ok=True)
with requests.get(url, stream=True, timeout=120) as response:
response.raise_for_status()
with open(destination, "wb") as handle:
for chunk in response.iter_content(chunk_size=8192):
handle.write(chunk)
return destination
def make_einvoice(pdf_url, xml_url, profile="facturx", output_name=None):
"""Produce one e-invoice. Returns True if it's ready to send."""
job_id = submit_invoice(pdf_url, xml_url, profile, output_name)
print(f"Submitted {job_id}")
result = wait_for_result(job_id)
name = result.get("name") or output_name or "invoice.pdf"
if result.get("status") == "success" and result.get("isCompliant"):
download(result["url"], READY_DIR / name)
report_url = result.get("validationReportUrl")
if report_url:
report_name = result.get("validationReportName", f"{name}-validation.json")
download(report_url, READY_DIR / report_name)
print(f"READY: {profile} e-invoice -> {READY_DIR}/{name}")
for warning in result.get("warnings", []):
print(f" {warning['code']}: {warning['message']}")
return True
# Failed. Record why, so somebody can fix the source and re-run it.
FAILED_DIR.mkdir(parents=True, exist_ok=True)
message = result.get("message", "Conversion did not complete")
(FAILED_DIR / f"{name}.error.txt").write_text(f"{job_id}\n{message}\n")
print(f"FAILED: {name}")
print(f" {message}")
return False
if __name__ == "__main__":
if len(sys.argv) < 3:
sys.exit("Usage: python make_einvoice.py <pdf-url> <xml-url> [profile]")
chosen_profile = sys.argv[3] if len(sys.argv) > 3 else "facturx"
make_einvoice(sys.argv[1], sys.argv[2], chosen_profile)A successful run looks like this:
Submitted EXAMPLEJOBID...--167-300
READY: facturx e-invoice -> ready_to_send/INV-2026-0042.pdf
documentModified: Conversion rewrote the PDF. Change details are reported where available.Step 6: Run it over a month of invoices
One invoice at a time is fine for testing. The real version runs at the end of a billing cycle, over everything your system just produced.
Each conversion is an independent job, so this is as simple as a loop. Feed it a CSV exported by your billing system, with one row per invoice containing the PDF URL, the XML URL, and which profile that customer needs:
name,pdf_url,xml_url,profile
INV-2026-0042.pdf,https://example.com/0042.pdf,https://example.com/0042.xml,facturx
INV-2026-0043.pdf,https://example.com/0043.pdf,https://example.com/0043.xml,zugferdimport csv
def process_batch(csv_path):
ready, failed = [], []
with open(csv_path, newline="", encoding="utf-8") as handle:
for row in csv.DictReader(handle):
name = row["name"]
try:
ok = make_einvoice(
row["pdf_url"],
row["xml_url"],
row.get("profile", "facturx"),
name,
)
except Exception as error:
# One bad invoice shouldn't stop the run.
print(f"ERROR: {name} — {error}")
failed.append(name)
continue
(ready if ok else failed).append(name)
print(f"\n{len(ready)} ready to send, {len(failed)} need attention")
if failed:
print("Check needs_attention/ for the reason on each one.")
return ready, failedNow you have two folders. One holds invoices that passed every check and can go straight out to customers. The other holds the ones that didn't, each with a note saying why.
That split is the whole point. Nobody opens a hundred PDFs to spot-check them, and nobody wants to find out an invoice was malformed when a customer's accounts payable system bounces it. Let the script sort them, and spend your time on the handful that actually need a person.
A couple of practical notes. The loop above is sequential, which is fine for tens of invoices. If you're running hundreds, add modest concurrency to avoid hitting rate limits. And a new submission is always a new job, so if you re-run a row after fixing its XML, that's a fresh conversion rather than a retry of the old one.
What it checks, and what it doesn't
It's worth being clear about the boundary here, because it's the thing most likely to bite you.
The validation checks your XML on its own terms. It confirms the file is a proper PDF/A-3, that the XML satisfies EN 16931, and that the attachment is intact. It does not compare the numbers in your XML against the numbers printed on your PDF. If your PDF says €500 and your XML says €5,000, both can be individually valid and the job will pass.
Making sure the two tell the same story is your system's job, upstream of this call. If both come from the same invoice record, they should agree, but confirm this rather than assuming.
Similarly, passing validation isn't a guarantee that every trading partner or authority will accept the invoice. It means the file is well-formed and the data meets the standard, which is necessary but not always sufficient.
A few inputs get rejected before conversion even starts:
- Digitally signed PDFs. Conversion rewrites the document, which breaks the signature, so the endpoint refuses rather than quietly invalidating it. Submit an unsigned copy and keep the original. Empty, unsigned signature fields are fine.
- Password-protected PDFs. There's no password parameter. Unlock the file first. This includes owner-password files that open without prompting you.
- Certain OCR'd PDFs, where the same glyphless OCR font is used for both invisible text and text clipping. Ordinary OCR output is fine.
All of these fail cleanly with a message, and the processing charge is refunded.
Where to go next
A few ways to take this further:
- Wire it to your billing system. The CSV is a stand-in. Point the loop at your invoicing database or the webhook that fires when invoices are issued, and the whole thing runs without anyone starting it.
- Send the failures somewhere people look. A Slack message or a ticket beats a folder nobody opens, especially when a bad XML export will produce the same failure across every invoice in the batch.
- Keep the reports. If an authority ever asks whether an invoice conformed when you issued it, the validation report is the answer. Store it with the invoice.
- Use a callback instead of polling. For large batches it removes the poll loop entirely, and the per-check cost with it.
Related reading
- PDF to E-Invoice API reference
- New in PDF.co: Create Factur-X and ZUGFeRD e-Invoices
- Convert PDFs to PDF/A in Python and handle validation results
If you only need archival PDF/A and not the invoice XML, that's a separate, simpler endpoint covered in the tutorial above.
Related Tutorials

