DynamicDocumentAPI

Template language

Jinja syntax, autoescaping, built-in filters and every custom filter for money, dates, QR codes, barcodes, charts and layout, with sandbox limits.

View as Markdown

Templates are written in Jinja syntax: HTML and CSS with expressions such as {{ customer.name }} and blocks such as {% for item in items %}. The engine is a Rust implementation built on MiniJinja, extended with filters for documents. The dashboard preview and the production renderer use the same engine, so what you see in the editor is what the API produces.

Where the template language runs

  • The body and head of a template, and input.html and input.head for HTML renders (when templating is on, which is the default as soon as you send data)
  • Header and footer HTML and simple-mode text
  • Markdown input, before the Markdown is converted to HTML
  • Output filenames, where your data is available under data, as in invoice-{{ data.number }}.pdf

Syntax

Variables

Keys of the data object are available as top-level variables. Use dots or brackets for nested values:

<h1>Invoice {{ invoice.number }}</h1>
<p>{{ customer["name"] }} · {{ items[0].description }}</p>

The whole object is also available as data, and render describes the render itself:

VariableDescription
render.id, render.created_at, render.template_id, render.testThe render ID, its time (RFC 3339), the template ID (empty for HTML, URL and Markdown renders) and whether it is a test render
render.invoiceThe invoice amounts computed by the platform (line net amounts, VAT breakdown, totals) for templates with invoice data; see Print the computed amounts
render.einvoiceThe same amounts plus the profile, only when the PDF embeds an e-invoice

Filters

Filters transform a value. They take arguments and can be chained:

{{ customer.name | upper }}
{{ note | default("No notes") | truncate(80) }}
{{ items | map(attribute="quantity") | sum }}

Tests

Tests ask a question about a value with is:

{% if discount is defined and discount > 0 %}…{% endif %}
{% if item.note is none %}…{% endif %}
{% if loop.index is even %}…{% endif %}

Available tests include defined, undefined, none, boolean, true, false, number, integer, float, string, sequence, mapping, iterable, odd, even, divisibleby(n), lower, upper, sameas, in, and the comparison tests eq, ne, lt, le, gt and ge.

Conditions

{% if invoice.status == "paid" %}
  <span class="badge badge-paid">Paid</span>
{% elif invoice.status == "overdue" %}
  <span class="badge badge-overdue">Overdue</span>
{% else %}
  <span class="badge">Open</span>
{% endif %}

