Skip to content

ZUGFeRD and Factur-X e-invoices

A ZUGFeRD / Factur-X e-invoice is a regular PDF with the structured invoice data embedded as XML, plus XMP metadata declaring which profile that XML follows. Oicana produces all three parts from a Typst template. You supply the invoice XML, and Oicana renders the PDF, embeds the XML, and writes the metadata.

This guide builds the smallest setup that still produces a valid e-invoice, so you have a working baseline to iterate on. The visible PDF is almost empty on purpose. The focus is the plumbing: exporting the right PDF standards, embedding the XML, and passing data from an integration.

This guide uses the oicana CLI to compile and pack the template, so install it first.

The file embedding and custom metadata is wrapped in the open source invoice-harness package, which gives you a single factur-x(...) call. invoice-harness is not on the Typst package registry yet, so install it as a local package following the instructions in its repository. After that, @local/invoice-harness:0.1.1 is importable from your template.

A valid e-invoice needs a Cross Industry Invoice (CII) XML document that conforms to the profile you declare. Hand-writing one is error prone, so start from an official reference sample.

The invoice-harness repository ships one validated sample per profile under tests/invoices/<profile>/factur-x.xml, taken from the ZUGFeRD corpus. We use the EN 16931 profile, the European baseline most national mandates build on. Copy that sample into your template directory:

tests/invoices/en16931/factur-x.xml -> factur-x.xml

The manifest wires up two things: the PDF standards and the inputs.

typst.toml
[package]
name = "minimal_e_invoice"
version = "0.1.0"
entrypoint = "main.typ"
[tool.oicana]
manifest_version = 1
[tool.oicana.export.pdf]
standards = ["ua-1", "a-3b"]
# Structured invoice fields drive the visible PDF.
[[tool.oicana.inputs]]
type = "json"
key = "invoice"
development = "invoice.json"
# The CII document that gets embedded into the PDF.
[[tool.oicana.inputs]]
type = "blob"
key = "zugferd"
required = false
development = { file = "factur-x.xml" }

The choices behind this manifest:

  • standards = ["ua-1", "a-3b"]: a-3b is the archivable PDF/A profile that allows embedded files, which makes the file a valid e-invoice. Adding ua-1 makes the same document accessible (PDF/UA-1). Both build on PDF 1.7, so they combine. See Export Formats.
  • zugferd is an optional blob input with a development value. The sample XML lets the template compile on its own during development, while in production an integration passes a fresh XML per invoice. required = false means a missing XML is not an error: the template just skips the embedding and renders a plain PDF.
  • invoice is a json input with a development value, so the editor preview has data to show. It feeds the human-readable side of the PDF.

Create the invoice.json development value next to the manifest:

invoice.json
{
"id": "2026-0001",
"customer": "ACME Corp",
"total": "1190.00 EUR"
}

The template stays deliberately bare. It embeds the XML with one call and renders just enough to be a recognizable document.

main.typ
#import "@preview/oicana:0.2.0": setup
#import "@local/invoice-harness:0.1.1": *
#let read-project-file(path) = read(path, encoding: none)
#let (input, _, _) = setup(read-project-file)
#set document(title: "Invoice " + input.invoice.id, date: datetime.today())
#if input.zugferd != none {
factur-x(input.zugferd.bytes, profiles.en16931)
}
= Invoice #input.invoice.id
Billed to #input.invoice.customer.
*Total: #input.invoice.total*

What each part does:

  • factur-x(input.zugferd.bytes, profiles.en16931) takes the bytes of the zugferd blob input, embeds them as the associated factur-x.xml, and declares the EN 16931 profile in the XMP. That call is the whole e-invoice machinery. It is guarded by if input.zugferd != none so the template still produces a plain PDF when no XML is passed.
  • set document(title: ...) is required for PDF/UA-1. If you add images later, give each one alt text for the same reason.
  • Everything below is ordinary Typst. Grow it into a real invoice layout at your own pace.

With the oicana CLI in the template directory:

Terminal window
oicana validate # check the manifest and that fallbacks fit their schemas
oicana compile --development # render a PDF into ./output using the development values
oicana pack # produce minimal_e_invoice-0.1.0.zip

Let’s make sure the PDF created by oicana compile --development is a valid e-invoice.

Three things have to line up: PDF/A-3 conformance, the embedded XML, and the XMP metadata. A mistake in any of them makes a file that systems could reject, so validate every change with a tool. Here some options:

  • portinvoice.com: a free, vendor-neutral online validator. Upload the PDF and it checks the embedded XML against EN 16931 and reports the detected profile. Convenient while iterating.
  • KoSIT validator: the official German government reference validator. Run it locally when you need the authoritative verdict.
  • Mustangproject: open source, runs offline, and validates both the PDF/A side (via veraPDF) and the CII schema plus EN 16931 Schematron. This can be a good choice to run in CI.
  • veraPDF: the industry-standard PDF/A validator if you only want to check the archival conformance of the PDF.

In an application you register the packed template once, then per request pass the invoice fields as the invoice JSON input and the XML bytes as the zugferd blob input.

import { initialize, Template, type BlobWithMetadata } from '@oicana/browser';
import wasmUrl from '@oicana/browser-wasm/oicana_browser_wasm_bg.wasm?url';
await initialize(wasmUrl);
const templateResponse = await fetch('/minimal_e_invoice-0.1.0.zip');
const template = new Template(new Uint8Array(await templateResponse.arrayBuffer()));
const xmlResponse = await fetch('/factur-x.xml');
const xml = new Uint8Array(await xmlResponse.arrayBuffer());
const jsonInputs = new Map<string, string>();
jsonInputs.set('invoice', JSON.stringify({
id: '2026-0001',
customer: 'ACME Corp',
total: '1190.00 EUR',
}));
const blobInputs = new Map<string, BlobWithMetadata>();
blobInputs.set('zugferd', { bytes: xml });
const pdf = template.export(jsonInputs, blobInputs);

For the framework around these calls (loading the template at startup, serving the PDF over HTTP) follow the getting started chapter for your stack. The code snippets are not complete and in most cases are missing required boilerplate that is unrelated to Oicana.

You now have code that can produce an e-invoice that passes validation. To turn it into a real e-invoice setup:

  1. Generate the XML from your data, so the embedded invoice and the visible PDF always describe the same thing instead of embedding a fixed sample. There are many libraries that can produce these xml files in the different language ecosystems.
  2. Flesh out the visible layout into a proper invoice. The invoice_zugferd example template can be a more complete reference.
  3. Pick the right profile for your obligations (MINIMUM, BASIC, EN 16931, XRECHNUNG, and more) and pass the matching value to factur-x.