TutorialInvoice TemplateAPI

Customize invoice templates via API: BlockTemplate tutorial

How to generate PDF invoices with logo, brand colors and dynamic VAT rows using JSON only. Incl. EN 16931 compliance and ZUGFeRD pitfalls.

Customize invoice templates via API: BlockTemplate tutorial
Patrick Jerominek

Patrick Jerominek

Cofounder xhub.io

April 14, 2026
8 min reading time

Many invoice APIs lock you into rigid layouts: logo top-left, no discount column, no control over colors. Invoice-api.xhub goes the other way β€” PDF invoices are described by a JSON-based BlockTemplate. The result is versioned, diffable, and reproducible through a CI pipeline.

In this tutorial I'll show you how to build your own template in 10 minutes, embed your logo, apply brand colors via design tokens, and render VAT rows correctly β€” including the pitfalls around EN 16931 and ZUGFeRD.

πŸ“š Full reference: All schemas and block types are documented in the invoice templates docs.


BlockTemplate in 30 seconds

A BlockTemplate is a JSON document with five areas:

json
1{
2 "version": "1.0.0",
3 "name": "my-invoice",
4 "page": { /* page size, margins, design tokens */ },
5 "header": { /* optional letterhead β€” on every page */ },
6 "body": { "blocks": [ /* the actual content */ ] },
7 "footer": { /* optional footer area */ },
8 "styles": { /* named presets */ }
9}

The body holds an array of nine block types: text, table, keyvalue, summary, image, spacer, line, qrcode, and columns. Each block shares the same envelope { type, data } β€” and an optional condition that hides it when its placeholder is empty.

Step 1 β€” Define design tokens

Instead of repeating hex values across every block, declare them once in page.colorScheme:

json
1{
2 "page": {
3 "size": "A4",
4 "margins": { "top": 40, "right": 40, "bottom": 40, "left": 40 },
5 "colorScheme": {
6 "primary": "#0ea5e9",
7 "text": "#111827",
8 "muted": "#6b7280",
9 "line": "#e5e7eb"
10 },
11 "fontSizes": { "body": 10, "heading": 18, "small": 8 }
12 }
13}

Blocks reference them via { token: "…" }:

json
1{ "type": "text", "data": { "content": "INVOICE", "color": { "token": "primary" } } }

When the brand color changes, you swap a single value β€” the whole template follows.

Step 2 β€” Logo in the header

A two-column header: company name on the left, logo on the right. repeatOnAllPages: true turns it into a letterhead on every page.

json
1{
2 "header": {
3 "height": 90,
4 "repeatOnAllPages": true,
5 "blocks": [{
6 "type": "columns",
7 "data": {
8 "columnGap": 20,
9 "columns": [
10 { "width": "*", "blocks": [
11 { "type": "text", "data": { "content": "{{seller.name}}", "bold": true, "fontSize": { "token": "heading" } } }
12 ]},
13 { "width": "auto", "blocks": [
14 { "type": "image", "data": { "src": "https://cdn.example.com/logo.png", "fit": [140, 70], "alignment": "right" } }
15 ]}
16 ]
17 }
18 }]
19 }
20}

The src accepts a URL, a base64 data URI, or β€” as here β€” a placeholder that is supplied per tenant from the invoice payload.

Step 3 β€” Line items with a discount column

The table block binds to {{items}}. Each column declares field, header, width, and optionally format: "currency" | "percent" | "number":

json
1{
2 "type": "table",
3 "data": {
4 "dataSource": "{{items}}",
5 "repeatHeader": true,
6 "layout": "lightHorizontalLines",
7 "headerFillColor": { "token": "primary" },
8 "headerTextColor": "#FFFFFF",
9 "columns": [
10 { "field": "articleNumber", "header": "Art. No.", "width": "15%" },
11 { "field": "description", "header": "Description", "width": "*" },
12 { "field": "quantity", "header": "Qty", "width": "10%", "align": "center" },
13 { "field": "unitPrice", "header": "Unit price", "width": "15%", "align": "right", "format": "currency" },
14 { "field": "netAmount", "header": "Net", "width": "15%", "align": "right", "format": "currency" }
15 ]
16 }
17}

Column widths accept pixel values, percentages, auto, or * (fills remaining space).

Step 4 β€” Dynamic VAT rows

This is the part many templates hard-code β€” and then break the moment an invoice has mixed VAT rates or reverse-charge. The summary block with dynamicRows iterates over {{taxSummary}}:

json
1{
2 "type": "summary",
3 "data": {
4 "alignment": "right",
5 "headerRows": [
6 { "label": "Net total", "value": "{{subtotal}}", "valueFormat": "currency" }
7 ],
8 "dynamicRows": {
9 "dataSource": "{{taxSummary}}",
10 "valueField": "taxAmount",
11 "valueFormat": "currency",
12 "labelTemplate": "VAT {{taxRate}}%"
13 },
14 "footerRows": [
15 { "label": "Total due", "value": "{{total}}", "bold": true, "valueFormat": "currency", "separator": true }
16 ]
17 }
18}

An invoice with 7% + 19% automatically renders two rows. Reverse-charge yields taxAmount: 0 plus the mandatory notice as a conditional text block.

Step 5 β€” Use the template

Two ways to hand your template to the API:

1. As a saved template (recommended):

bash
1curl -X POST https://service.invoice-api.xhub.io/api/v1/invoice/de/zugferd/generate \
2 -H "Authorization: Bearer $XHUB_API_KEY" \
3 -d '{
4 "templateId": "b3c9a0d8-4f2e-4a1c-9e87-0d2f1a5b6c7d",
5 "invoice": { /* creator payload */ }
6 }'

2. Inline β€” for CI tests or dynamic layouts:

bash
1curl -X POST https://service.invoice-api.xhub.io/api/v1/invoice/de/zugferd/generate \
2 -H "Authorization: Bearer $XHUB_API_KEY" \
3 -d '{
4 "invoice": { /* ... */ },
5 "formatOptions": { "template": { /* inline BlockTemplate */ } }
6 }'

If both are provided, precedence is templateId > formatOptions.template > default.


Compliance pitfalls

Three things that are easy to miss:

  1. Mandatory fields (Β§ 14 UStG / EN 16931). Invoice number, issue date, delivery date, VAT ID or tax number, amounts per VAT rate β€” none may be missing. The validator checks this on /generate, but a defensive template never drops those blocks in the first place.
  2. Leitweg-ID for B2G. Invoices to German public authorities require a Leitweg-ID. In the payload it lives under countrySpecific.leitwegId (DE-specific). Using condition: "{{countrySpecific.leitwegId}}" the block renders only for B2G β€” the same template keeps working for B2B.
  3. ZUGFeRD is not just the PDF. The BlockTemplate is the visible layer. The structured EN-16931 XML is automatically embedded into PDF/A-3 on /generate. Both views must show identical numbers β€” that's why both come from the same invoice payload.

The full compliance matrix covering reverse-charge, small-business scheme, and Factur-X lives in the compliance docs.


Closing thoughts

BlockTemplate is not a replacement for a design tool β€” it's an API-first approach for teams that treat invoices like code: versioned, reviewable, testable in CI. For one-off layout tweaks a WYSIWYG editor may be faster. For 50 tenants with distinct logos and colors rolled out through a single API call, JSON wins.

Further reading:


FAQ

Do I need a separate template per tenant layout? No β€” one template with design tokens is enough. Per tenant, you override only page.colorScheme and the logo. For 50 tenants you keep a single template structure; only the tokens change.

Can I version BlockTemplate and test it in CI? Yes. Because the template is a JSON document, it flows through Git like code: diffs stay readable, code review works, and a CI snapshot test can hash the rendered PDF after every commit and flag unintended changes.

What's the difference between templateId and formatOptions.template? templateId references a template stored in the console or via the pdf.templateCreate tRPC mutation (recommended for stable production). formatOptions.template passes BlockTemplate JSON inline β€” handy for tests, CI and dynamically generated layouts. When both are provided, templateId wins.

Does BlockTemplate also work for Peppol and XRechnung β€” or only PDF/ZUGFeRD? BlockTemplate only controls the visual PDF layer. The structured XML for XRechnung or Peppol BIS 3.0 comes from the invoice payload. For ZUGFeRD and Factur-X the EN 16931 XML is automatically embedded into the PDF/A-3 during /generate (hybrid invoice).

Where do I find all available placeholders? In the placeholder reference. Every field from the creator payload is available as a {{mustache}} placeholder β€” from {{invoiceNumber}} through {{seller.bankAccount.iban}} to {{countrySpecific.leitwegId}}.

Share article
Patrick Jerominek

Written by

Patrick Jerominek

Cofounder xhub.io

Builds APIs that developers love. Writes about e-invoicing, TypeScript, and everything in between.

Similar Articles

Ready to master e-invoicing?

Get started in under 5 minutes with Invoice-api.xhub. No credit card required.