Jinja2PDF
E-invoice: ZUGFeRD and Factur-X from one template
A German invoice whose data is one _invoice block. The template prints the block, and the same block becomes the XML inside the PDF: a ZUGFeRD / Factur-X invoice, a PDF/A-3 that carries factur-x.xml, validated against the rules of EN 16931 before it is delivered.
No card needed to look: you get your own copy in the editor, with the invoice check and the preview. Live e-invoices need the Starter plan; on Free, test renders make up to 20 a day with the test mark.
Rendered result
What this example shows
settings.jsondeclareseinvoicewith the profileen16931, so every render of this template is an e-invoice: a PDF/A-3 with the invoice attached asfactur-x.xml. The request is the one of any other template.settings.jsonalso declarespdf.ua, so the same file is a PDF/UA-1 document: tagged, repaired, and held by veraPDF to the machine-checkable rules of PDF/UA-1 after it became the PDF/A-3.truemeans strict: a file that fails those rules is not delivered. The render carries the verdict asaccessibilitybesideeinvoice, and the two small tables useth scope="row", without which Chromium does not tag them as tables.- The data is one
_invoiceblock and the template prints from it: number, parties, lines, the VAT breakdown and the totals are stated once and end up on the page and in the XML. - Units and VAT categories are codes in the data (
HUR,S), as the standard wants them; the template turnsHURintoStd.for the reader, andmoneyprints3.760,40 €from3760.4. - Before the file is delivered, the XML is held to the rules of EN 16931 and the PDF to PDF/A-3b, and the render answers with the report in
einvoice.validation. A total that does not add up fails the request witheinvoice_data_invalidand names the rule it breaks. - The text of the finished PDF is then compared with the XML: a number or total the page does not show is listed in
einvoice.display.missing, because the XML is the invoice and the page must not say something else. - Live e-invoices are part of the Starter plan and above and add one unit to the render. On the Free plan, test renders make up to 20 e-invoices a day, each with the test mark on its pages; a live request is answered with
403. On the paid plans test e-invoices carry the mark too and are counted per day: 100 on Starter, 500 on Pro, 2,000 on Business. Formfeed creates and validates the file; sending it, archiving it and its VAT treatment stay with you.
Helpers used
Template folder
{% set inv = _invoice %}
{% set units = { HUR: 'Std.', DAY: 'Tage', C62: 'Stk.' } %}
<p class="sender">{{ inv.seller.name }} · {{ inv.seller.address.street }} · {{ inv.seller.address.postcode }} {{ inv.seller.address.city }}</p>
<address class="recipient">
<strong>{{ inv.buyer.name }}</strong><br>
{{ inv.buyer.address.street }}<br>
{{ inv.buyer.address.postcode }} {{ inv.buyer.address.city }}
</address>
<table class="facts">
<tr><th scope="row">Rechnungsnummer</th><td>{{ inv.number }}</td></tr>
<tr><th scope="row">Rechnungsdatum</th><td>{{ inv.issue_date | date('dd.MM.yyyy') }}</td></tr>
<tr><th scope="row">Leistungsdatum</th><td>{{ inv.delivery.date | date('dd.MM.yyyy') }}</td></tr>
</table>
<h1>Rechnung {{ inv.number }}</h1>
<table class="lines">
<thead>
<tr><th>Pos.</th><th>Leistung</th><th class="num">Menge</th><th class="num">Einzelpreis</th><th class="num">USt.</th><th class="num">Netto</th></tr>
</thead>
<tbody>
{% for line in inv.lines %}
<tr>
<td>{{ line.id }}</td>
<td>{{ line.name }}</td>
<td class="num">{{ line.quantity | number(0) }} {{ units[line.unit_code] }}</td>
<td class="num">{{ line.net_price | money }}</td>
<td class="num">{{ line.tax.rate }} %</td>
<td class="num">{{ line.net_amount | money }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<table class="totals">
<tr><th scope="row">Netto</th><td class="num">{{ inv.totals.tax_basis | money }}</td></tr>
{% for tax in inv.tax.breakdown %}
<tr><th scope="row">USt. {{ tax.rate }} %</th><td class="num">{{ tax.amount | money }}</td></tr>
{% endfor %}
<tr class="grand"><th scope="row">Rechnungsbetrag</th><td class="num">{{ inv.totals.grand | money }}</td></tr>
</table>
<p class="pay">
{{ inv.payment.terms }}<br>
<span class="muted">IBAN {{ inv.payment.iban }} · Verwendungszweck {{ inv.payment.reference }}</span>
</p>body { font-family: 'Inter', sans-serif; font-size: 9.5pt; line-height: 1.45; color: #1e293b; }
.sender {
display: inline-block;
margin: 10mm 0 3mm;
padding-bottom: 1mm;
border-bottom: 1px solid #cbd5e1;
font-size: 7pt;
color: #64748b;
}
.recipient { min-height: 27mm; font-style: normal; line-height: 1.5; }
.facts { float: right; margin-top: -27mm; font-size: 8.5pt; color: #475569; }
.facts td { padding: 0.4mm 0 0.4mm 5mm; }
.facts td:last-child { text-align: right; color: #1e293b; }
h1 { clear: both; margin: 14mm 0 4mm; font-size: 16pt; color: #312e81; }
table.lines { width: 100%; margin-top: 6mm; border-collapse: collapse; }
.lines th {
padding: 2mm 1.5mm;
border-bottom: 1.5px solid #1e293b;
text-align: left;
font-size: 7.5pt;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #64748b;
}
.lines td { padding: 2.3mm 1.5mm; border-bottom: 1px solid #e2e8f0; vertical-align: top; }
.lines tr { break-inside: avoid; }
.num { text-align: right !important; white-space: nowrap; }
.totals { width: 78mm; margin: 5mm 0 0 auto; border-collapse: collapse; }
.totals td { padding: 1.2mm 1.5mm; }
.totals .grand td { border-top: 1.5px solid #1e293b; font-weight: 700; font-size: 11pt; }
.muted { color: #64748b; }
.facts th, .totals th { font-weight: 400; text-align: left; }
.facts th { padding: 0.4mm 0 0.4mm 5mm; }
.totals th { padding: 1.2mm 1.5mm; }
.totals .grand th { border-top: 1.5px solid #1e293b; font-weight: 700; font-size: 11pt; }
.pay { margin-top: 12mm; }<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"><div style="box-sizing:border-box;width:100%;display:flex;justify-content:space-between;gap:6mm;padding:0 18mm;font-size:7px;line-height:1.5;color:#555">
<span>{{ _invoice.seller.name }} · {{ _invoice.seller.legal_info }} · USt-IdNr. {{ _invoice.seller.vat_id }}</span>
<span>Seite <span class="pageNumber"></span> von <span class="totalPages"></span></span>
</div>{
"pdf": {
"ua": true,
"metadata": {
"title": "Rechnung {{ _invoice.number }}"
}
},
"paper": {
"format": "A4"
},
"margin": {
"top": "20mm",
"right": "18mm",
"bottom": "24mm",
"left": "18mm"
},
"locale": "de-DE",
"timezone": "Europe/Berlin",
"currency": "EUR",
"einvoice": {
"profile": "en16931",
"flavour": "zugferd"
},
"footer": {
"height": "14mm"
}
}{
"_invoice": {
"number": "RE-2026-0042",
"issue_date": "2026-09-10",
"currency": "EUR",
"seller": {
"name": "Fennlor Studio GmbH",
"vat_id": "DE000000000",
"legal_info": "Amtsgericht Musterstadt, HRB 00000 · Geschäftsführerin Erika Mustermann",
"address": {
"street": "Musterstraße 1",
"postcode": "12345",
"city": "Musterstadt",
"country": "DE"
},
"contact": {
"name": "Erika Mustermann",
"phone": "+49 000 0000000",
"email": "rechnung@fennlor.example"
},
"electronic_address": "rechnung@fennlor.example"
},
"buyer": {
"name": "Olvarest GmbH",
"address": {
"street": "Beispielweg 2",
"postcode": "54321",
"city": "Beispielstadt",
"country": "DE"
},
"electronic_address": "eingang@olvarest.example"
},
"delivery": {
"date": "2026-09-10"
},
"lines": [
{
"id": "1",
"name": "Beratung",
"quantity": 8,
"unit_code": "HUR",
"net_price": 120,
"net_amount": 960,
"tax": {
"category": "S",
"rate": 19
}
},
{
"id": "2",
"name": "Umsetzung",
"quantity": 20,
"unit_code": "HUR",
"net_price": 110,
"net_amount": 2200,
"tax": {
"category": "S",
"rate": 19
}
}
],
"tax": {
"breakdown": [
{
"category": "S",
"rate": 19,
"basis": 3160,
"amount": 600.4
}
]
},
"totals": {
"line_net": 3160,
"tax_basis": 3160,
"tax_total": 600.4,
"grand": 3760.4,
"due": 3760.4
},
"payment": {
"means_code": "58",
"iban": "DE36000000000000000000",
"reference": "RE-2026-0042",
"terms": "Zahlbar bis zum 24.09.2026 ohne Abzug.",
"due_date": "2026-09-24"
}
}
}Render it through the API
Save the sample data as data.json and send it with the template’s name. TypeScript and Python use the SDKs; every other language posts the same JSON to the REST API with its standard library. Renders with a key that starts with ff_test_ are not metered, and a test e-invoice carries the test mark on every plan; live e-invoices need the Starter plan or above.
jq '{template: "e-rechnung", data: ., filename: "rechnung-RE-2026-0042.pdf"}' data.json \
| curl https://api-eu.formfeed.dev/v1/renders \
-H "Authorization: Bearer ff_test_..." \
-H "Content-Type: application/json" \
--data-binary @-import { Formfeed } from '@formfeed/sdk';
import data from './data.json' with { type: 'json' };
const formfeed = new Formfeed({ apiKey: process.env.FORMFEED_API_KEY! });
const render = await formfeed.renders.create({
template: 'e-rechnung',
data,
filename: 'rechnung-RE-2026-0042.pdf',
});
console.log(render.status, render.download_url);import json
import os
from formfeed import Formfeed
formfeed = Formfeed(api_key=os.environ["FORMFEED_API_KEY"])
with open("data.json", encoding="utf-8") as file:
data = json.load(file)
render = formfeed.renders.create(
template="e-rechnung",
data=data,
filename="rechnung-RE-2026-0042.pdf",
)
print(render.status, render.download_url)<?php
$body = [
'template' => 'e-rechnung',
'data' => json_decode(file_get_contents('data.json'), true),
'filename' => 'rechnung-RE-2026-0042.pdf',
];
$curl = curl_init('https://api-eu.formfeed.dev/v1/renders');
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('FORMFEED_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($body),
]);
$render = json_decode(curl_exec($curl), true);
echo $render['status'], ' ', $render['download_url'], PHP_EOL;package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
func main() {
data, err := os.ReadFile("data.json")
if err != nil {
panic(err)
}
body, _ := json.Marshal(map[string]any{
"template": "e-rechnung",
"data": json.RawMessage(data),
"filename": "rechnung-RE-2026-0042.pdf",
})
request, _ := http.NewRequest("POST", "https://api-eu.formfeed.dev/v1/renders", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+os.Getenv("FORMFEED_API_KEY"))
request.Header.Set("Content-Type", "application/json")
response, err := http.DefaultClient.Do(request)
if err != nil {
panic(err)
}
defer response.Body.Close()
var render struct {
Status string `json:"status"`
DownloadURL string `json:"download_url"`
}
json.NewDecoder(response.Body).Decode(&render)
fmt.Println(render.Status, render.DownloadURL)
}using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
var body = new
{
template = "e-rechnung",
data = JsonDocument.Parse(File.ReadAllText("data.json")).RootElement,
filename = "rechnung-RE-2026-0042.pdf",
};
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("FORMFEED_API_KEY"));
var response = await http.PostAsJsonAsync("https://api-eu.formfeed.dev/v1/renders", body);
var render = await response.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine($"{render.GetProperty("status")} {render.GetProperty("download_url")}");import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
public class Render {
public static void main(String[] args) throws Exception {
String data = Files.readString(Path.of("data.json"));
String body = """
{
"template": "e-rechnung",
"filename": "rechnung-RE-2026-0042.pdf",
"data": %s
}
""".formatted(data);
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api-eu.formfeed.dev/v1/renders"))
.header("Authorization", "Bearer " + System.getenv("FORMFEED_API_KEY"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body()); // status, download_url, page_count, units …
}
}require "json"
require "net/http"
body = {
template: "e-rechnung",
data: JSON.parse(File.read("data.json")),
filename: "rechnung-RE-2026-0042.pdf",
}
response = Net::HTTP.post(
URI("https://api-eu.formfeed.dev/v1/renders"),
JSON.generate(body),
"Authorization" => "Bearer #{ENV.fetch("FORMFEED_API_KEY")}",
"Content-Type" => "application/json"
)
render = JSON.parse(response.body)
puts render["status"], render["download_url"]More ways to call it
// "both" stores the XML beside the PDF as well, for a system that takes the XML alone
const render = await formfeed.renders.create({
template: 'e-rechnung',
data,
post: { einvoice: { xml: 'both' } },
});
console.log(render.einvoice?.xml_url);# check every data set against the invoice rules, offline
formfeed validate
# the XML alone, offline, from data/default.json
formfeed einvoice e-rechnung --out factur-x.xmlStart rendering today
The free plan includes 100 units a month and free test renders. Change plans when you need more.
