Disclosure up front: I'm Vitalii, founder of PDFik, a hosted URL/HTML-to-PDF API. We shipped Factur-X output this week, and this article is the write-up of what it took. PDFik appears once near the end, clearly marked, next to the self-hosted paths that compete with it. If you must keep invoices on your own infrastructure, the self-hosted section is for you and the hosted one is not.
If you already render invoices from HTML, turning them into hybrid e-invoices looks like a small step: attach an XML file to the PDF. It is not a small step. The container has to be PDF/A-3, the attachment has to carry the right relationship, the metadata has to declare a custom schema, and two of the most common mistakes pass both validators everyone uses.
Here is what a valid file actually contains, the pipeline we ended up with, the traps that cost us days, and three ways to build it yourself.
Why a hybrid PDF at all
Factur-X (France) and ZUGFeRD 2.x (Germany) are the same standard under two names: one PDF that a human reads, with a UN/CEFACT Cross Industry Invoice (CII) XML embedded for the accounting system. The XML follows the European semantic model EN 16931.
Where this matters:
- Germany. Domestic businesses have had to be able to receive e-invoices since 1 January 2025 — small businesses included, even though they are exempt from issuing them. Issuing is phased in through a transition rule: until 31 December 2026 any issuer may still send an "ordinary invoice" instead, and until 31 December 2027 if their previous year's turnover was €800,000 or less. Note what an ordinary invoice means there: paper, or an unstructured electronic file such as a plain PDF — and the electronic variant, exactly as before, may only be used if the recipient agrees to that format. So in practice the duty to issue bites from 2027 for larger companies and from 2028 for the rest. The BMF names XRechnung and ZUGFeRD from version 2.0.1 as satisfying the VAT-law requirements, with the MINIMUM and BASIC-WL profiles explicitly excluded (BMF FAQ).
- France. Since 1 September 2026 every business covered by the reform must be able to receive electronic invoices, and reception runs through an approved platform. Large enterprises and mid-caps (ETI) must also issue from that same date; SMEs, small firms and micro-enterprises must issue from 1 September 2027 (DGFiP start-up guide, PDF). Approved platforms are required to transmit invoices in one of three formats — CII, UBL, or Factur-X, described there as a mixed format of structured XML plus a readable PDF (DGFiP fiche, PDF).
- Belgium went the other way: B2B e-invoicing has been mandatory since 1 January 2026 over Peppol, and "sending a PDF invoice by e-mail or via a platform will no longer be enough" (source). The hybrid PDF is a courtesy copy there, not the legal document.
- Poland's KSeF takes structured XML only. The FA(3) logical structure replaced FA(2) on 1 February 2026 (structure page), and issuing through KSeF has been mandatory since 1 February 2026 for the largest taxpayers and since 1 April 2026 for the rest — with temporary carve-outs running to the end of 2026, notably for issuers whose invoiced sales stay at or below 10,000 zł in a month (legal basis and key dates). Even an invoice "attachment" there is a structured element inside the FA(3) XML, and using it at all requires notifying the tax office first (FA(3) brochure, PDF) — so a hybrid PDF has nothing to attach itself to.
So the hybrid PDF is the right target for Germany and France, and a human-readable extra everywhere else. Nothing below generates the XML for you. That is your accounting data, and every path here assumes you already have valid CII.
What a valid Factur-X file actually requires
Four layers, and every one of them can be wrong independently.
1. The container is PDF/A-3. Chromium gives you a plain PDF. PDF/A-3b on top of it needs an OutputIntent with an embedded sRGB ICC profile, XMP metadata carrying pdfaid:part=3 and pdfaid:conformance=B, every font embedded, no JavaScript and no encryption. PDF/A-3b permits transparency, so CSS opacity is not the problem it was under PDF/A-1.
Chromium embeds what it renders. Across a dozen local test renders — Latin, Cyrillic, Greek, CJK, SVG text, form controls, a web font fetched from Google Fonts, a family that does not exist anywhere — every text font landed in the file as a subsetted CIDFontType2 with its outlines in /FontFile2: one line of text pulled a 1 MB, 4,651-glyph face down to a 47 KB subset, and a character no installed font covers is drawn as the empty box out of a font that is already embedded. Two caveats, both of which cost us work. A font whose OS/2 fsType forbids embedding is not embedded: Chromium converts it to a Type3 font and writes the glyph outlines into the PDF itself, which is a different construct from everything else in the file — worth putting through your own validator before you ship an invoice template built on a licensed corporate typeface. And Chromium neither installs fonts nor touches anything you draw outside it: our worker image adds DejaVu, Lato, Open Sans, Roboto and Noto core on top of the Playwright base, and the sandbox watermark registers and embeds its own TTF, failing the job outright when it cannot find one.
2. The embedded file has the right name. For Factur-X the attachment is factur-x.xml for every profile, listed under /Names/EmbeddedFiles and referenced from the catalog's /AF array.
3. The AFRelationship value. The file specification carries /AFRelationship, and Factur-X gives it meaning in section 6.2.2: Data when the visual representation carries more invoice data than the XML, Source when the visual representation was produced from the XML, Alternative when the two carry exactly the same invoice content in two forms. MINIMUM and BASIC WL never carry a complete invoice, so both countries require Data there. For BASIC, EN 16931 and EXTENDED the two countries part ways: in France Data, Source or Alternative are all allowed, "depending on how the PDF part was created", while for use in Germany the specification makes Alternative mandatory — the word is "zwingend", on legal grounds, because the value asserts that the tax-relevant content of both representations is identical. That is sections 5.3 and 6.2.2 of Factur-X 1.07.2 / ZUGFeRD 2.3.2, 15 November 2024; the spec is free but email-gated at fnfe-mpe.org, and a third-party mirror of that edition is readable without the form. The current release is 1.09.2 / ZUGFeRD 2.5.2, whose published changes are code lists, validation artifacts and new EXTENDED elements — I have not read it, so treat the version above as the one these quotes come from. One thing worth being clear about: Source is not our case. We render the PDF from your HTML and embed your XML, so the two are independent inputs and neither we nor a validator can tell whether they agree. Alternative is a claim about your data, and it is yours to make true.
Here is the trap. The Python factur-x library defaults to data, and quietly downgrades alternative to data only for the two small profiles. Call it with defaults for an EN 16931 invoice and you get a structurally perfect file with the wrong relationship, and nothing downstream objects. Mustang's validator never looks at the value at all — the word "relationship" does not appear in any of the nine source files of its validator module — and veraPDF checks the container, not whether the PDF and the XML "say the same thing", which is not something a validator can decide from the bytes anyway. You have to pass the value explicitly and assert it in your own tests.
4. The XMP extension schema. PDF/A only allows custom XMP properties that are declared in a pdfaExtension:schemas block. Factur-X defines the namespace urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0# with four properties: fx:DocumentType (INVOICE), fx:DocumentFileName (factur-x.xml), fx:Version (1.0) and fx:ConformanceLevel. The last one is spelled per profile, and the spelling is not your API's key:
| API profile | fx:ConformanceLevel |
AFRelationship (German rule) |
Complete legal invoice? |
|---|---|---|---|
minimum |
MINIMUM |
Data |
no |
basicwl |
BASIC WL |
Data |
no |
basic |
BASIC |
Alternative |
yes |
en16931 |
EN 16931 |
Alternative |
yes |
extended |
EXTENDED |
Alternative |
yes |
Note the spaces in BASIC WL and EN 16931. A misspelled level is catchable: Mustang's PDFValidator matches the XMP value against a fixed list — as of Mustang 2.26 that is BASIC WL, BASIC, MINIMUM, EN 16931, COMFORT, CIUS, EXTENDED, XRECHNUNG, a superset of the five Factur-X levels above — exactly and case-sensitively, and anything outside it is reported as <error type="12">XMP Metadata: ConformanceLevel contains invalid value</error>. A level that is missing entirely raises type="11" ("ConformanceLevel not found") and, because the membership check then fails too, type="12" alongside it — validator source. What it does not catch is a level that is on the list but disagrees with the profile in the XML: the XML's own profile is read separately, and nothing in the validator compares the two, so BASIC in the XMP on an EN 16931 invoice draws no complaint. Keep the mapping in exactly one place in your code.
The pipeline
Four steps, and the order is load-bearing.
-
Render the HTML with Chromium (Playwright
page.pdf()). -
Normalise to PDF/A-3b with pikepdf: add the OutputIntent, set the
pdfaidXMP properties. Anything you stamp onto the page (we stamp a demo watermark on anonymous sandbox output) has to happen before this step, or its font is not part of the file the PDF/A check sees. -
Embed the XML with
factur-x, passingafrelationshipexplicitly. The library copies the OutputIntent from its input and does not make a PDF/A itself, so step 2 must come first. -
Validate with two tools: veraPDF with the
3bprofile for the container, Mustang for the Factur-X structure and the XML.
The Mustang gotcha: do not gate on its exit code. In the validator source, the flag that decides overall validity is set from the XML result alone (wasCompletelyValid = xmlValidity); the PDF/A half is written into the XML report but does not change the verdict. A file with a broken PDF/A container and a valid XML exits 0. Parse the report and look at the last <summary status="..."> element instead.
We rejected two alternatives on purpose. Ghostscript's pdfwrite can produce PDF/A, but it is AGPL, which for a closed-source service means a commercial licence, and we did not want to debug its output against veraPDF as well. If you are open source or already licensed, it is a legitimate path. And we do not run Schematron (the business rules of EN 16931) at request time: that needs a Java toolchain in the hot path. We validate against the profile XSD per request and run Mustang in the release gate. Schema-valid is not tax-compliant; the content stays the sender's responsibility either way.
The traps we hit
XML in a queue message. Our workers pull jobs from SQS, which caps a message at 256 KB. A 200 KB invoice XML grows past that once it is encrypted and base64-encoded, and the failure would land after the job row and the quota charge. The fix is gzip before encrypt, then a size check of the final serialised message before anything is charged. How much you save depends almost entirely on how repetitive the invoice is: the small single-page profile samples we measured gzip only 3–6x, while an invoice with hundreds of line items does far better. So do not size the check from a ratio at all — measure the final serialised message, and note that the envelope base64-encodes twice, once around the gzip bytes and once around the AES ciphertext, inflating them by about 1.8x before they ever reach the queue. On the consumer side, cap the inflated size again (we stop at 1 MB) so a corrupted message cannot become a zip bomb on your own data.
CSS watermarks do not survive customer CSS. For the anonymous sandbox we mark the output. A body::after watermark is removed by one !important in the customer's stylesheet, because in the "bring your own HTML" mode the page is theirs. The stamp is a PDF-level overlay drawn with an embedded TrueType font, applied before PDF/A normalisation. Paid or free accounts get no stamp at all; this is only about anonymous output.
libxml2's schema registry is process-global. On a freshly started worker, the first batch of jobs compiled the same XSDs in five threads at once, under Chromium memory pressure. libxml2's built-in type registry ended up corrupted for the life of the process, and every later job failed with xmlSchemaPValAttrNodeValue, the given type is not a built-in type. It does not reproduce in isolation. facturx.xml_check_xsd rebuilds the schema on every call, so we stopped using it at request time: compile each profile's schema once under a process-wide lock, warm all five up at startup so a broken environment fails the start instead of the customers' jobs, serialise validation through the same lock (it takes milliseconds; the compile is what costs), and exit the process when that error signature appears so the orchestrator restarts it.
Profile mismatch. The XML declares its own profile in GuidelineSpecifiedDocumentContextParameter/ID (urn:cen.eu:en16931:2017 for EN 16931). If the caller says en16931 and the XML says basic, do not pick one: reject. A silent upgrade produces a file whose XMP claims a level the XML does not meet.
pypdf pins. factur-x needs pypdf 5.3 or newer. Our worker was pinned to 4.2, and the unit tests stub pypdf, so the pin change was green in CI and only a real file proved it. If your tests mock the PDF library, one live render belongs in the gate.
The self-hosted route
Three stacks, one shape: get a PDF/A-3, then embed. All three snippets below were run as written on 2026-09-08 — the Python script end to end, the Java fragment compiled and executed against Mustang 2.26.0, the PHP one against horstoeko/zugferd v1.0.132 — and each output passed veraPDF (PDF/A-3b) and Mustang validation. They are still snippets, not products: error handling and your invoice data are on you.
Python: Playwright + pikepdf + factur-x
One installation gotcha: on PyPI the package is factur-x with a hyphen — pip install facturx finds nothing.
pip install factur-x pikepdf playwright && playwright install chromium
import io
import pikepdf
from pikepdf import Name, Pdf
from playwright.sync_api import sync_playwright
from facturx import generate_from_binary
AFRELATIONSHIP = {"minimum": "data", "basicwl": "data",
"basic": "alternative", "en16931": "alternative",
"extended": "alternative"}
def render(html: str) -> bytes:
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.set_content(html, wait_until="networkidle")
pdf = page.pdf(format="A4", print_background=True)
browser.close()
return pdf
def to_pdfa3b(pdf_bytes: bytes, icc_path: str, title: str) -> bytes:
pdf = Pdf.open(io.BytesIO(pdf_bytes))
with open(icc_path, "rb") as fh:
icc = pikepdf.Stream(pdf, fh.read())
icc.N = 3
intent = pikepdf.Dictionary(
Type=Name.OutputIntent, S=Name("/GTS_PDFA1"),
OutputConditionIdentifier=pikepdf.String("sRGB"),
Info=pikepdf.String("sRGB IEC61966-2.1"),
DestOutputProfile=pdf.make_indirect(icc),
)
pdf.Root.OutputIntents = pikepdf.Array([pdf.make_indirect(intent)])
with pdf.open_metadata(set_pikepdf_as_editor=False) as meta:
meta["pdfaid:part"] = "3"
meta["pdfaid:conformance"] = "B"
meta["dc:title"] = title
out = io.BytesIO()
pdf.save(out)
return out.getvalue()
profile = "en16931"
xml_bytes = open("invoice.xml", "rb").read()
pdfa = to_pdfa3b(render(open("invoice.html").read()), "sRGB.icc", "Invoice INV-2026-0042")
final = generate_from_binary(pdfa, xml_bytes, flavor="factur-x", level=profile,
afrelationship=AFRELATIONSHIP[profile])
open("invoice-facturx.pdf", "wb").write(final)
Ship an sRGB ICC file in your image; pikepdf does not bundle one. Pin factur-x deliberately: the XSD set it bundles defines what you accept.
Java: Mustang (run and validated 2026-09-08; artifact org.mustangproject:library — 2.26.0 was the current release when this was written on 2026-09-07; check Maven Central for a newer 2.x)
<dependency>
<groupId>org.mustangproject</groupId>
<artifactId>library</artifactId>
<version>2.26.0</version>
</dependency>
import org.mustangproject.ZUGFeRD.ZUGFeRDExporterFromA3;
import java.nio.file.Files;
import java.nio.file.Path;
byte[] xml = Files.readAllBytes(Path.of("invoice.xml"));
ZUGFeRDExporterFromA3 exporter = new ZUGFeRDExporterFromA3()
.setProducer("my-billing")
.setCreator("my-billing")
.setProfile("EN16931") // no space here; Mustang writes "EN 16931" into the XMP itself
.load("invoice-pdfa3.pdf");
exporter.setXML(xml); // must come after setProfile: this call attaches the file
exporter.export("invoice-facturx.pdf");
Mustang writes the factur-x.xml attachment and the fx: XMP block for you, and its AFRelationship default is the opposite of the Python library's. In ZUGFeRDExporterFromA3 the relationship starts as Alternative and is lowered to Data only when the profile is MINIMUM or BASICWL — and only if you called setProfile first, because the check reads that field. Skip the call and a MINIMUM invoice ships as Alternative. Two more things the source says and the docs do not: ignorePDFAErrors() compiles on this class but changes nothing, because ensurePDFIsValid here returns true unconditionally and load() never inspects the input — it is the ...FromA1 exporter that validates and throws. And its default conformance level is UNICODE (conformanceLevel = PDFAConformanceLevel.UNICODE, with overwrite = true), so the output declares pdfaid:conformance=U — PDF/A-3U, not 3b. setConformanceLevel(PDFAConformanceLevel.BASIC) makes it declare B instead; whichever letter you ship, validate against the level you claim.
PHP: horstoeko/zugferd (run and validated 2026-09-08 against tag v1.0.132; composer require horstoeko/zugferd)
<?php
require 'vendor/autoload.php';
use horstoeko\zugferd\ZugferdDocumentPdfMerger;
$merger = new ZugferdDocumentPdfMerger('/path/invoice.xml', '/path/invoice.pdf');
$merger->setAttachmentRelationshipTypeToAlternative(); // default is 'Data' — the same trap again
$merger->generateDocument();
$merger->saveDocument('/path/invoice-facturx.pdf');
This library repeats the Python default: ZugferdDocumentPdfBuilderAbstract initialises the attachment relationship to Data, so an EN 16931 invoice carries Data unless you say otherwise. It reads the profile out of your XML rather than taking it as an argument, names the attachment from the resolved profile (factur-x.xml for the Factur-X profiles, xrechnung.xml for XRechnung), writes the fx: block with the spaced EN 16931, and emits its own sRGB OutputIntent — the pages of your input are re-imported into a fresh document (FPDI) and the PDF/A metadata is written from scratch, so the input is a plain PDF here, not a PDF/A-3. Whether the result passes veraPDF still depends on what is inside those pages, fonts first. Whatever your stack, run the checklist at the end on the output; the libraries agree on structure and disagree on defaults.
The hosted route (this is mine)
PDFik's version of the same pipeline is POST /einvoice-to-pdf: you send the CII XML, we build the visual invoice from a block template (or you keep your own HTML, see below), normalise to PDF/A-3, embed with the profile-correct relationship and metadata, and hand back a job id. The output is validated with veraPDF and Mustang in our release gate, on every plan including Free, with no watermark on account output. The Free plan takes no credit card to start.
# invoice.xml is your UN/CEFACT CII file (EN 16931 profile)
jq -n --rawfile xml invoice.xml '{xml: $xml, profile: "en16931"}' > request.json
curl -X POST https://api.pdfik.net/einvoice-to-pdf \
-H "X-API-Key: sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d @request.json
The response is 202 with {"job_id": "...", "status": "queued", ...}; poll GET /jobs/{job_id} and download from GET /jobs/{job_id}/download, or give a webhook_url. profile defaults to en16931. The XML is validated against the profile's XSD, its guideline URN must match the declared profile, and a rejected request costs nothing. Up to 1 MB of XML.
To keep your own design, add an einvoice block to POST /html-to-pdf or POST /url-to-pdf:
{
"html": "<html>...your invoice...</html>",
"einvoice": { "format": "factur-x", "profile": "en16931", "xml": "<rsm:CrossIndustryInvoice ..." }
}
It cannot be combined with user_password (PDF/A forbids encryption) or compression (re-saving breaks the PDF/A attributes); both are rejected with 422. The full reference is at docs.pdfik.net/einvoicing, and the homepage sandbox runs the same pipeline on pasted XML without an account, watermarked, a few renders per day.
When the hosted route is the wrong choice: PDFik runs on AWS in the United States, so if your invoices must not leave the EU, self-host. Same answer if you are air-gapped, if you already operate Mustang in a Java stack, or if your volume makes per-document pricing lose to your own servers. It also accepts CII only; UBL input is not supported today.
A validation checklist you can run
Run all four on every build, not once.
# 1. PDF/A-3b container
verapdf -f 3b --format xml invoice-facturx.pdf | grep -o 'isCompliant="[a-z]*"'
# 2. Factur-X structure + XML; read the LAST summary, never the exit code
java -jar Mustang-CLI.jar --no-notice --action validate --source invoice-facturx.pdf \
| grep -o '<summary status="[a-z]*"' | tail -1
# 3 + 4. The things the validators do not check for you
import re, pikepdf
pdf = pikepdf.open("invoice-facturx.pdf")
print([str(fs.get("/AFRelationship")) for fs in pdf.Root.AF]) # ['/Alternative'] for en16931
names = pdf.Root.Names.EmbeddedFiles.Names
print([str(names[i]) for i in range(0, len(names), 2)]) # ['factur-x.xml']
with pdf.open_metadata() as meta:
xmp = str(meta)
print(re.findall(r"fx:ConformanceLevel[^<]*", xmp)) # 'EN 16931', with the space
print("pdfaid:part" in xmp, pdf.Root.get("/OutputIntents") is not None)
Then the boring ones: the XML's guideline URN matches the level you wrote into XMP; TypeCode is 380 or 381; the PDF shows every amount the XML carries (otherwise Alternative is a lie); the file opens in Adobe Reader with the attachment visible; and a wrong profile, a DTD in the XML, and an unembedded font each fail your pipeline loudly instead of producing a file.
Library defaults, validator behaviour and mandate dates last verified: 2026-09-07 — factur-x 6.8, Mustang 2.26.0 (library and validator sources), horstoeko/zugferd v1.0.132, and the BMF, DGFiP and KSeF pages linked above. If something here is outdated, tell me and I'll fix it.
Top comments (0)