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.

Patrick Jerominek

Patrick Jerominek

Cofounder xhub.io

April 14, 20268 min reading time
Customize invoice templates via API: BlockTemplate tutorial

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

Similar Articles

Ready to master e-invoicing?

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