Expressions support ==, !=, <, <=, >, >=, and, or, not, in, arithmetic (+, -, *, /, //, %, **), string concatenation with ~, and an inline conditional:

{{ "Paid" if invoice.status == "paid" else "Due" }}

Loops

<tbody>
  {% for item in items %}
    <tr class="{{ loop.cycle('odd', 'even') }}">
      <td>{{ loop.index }}</td>
      <td>{{ item.description }}</td>
      <td>{{ item.amount | format_currency("EUR", "de-DE") }}</td>
    </tr>
  {% else %}
    <tr><td colspan="3">No items</td></tr>
  {% endfor %}
</tbody>

Inside a loop, loop describes the iteration:

VariableValue
loop.index, loop.index0Position, counting from 1 or from 0
loop.revindex, loop.revindex0Position counted from the end
loop.first, loop.lastWhether this is the first or last iteration
loop.lengthNumber of items
loop.cycle(a, b, …)Cycles through the given values
loop.previtem, loop.nextitemThe neighbouring items
loop.changed(value)true when the value differs from the previous iteration
loop.depth, loop.depth0Nesting level in a recursive loop

You can filter and unpack while looping:

{% for item in items if item.quantity > 0 %}…{% endfor %}
{% for key, value in totals.items() %}…{% endfor %}

The else block runs when the sequence is empty, which is a tidy way to handle empty tables.

Assignments

{% set subtotal = items | sum_by("amount") %}
{% set tax = subtotal * invoice.tax_rate %}

Variables set inside a loop don't survive the iteration. Use a namespace to accumulate across iterations:

{% set totals = namespace(weight=0) %}
{% for parcel in parcels %}
  {% set totals.weight = totals.weight + parcel.weight %}
{% endfor %}
<p>Total weight: {{ totals.weight | format_number("en-GB", 2) }} kg</p>

Block assignments capture markup:

{% set address %}
  {{ customer.street }}<br>
  {{ customer.postcode }} {{ customer.city }}
{% endset %}

with

with scopes one or more variables to a block:

{% with total = items | sum_by("amount") %}
  <p>Total: {{ total | format_currency("EUR", "en-IE") }}</p>
{% endwith %}

Macros and call

Macros are reusable snippets:

{% macro money(amount, currency, locale) -%}
  <span class="amount">{{ amount | format_currency(currency, locale) }}</span>
{%- endmacro %}

<td>{{ money(item.amount, invoice.currency, invoice.locale) }}</td>

call passes a block of markup to a macro, which renders it with caller():

{% macro panel(title) %}
  <section class="panel"><h2>{{ title }}</h2>{{ caller() }}</section>
{% endmacro %}

{% call panel("Payment details") %}
  <p>IBAN: {{ payment.iban }}</p>
{% endcall %}

Raw blocks

Everything inside raw is copied verbatim, which is useful when the document itself talks about template syntax:

{% raw %}Write {{ customer.name }} to insert the customer name.{% endraw %}

Whitespace control

A minus sign next to a block delimiter strips the whitespace on that side. This matters in HTML where stray spaces can affect inline layout:

{%- for tag in tags -%}
  {{ tag }}{% if not loop.last %}, {% endif %}
{%- endfor -%}

Comments

{# Totals come from the billing system and are not recalculated here #}

Comments never appear in the output.

Includes and imports

Shared partials at workspace level, such as a legal footer or an address block, are planned. They will be versioned like templates and available through include and import. Templates cannot read files from disk or fetch other templates.

Autoescaping

HTML sources are autoescaped: characters such as <, > and & in your data become HTML entities, so data can never inject markup by accident. The head, the header and the footer are escaped too.

Markdown templates are escaped the same way, and a value inside a code span or a fenced code block prints exactly as your data has it: {{ release.install_command }} in a code block shows npm install x && y migrate, not &amp;&amp;.

To output HTML that you trust, mark it safe:

{{ product.description_html | safe }}

Only use safe for content you control. For text written by users, use the markdown filter instead, which sanitizes its output.

Filters that build markup — qrcode, barcode, render_chart, table, markdown, nl2br, page_break() and the render_* aliases — return safe HTML already, so you never need safe with them. tojson produces JSON that is safe to embed in HTML.

Undefined values

Missing variables render as an empty string, and so does attribute access on a missing value: {{ customer.address.city }} is empty when address isn't there. This keeps a missing field from breaking a whole document.

Two ways to handle gaps deliberately:

{{ customer.vat_id | default("—") }}
{% if customer.vat_id is defined %}VAT {{ customer.vat_id }}{% endif %}

A JSON null is a defined value, so default doesn't replace it unless you also pass true:

{{ customer.vat_id | default("—", true) }}

To catch typos and missing data during development, set strict_undefined: true in the render request. Referencing an undefined variable then fails with template_runtime_error and the path that was missing, instead of rendering an empty space.

Python method compatibility

Templates written for Python Jinja2 often call Python methods on strings and dictionaries. These work here too:

Strings: capitalize, count, endswith, find, format, isalnum, isalpha, isascii, islower, isnumeric, isspace, isupper, join, lower, lstrip, replace, rfind, rstrip, split, splitlines, startswith, strip, title, upper

Maps: get, items, keys, values

{{ customer.email.strip().lower() }}
{{ "{} of {}".format(loop.index, items | length) }}
{{ settings.get("currency", "EUR") }}
{% if order.reference.startswith("TEST-") %}…{% endif %}
{% for key, value in totals.items() %}…{% endfor %}

Values are immutable, so list methods that modify in place, such as append, are not available. Build lists with filters, or accumulate with namespace.

Built-in filters

The Jinja2 built-ins are available. The most useful ones for documents:

FilterDescriptionExample
absAbsolute value{{ -3 | abs }}
attr(name)Look up an attribute by name{{ item | attr("sku") }}
batch(n)Split a sequence into chunks of n{% for row in items | batch(3) %}
capitalizeFirst letter upper case, rest lower case{{ "hello world" | capitalize }}
count, lengthNumber of items or characters{{ items | length }}
default(value), d(value)Fallback for undefined values{{ note | default("—") }}
dictsortSort a mapping by key{% for k, v in totals | dictsort %}
escape, eEscape HTML{{ raw_text | escape }}
first, lastFirst or last item{{ items | first }}
float, intConvert to a number{{ "12.50" | float }}
format(…)Printf-style formatting{{ "%s of %s" | format(3, 10) }}
groupby(attribute)Group a list by an attribute{% for g in items | groupby("category") %}
indent(n)Indent lines{{ text | indent(4) }}
itemsKey-value pairs of a mapping{% for k, v in totals | items %}
join(separator)Join a sequence into a string{{ tags | join(", ") }}
listConvert to a list{{ "abc" | list }}
lower, upper, titleChange case{{ name | upper }}
map(attribute=…)Pick an attribute from every item{{ items | map(attribute="sku") | join(", ") }}
max, minLargest or smallest value{{ prices | max }}
randomA random item (avoid in documents that must be reproducible){{ quotes | random }}
reject, selectKeep items that fail or pass a test{{ numbers | select("odd") | list }}
rejectattr, selectattrThe same, by attribute{{ items | selectattr("taxable") | list }}
replace(old, new)Replace text{{ phone | replace(" ", "") }}
reverseReverse a sequence{{ items | reverse | list }}
round(precision)Round a number{{ 3.14159 | round(2) }}
safeMark a string as HTML{{ snippet | safe }}
slice(n)Split into n columns{% for column in items | slice(2) %}
sort(attribute=…)Sort a sequence{{ items | sort(attribute="position") }}
stringConvert to a string{{ 42 | string }}
sum(attribute=…)Sum numbers{{ items | sum(attribute="amount") }}
tojson(indent=…)Serialize as JSON{{ chart | tojson }}
trimStrip surrounding whitespace{{ note | trim }}
truncate(n)Shorten text{{ description | truncate(60) }}
uniqueRemove duplicates{{ tags | unique | list }}
urlencodePercent-encode for URLs{{ query | urlencode }}
wordcountCount words{{ text | wordcount }}
wordwrap(width)Wrap long text{{ text | wordwrap(60) }}

Global functions include range(start, stop, step), dict(…), namespace(…), cycler(…) and joiner(separator).

Formatting filters

These use CLDR locale data, so number, currency and date formats match local conventions.

format_currency

{{ amount | format_currency(currency, locale, decimals) }}

Formats a number as an amount of money. currency is an ISO 4217 code such as "EUR". locale is a BCP 47 tag and defaults to "en". decimals is optional and defaults to the currency's usual number of decimal places. Values are rounded half up.

{{ 1234.5 | format_currency("EUR", "de-DE") }}   {# 1.234,50 € #}
{{ 1234.5 | format_currency("USD", "en-US") }}   {# $1,234.50 #}
{{ 1234.5 | format_currency("USD", "en-US", 0) }} {# $1,235 #}

format_number

{{ value | format_number(locale, decimals) }}

Formats a number with the locale's grouping and decimal separators. Without decimals, the locale's standard format is used.

{{ 1234567.891 | format_number("en-US", 2) }}  {# 1,234,567.89 #}
{{ 1234567.891 | format_number("de-DE", 2) }}  {# 1.234.567,89 #}
{{ 12.5 | format_number("en-GB", 1) }}         {# 12.5 #}

format_percent

{{ ratio | format_percent(locale, decimals) }}

Formats a ratio as a percentage: 0.19 becomes 19 %. Pass decimals for fractional percentages.

{{ 0.19 | format_percent("de-DE") }}       {# 19 % #}
{{ 0.19 | format_percent("en-US") }}       {# 19% #}
{{ 0.0725 | format_percent("en-US", 2) }}  {# 7.25% #}

number_to_words

{{ number | number_to_words(lang) }}

Writes a whole number in words, for cheques and contracts. lang is en, de, fr, es or nl. Pass an integer; for amounts with cents, convert the whole part and add the cents separately.

{{ 1250 | number_to_words("en") }}  {# one thousand two hundred and fifty #}

Date and time filters

Dates can be ISO 8601 strings such as "2026-09-14" or "2026-09-14T09:30:00Z", or values produced by strptime, epoch_to_datetime, date_add and now.

format_date

{{ value | format_date(style_or_pattern, locale, tz) }}

Formats a date or date-time. The first argument is either a locale-aware style — short, medium, long or full — or a strftime pattern such as "%d.%m.%Y". locale defaults to "en", and tz is an IANA time zone that defaults to "UTC".

{{ "2026-09-14" | format_date("short", "en-US") }}   {# 9/14/26 #}
{{ "2026-09-14" | format_date("medium", "en-US") }}  {# Sep 14, 2026 #}
{{ "2026-09-14" | format_date("long", "en-GB") }}    {# 14 September 2026 #}
{{ "2026-09-14" | format_date("full", "de-DE") }}    {# Montag, 14. September 2026 #}
{{ "2026-09-14" | format_date("%d.%m.%Y") }}         {# 14.09.2026 #}
{{ "2026-09-14T09:30:00Z" | format_date("%d %b %Y, %H:%M", "en-GB", "Europe/Berlin") }}  {# 14 Sep 2026, 11:30 #}

Common pattern directives:

DirectiveMeaningExample
%Y, %yYear, four or two digits2026, 26
%m, %dMonth and day, zero-padded09, 14
%B, %bMonth name, full or shortSeptember, Sep
%A, %aWeekday name, full or shortMonday, Mon
%H, %M, %SHours (24), minutes, seconds09, 30, 00
%I, %pHours (12) and AM/PM09, AM
%jDay of the year257
%z, %ZUTC offset, time zone name+0200, CEST

date_add

{{ value | date_add(days=…, months=…) }}

Shifts a date by days, months or both. Use it for due dates and validity periods.

{{ invoice.issue_date | date_add(days=30) | format_date("medium", "en-US") }}
{{ "2026-09-14" | date_add(months=3) | format_date("%Y-%m-%d") }}  {# 2026-12-14 #}

now

{{ now(tz) }}

The current time, in UTC unless you pass an IANA time zone. Format it with format_date or strftime.

Generated {{ now("Europe/Berlin") | format_date("%d.%m.%Y %H:%M", "de-DE", "Europe/Berlin") }}

Templates that use now() produce different output every time they run. When a document must be reproducible, pass the date in your data instead.

strptime

{{ text | strptime(format) }}

Parses a date-time from text that isn't ISO 8601.

{{ "14/09/2026" | strptime("%d/%m/%Y") | format_date("long", "en-GB") }}  {# 14 September 2026 #}

strftime

{{ datetime | strftime(format) }}

Formats a date-time with a strftime pattern. Month and weekday names are English; use format_date when you need them localized.

{{ strptime("2026-09-14 09:30", "%Y-%m-%d %H:%M") | strftime("%H:%M on %d %b") }}  {# 09:30 on 14 Sep #}

epoch_to_datetime

{{ epoch_to_datetime(seconds, tz_hours=0) }}

Converts a Unix timestamp in seconds to a date-time, optionally at a fixed UTC offset.

{{ epoch_to_datetime(1789657594) | strftime("%Y-%m-%d %H:%M") }}             {# 2026-09-17 15:06 #}
{{ epoch_to_datetime(1789657594, tz_hours=2) | strftime("%Y-%m-%d %H:%M") }} {# 2026-09-17 17:06 #}

Codes

qrcode

{{ value | qrcode(size=…, margin=…, ecc=…, color=…, background=…, style=…, class=…, alt=…, inline=…) }}

Renders a QR code as an image element containing an SVG, so it stays sharp at any print resolution. It can also be called as a function: {{ qrcode(value, size=160) }}.

ArgumentDescription
sizeWidth and height in pixels, or a CSS length such as "30mm". Default 200
marginQuiet zone around the code, in modules. Default 4; 0 removes it
eccError correction level: L, M (default), Q or H
colorColour of the modules: hex (#1b2a4a), rgb(…), rgba(…) or a colour name. Default black
backgroundBackground colour; transparent leaves it out. Default white
styleInline CSS applied to the generated image element
classCSS class of the image element
altAlternative text of the image. Default "QR code"
inlinetrue outputs the <svg> element itself instead of an image (style, class and alt then don't apply). Default false
{{ ticket.url | qrcode(size=160, margin=0, ecc="Q") }}
{{ invoice.payment_link | qrcode(size=120, color="#1b2a4a", style="float:right") }}

Use a higher error correction level (Q or H) for codes that will be printed on labels or may be partly covered.

barcode

{{ value | barcode(type, module_width=…, module_height=…, quiet_zone=…, font_size=…, text_distance=…, write_text=…, foreground=…, background=…, font_family=…, style=…, class=…, alt=…, inline=…) }}

Renders a 1D barcode as SVG, with the human-readable text below it. The function form is {{ barcode(value, "code128") }}.

ArgumentDescription
typeThe symbology, from the table below. Default code128
module_widthWidth of the narrowest bar in millimetres (0.05 to 5). Default 0.2
module_heightHeight of the bars in millimetres (1 to 500). Default 15
quiet_zoneEmpty space left and right of the bars in millimetres. Default 6.5
font_sizeSize of the text in points (1 to 72). Default 10
text_distanceDistance from the bars to the text baseline in millimetres. Default 5
write_textfalse leaves out the human-readable text. Default true
foregroundColour of the bars and the text, as for qrcode; color is accepted too. Default black
backgroundBackground colour; transparent leaves it out. Default white
font_familyCSS font family of the text. Default monospace
styleInline CSS applied to the generated image element
classCSS class of the image element
altAlternative text of the image. Default: the encoded text
inlinetrue outputs the <svg> element itself instead of an image. Default false

The symbologies:

TypeUse
code128, code39, code93General purpose, alphanumeric
ean8, ean13, janRetail articles
upca, upceRetail articles in North America
itf, gtin14Cartons and logistics units
gs1_128Logistics data with application identifiers
codabarLibraries, blood banks, logistics
isbn10, isbn13, issnBooks and periodicals
pznPharmaceutical products

Check digits are calculated for isbn10, isbn13, issn, gtin14, jan, pzn and gs1_128. Sizes are given in millimetres (module_width, module_height, quiet_zone, text_distance) and the text size in points (font_size); CSS lengths such as "0.3mm" or "12pt" work too.

{{ parcel.tracking_number | barcode("code128", module_height=14) }}
{{ product.gtin | barcode("ean13") }}

Data helpers

sum_by

{{ list | sum_by(attribute) }}

Adds up one attribute across a list of objects. With items set to [{"amount": 120.0}, {"amount": 80.5}]:

{{ items | sum_by("amount") }}                                   {# 200.5 #}
{{ items | sum_by("amount") | format_currency("EUR", "en-IE") }}  {# €200.50 #}

group_by

{{ list | group_by(attribute) }}

Groups a list of objects by an attribute. Each group has grouper (the shared value) and list (the items), the same shape as the built-in groupby filter.

{% for group in items | group_by("category") %}
  <h3>{{ group.grouper }}</h3>
  <ul>
    {% for item in group.list %}<li>{{ item.name }}</li>{% endfor %}
  </ul>
  <p>Subtotal: {{ group.list | sum_by("amount") | format_currency("EUR", "en-IE") }}</p>
{% endfor %}

json and tojson

{{ value | tojson(indent=…) }}

Serializes a value as JSON, escaped so that it is safe inside HTML. json is an alias of tojson. This is the safe way to hand data to a script in the page:

<script type="application/json" id="chart-data">{{ chart | tojson }}</script>
{{ {"sku": "A1", "qty": 2} | tojson }}  {# {"sku":"A1","qty":2} #}

gt, gte, lt, lte

{{ gt(a, b) }}

Comparison helpers that return true or false, equivalent to a > b, a >= b, a < b and a <= b. They also work as filters, where the piped value is the first argument. They exist mainly for templates migrated from other systems; in new templates, the operators read better.

{% if gt(invoice.total, 1000) %}Approval required{% endif %}
{{ stock.quantity | lte(stock.reorder_level) }}  {# true #}

Content filters

markdown

{{ text | markdown }}

Converts GitHub Flavored Markdown to sanitized HTML. Scripts, event handlers and unsafe URLs are removed, which makes it the right filter for text your users write.

{{ invoice.notes | markdown }}

With notes set to "**Payment terms:** 30 days.\n\nThank you for your business.", the output is:

<p><strong>Payment terms:</strong> 30 days.</p>
<p>Thank you for your business.</p>

nl2br

{{ text | nl2br }}

Escapes the text and turns line breaks into <br> elements. Useful for addresses and free-text fields.

{{ customer.address | nl2br }}  {# Example Ltd<br>1 Sample Street<br>Dublin #}

slugify

{{ text | slugify }}

Turns text into a lowercase, hyphenated identifier, handy in filenames and anchors.

{{ "Quarterly Report: Q3 2026" | slugify }}  {# quarterly-report-q3-2026 #}

table

{{ list | table(class=…, columns=…, headers=…) }}

Renders a list of objects as an HTML table. Without columns, the columns are the union of the objects' keys in the order they first appear, and the keys are used as headers. columns selects and orders columns, headers gives them labels in the same order, and class sets the table's CSS class.

{{ items | table(class="items", columns=["sku", "description", "qty"], headers=["SKU", "Description", "Qty"]) }}
<table class="items">
  <thead><tr><th>SKU</th><th>Description</th><th>Qty</th></tr></thead>
  <tbody>
    <tr><td>A1</td><td>Copy paper, A4</td><td>2</td></tr>
  </tbody>
</table>

Write the table markup yourself when you need per-column styling, totals or repeating headers; table is for quick, uniform data.

image_data_uri

{{ image_data_uri(url, max_bytes=…) }}

Downloads an image and returns it as a data: URI, so it is embedded in the document instead of linked. The download goes through the same policy-checked fetcher as page assets — public HTTP and HTTPS only — and is cached. max_bytes caps the size and defaults to 5 MB. It also works as a filter.

<img src="{{ image_data_uri(company.logo_url) }}" alt="{{ company.name }}" style="height:12mm">

This is the reliable way to show a remote image in a header or footer, which renders in an isolated context.

Layout

page_break

{{ page_break() }}

Inserts an element that forces a page break, which is easier to place inside loops than a CSS rule.

{% for certificate in certificates %}
  <section class="certificate">…</section>
  {% if not loop.last %}{{ page_break() }}{% endif %}
{% endfor %}

See Pagination basics for the CSS approach and for keeping blocks together.

Charts

render_chart

{{ rows | render_chart(type, label=…, value=…, …) }}
{{ render_chart(type, rows, label=…, value=…, …) }}

Draws a chart from your data: bar, horizontal-bar, line, area, pie, donut, radar, scatter or gauge. The renderer draws it as SVG with the same chart engine as image templates, so a chart looks the same in both editors and stays sharp at any zoom. Your document needs no script for it: you don't have to turn on javascript or wait for a ready flag, and charts work in PDF/A files and e-invoices.

{{ monthly_revenue | render_chart("bar", label="month", value="revenue") }}

Data

A chart takes its data from a list of rows, or from lists that you give directly.

ArgumentDescription
rowsA list of objects, one per category: the second argument of the function, or the value the filter is applied to
labelThe path of each row's category label, such as "month". It is written as text; a missing value is an empty label
valueThe path of each row's value, such as "revenue", or a list of paths for one series each: ["revenue", "costs"]
namesThe series names, one per value path. Default: the path's last part with _ as a space and a capital first letter, so "amount.net_total" is named "Net total"
labelsA list of category labels. With rows, it replaces the row labels, for example with formatted months
valuesA list of numbers for one series, used with labels instead of rows
seriesA list of series, each {"name": …, "values": […], "color": …}, used with labels instead of rows

Paths reach into nested objects and lists: "amount.net", "totals.0.value". Values are numbers or numeric strings such as "1234.5"; anything else, including null and "", leaves a gap. A chart takes at most 10,000 labels, 50 series and 10,000 values per series. When rows is missing or null in the data, the chart is drawn empty instead of failing, like table.

A scatter chart plots points: x comes from label (or labels), which must then be numbers, and y from value. Points with a missing x or y are left out. A series entry can also list its points directly: "points": [{"x": 1, "y": 2}, …].

Size

width and height are CSS lengths in px, mm, cm, in or pt, or numbers of pixels. The default is width="160mm" and height="70mm", and each must be between 16 and 4,000 pixels (96 pixels per inch). In a narrower container, the chart scales down and keeps its proportions.

Look

ArgumentDescription
titleA title above the chart
colorsThe series colours, as a list of hex colours: #rgb, #rrggbb or #rrggbbaa
highlight_lasttrue draws the last bar or point of a single-series bar, horizontal-bar, line or area chart in the second colour and the others in the first, to set the current period apart from the previous ones
legendtrue, false or a position: "top", "bottom", "left" or "right". Default: at the bottom for more than one series and for pie and donut, otherwise hidden
show_valuestrue writes the value next to each bar, point or slice
stackedtrue stacks the series of a bar, line or area chart
smoothtrue draws smooth curves in line and area charts
font_familyThe font of all text. Default "Inter"
font_sizeThe text size in pixels. Default 12
text_colorThe text colour, as a hex colour
backgroundA background colour, as a hex colour. Without it, the chart is transparent

Axes

ArgumentDescription
x_title, y_titleAxis titles
min, maxThe range of the value axis, and the scale of a gauge (0 to 100 by default)
gridfalse hides the grid lines. Default true
axis_labelsfalse hides the axis labels. Default true

Numbers

Numbers on the axis, in value labels and in pie and donut labels are plain numbers unless you choose a format:

ArgumentDescription
format"number" (default), "money" or "percent". Percent values are in percent units: 7.2 is written as 7.2 %
currencyAn ISO 4217 code such as "EUR"; required with "money"
localeA BCP 47 tag such as "de-DE" for separators and symbols. Default "en"
compacttrue writes short numbers: 138,400 becomes 138K
decimalsThe number of decimal places, from 0 to 6

Reference lines

reference draws a line at a value across a bar, line or area chart, such as a monthly target: horizontal, or vertical on a horizontal-bar chart. Pass a list for several lines, at most 10. reference_label labels the line (a list labels several lines in order), and reference_color sets its colour.

Output

render_chart returns a <dda-chart> placeholder element that describes the chart. The renderer replaces it with an <svg> element before the document is printed, in the body, the header and the footer, and in format: "html" output. If a chart can't be drawn, it leaves an empty box of the chart's size and an asset_failed warning.

ArgumentDescription
altThe text for screen readers and PDF accessibility. Default: the title, otherwise "Chart"
classA CSS class for the chart
styleInline CSS added to the chart's own

HTML previews return the placeholder as it is, and the dashboard draws it. An unknown chart type or argument, or a value out of range, fails with template_runtime_error and a message that names the argument.

Example

With monthly revenue in the data:

{
  "currency": "EUR",
  "target": 180000,
  "monthly_revenue": [
    { "month": "2026-06-01", "revenue": 163400, "costs": 121000 },
    { "month": "2026-07-01", "revenue": 171900, "costs": 118500 },
    { "month": "2026-08-01", "revenue": 184250, "costs": 126300 }
  ]
}

this bar chart shows the revenue per month in euros against the target, with the current month highlighted and the months written as "Jun 2026":

{{ render_chart("bar", monthly_revenue, label="month", value="revenue",
     labels=monthly_revenue | map(attribute="month") | map("format_date", "%b %Y") | list,
     title="Revenue", format="money", currency=currency, compact=true,
     highlight_last=true, colors=["#c7d2fe", "#4338ca"],
     reference=target, reference_label="Monthly target",
     width="170mm", height="70mm", alt="Revenue per month against the monthly target") }}

Two series as lines, and a donut from values written in the template:

{{ monthly_revenue | render_chart("line", label="month", value=["revenue", "costs"], legend="top") }}
{{ render_chart("donut", labels=["Online", "Retail", "Partners"], values=[52, 31, 17], format="percent") }}

Compatibility aliases

Templates migrated from other systems often use these names. They behave like the filters above:

AliasSame asNotes
render_qrcode(style=…)qrcodeAccepts the same options
render_barcode(type, style, quiet_zone, font_size, text_distance, module_width, module_height)barcodeSame type names and option names
render_table(table_class=…)tabletable_class sets the CSS class
jsontojsonSame output

Sandbox and limits

Templates run in a sandbox with no access to the filesystem, the network or the host: the only exception is image_data_uri, which goes through the policy-checked asset fetcher.

LimitValue
Execution budgetEvery operation consumes fuel, from 50,000 units on Free to 20 million on Scale. Exceeding it fails with 422 template_fuel_exhausted.
Recursion depth50, for nested macros, recursive loops and includes
Output size50 MB of rendered HTML
TimeBounded by the render's sync timeout or async maximum duration

Most templates stay far below the execution budget. Deeply nested loops over thousands of rows are the usual cause of template_fuel_exhausted: precompute totals in your application, or split the document into several renders.

Errors

Template problems are reported with the line, column and an excerpt of the source, both in the error response and in the render's error field:

{
  "type": "https://docs.dynamicdocumentapi.com/errors/template_runtime_error",
  "title": "Template runtime error",
  "status": 422,
  "code": "template_runtime_error",
  "detail": "'dict object' has no attribute 'total' (line 12, column 7)",
  "render_id": "rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q",
  "request_id": "req_01J9ZM4T6W8Y0A2C4E6G8J0K2M",
  "doc_url": "https://docs.dynamicdocumentapi.com/errors/template_runtime_error"
}
  • template_syntax_error: the template can't be parsed, for example because of an unclosed block. Publishing a template validates the syntax, so these rarely reach production.
  • template_runtime_error: something went wrong while rendering, such as an unknown filter, an unsupported operation between types, or an undefined value in strict mode.
  • template_fuel_exhausted: the template exceeded its execution budget.

See Errors for the full list.

Differences from Python Jinja2

The engine implements Jinja2 syntax and semantics and is checked against Python Jinja2 with a differential test suite, but it doesn't run Python. Keep these differences in mind when you port templates:

  • Python methods are limited to the compatibility list. A datetime's .strftime() or a list's .append() are not available; use the strftime filter and namespace instead.
  • Values are immutable, so nothing can be modified in place.
  • Booleans render as true and false.
  • Templates are loaded from the version bundle only. There is no filesystem loader, and workspace partials are planned.
  • The % string-formatting operator isn't supported. Use the format filter or .format().
  • Jinja2 extensions such as {% trans %} and custom Python filters aren't available. The custom filters cover the common document cases.

On this page