Skip to content

Template language

A template is one self-contained HTML document: inline CSS, inline markup, and Liquid expressions for the parts that come from your data. The stored source is the whole render — nothing is pulled in from a filesystem at render time — so a version renders the same way in a year as it does today.

Each template version carries four things:

PartWhat it is
sourceThe HTML document, with Liquid expressions.
schemaA JSON Schema for the data payload. See Data schemas.
optionsDefault render options, merged under per-request options.
enginechromium or satori. See Engines.
hello.html
<!doctype html>
<html>
<head>
<style>
@page { size: Letter; margin: 18mm; }
body { font: 12pt/1.5 "Helvetica Neue", Arial, sans-serif; color: #16181d; }
.total { font-weight: 700; }
</style>
</head>
<body>
<h1>Statement for {{ customer.name }}</h1>
<p>Issued {{ issued_on | date_medium }}</p>
<p class="total">{{ total | money: currency }}</p>
</body>
</html>

Render it:

Terminal window
curl -sS https://api.galleyrender.com/v1/render \
-H "Authorization: Bearer $GALLEY_API_KEY" \
-H 'content-type: application/json' \
-d '{
"template": "statement@1",
"format": "pdf",
"data": {
"customer": { "name": "Acme Robotics" },
"issued_on": "2026-09-16",
"total": 1999.5,
"currency": "USD"
}
}'

{{ … }} writes a value into the document. Dotted paths walk objects; [0] indexes arrays.

{{ invoice_number }}
{{ seller.name }}
{{ line_items[0].description }}

Output is HTML-escaped by default. If a field legitimately contains markup you control, pipe it through the standard escape_once or emit it as-is at your own risk — Galley does not sanitize what you choose to unescape.

Unknown filters are the opposite: strictFilters is on, so {{ total | flubber }} fails the render with render_failed rather than silently emitting nothing.

{% for item in line_items %}
<tr>
<td>{{ item.description }}</td>
<td class="num">{{ item.quantity }}</td>
<td class="num">{{ item.unit_price | money: currency }}</td>
</tr>
{% endfor %}

The usual Liquid loop helpers are available: forloop.index, forloop.first, forloop.last, forloop.length, plus limit, offset and reversed, and {% else %} for the empty case.

{% for item in line_items limit: 10 %}
<li>{{ forloop.index }}. {{ item.description }}</li>
{% else %}
<li>No line items.</li>
{% endfor %}
{% if due_on %}<div>Due <strong>{{ due_on | date_medium }}</strong></div>{% endif %}
{% if balance > 0 %}
<p class="warn">Balance outstanding.</p>
{% elsif balance < 0 %}
<p>Credit on file.</p>
{% else %}
<p>Paid in full.</p>
{% endif %}
{% unless notes == blank %}<p>{{ notes }}</p>{% endunless %}
{% case status %}
{% when "paid" %}<span class="badge green">Paid</span>
{% when "overdue" %}<span class="badge red">Overdue</span>
{% else %}<span class="badge">{{ status }}</span>
{% endcase %}

{% assign %} computes values in the template so your payload does not have to carry derived numbers. This is the real top of the invoice starter:

packages/templates/library/invoice/template.html
{%- assign currency = currency | default: "USD" -%}
{%- assign subtotal = 0 -%}
{%- for item in line_items -%}
{%- assign line = item.quantity | times: item.unit_price -%}
{%- assign subtotal = subtotal | plus: line -%}
{%- endfor -%}
{%- assign tax = subtotal | times: tax_rate | default: 0 -%}
{%- assign total = subtotal | plus: tax -%}

{% capture %} assigns rendered markup to a variable, and {% increment %} / {% decrement %} keep a counter outside the normal variable scope.

The - in {%- and -%} trims surrounding whitespace. Trimming here is not greedy (greedy: false): one run of whitespace next to the tag is removed, rather than every surrounding blank line. That matters in <pre> blocks and in table cells where a stray space changes layout.

Everything in standard Liquid is available — default, upcase, downcase, capitalize, strip, truncate, truncatewords, escape, escape_once, strip_html, newline_to_br, replace, split, join, first, last, size, sort, sort_natural, uniq, map, where, compact, reverse, slice, plus, minus, times, divided_by, modulo, round, ceil, floor, abs, at_least, at_most, date, url_encode, json — plus two of Galley’s own.

{{ value | money }}
{{ value | money: currency }}
{{ value | money: currency, locale }}
ArgumentPositionDefaultNotes
currency1st"USD"Any ISO 4217 code.
locale2nd"en-US"Any BCP 47 tag. Decides separators and symbol placement.

Formats with Intl.NumberFormat in style: "currency". Strings that parse as numbers are accepted; anything that is not a finite number renders as an empty string rather than failing the render.

ExpressionOutput
{{ 1999.5 | money }}$1,999.50
{{ 19 | money }}$19.00
{{ 1999.5 | money: "EUR", "de-DE" }}1.999,50 €
{{ "" | money }}(empty)
{{ value | date_medium }}
{{ value | date_medium: locale }}
ArgumentPositionDefaultNotes
locale1st"en-US"Any BCP 47 tag.

Parses the value with new Date(...) and formats it with Intl.DateTimeFormat at dateStyle: "medium", fixed to UTC. A date-only string like 2026-09-16 therefore never slips a day. null, undefined and "" render empty; a string that will not parse is passed through unchanged, so a bad date shows up in the document instead of blowing up the render.

ExpressionOutput
{{ "2026-09-16" | date_medium }}Sep 16, 2026
{{ "2026-09-16T23:30:00Z" | date_medium }}Sep 16, 2026
{{ "2026-09-16" | date_medium: "en-GB" }}16 Sept 2026
{{ "2026-09-16" | date_medium: "fr-FR" }}16 sept. 2026

For anything else — times, custom patterns — use standard Liquid date: {{ issued_on | date: "%B %-d, %Y" }}.

{% include %}, {% render %} and {% layout %} are disabled. The Liquid engine is constructed with an empty root and relativeReference: false, so there is no filesystem for a template to reach into.

This is a correctness decision, not a limitation we plan to remove:

  • A version’s checksum covers the source, schema, options and engine. If a template could pull in a file, the checksum would no longer describe the render, and the render cache would return output produced by a partial that has since changed.
  • A template is portable: GET /v1/templates/invoice returns everything needed to reproduce the document.
  • There is no path for a template to read something it should not.

To share markup across templates, generate the shared block on your side and pass it in, or use {% capture %} inside the single document. Remote assets are fine — images, fonts and stylesheets at public https:// URLs are fetched through the SSRF-safe asset fetcher.

PDFs render with the print media emulated; PNG and JPG render with screen. So @media print and @media screen both work, and they mean what they say.

@page { size: Letter; margin: 0.5in; }
@page :first { margin-top: 0.25in; }
PropertyEffect
break-inside: avoid / page-break-inside: avoidKeep a row, card or section on one page.
break-before: page / page-break-before: alwaysStart a new page.
break-after: avoidKeep a heading with the block that follows it.
orphans / widowsMinimum lines left at a page boundary.

A <thead> inside a <table> repeats on every page automatically, and <tfoot> is drawn after the last row. That is the reason the invoice starter puts the column headings in a real <thead> rather than a styled first row:

<table>
<thead>
<tr><th>Description</th><th class="num">Qty</th><th class="num">Amount</th></tr>
</thead>
<tbody>
{% for item in line_items %}
<tr style="break-inside: avoid;">
<td>{{ item.description }}</td>
<td class="num">{{ item.quantity }}</td>
<td class="num">{{ item.quantity | times: item.unit_price | money: currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>

These two interact, and the rule is worth memorising:

RequestWhat decides the paper
No options.page_sizeYour @page { size: … } wins — the PDF uses the CSS page size.
options.page_size setThe option wins and the CSS size is overridden.

Margins work the other way round: options.margin is always applied, defaulting to 0.5in on every side when you do not send one. If you want the CSS to own the margin box, set "margin": "0" and use @page { margin: … }.

Background colours and images print by default (print_background is true unless you set it to false), so you do not need the usual -webkit-print-color-adjust incantation.

Chromium templates can use anything the browser can load:

<style>
@import url("https://fonts.googleapis.com/css2?family=Newsreader:wght@400;600&display=swap");
body { font-family: Newsreader, Georgia, serif; }
</style>

or a self-hosted file:

@font-face {
font-family: "Söhne";
src: url("https://cdn.example.com/fonts/soehne-buch.woff2") format("woff2");
font-weight: 400;
font-display: block;
}

Every font request goes through the asset fetcher, so the URL must be public https://. The renderer waits on document.fonts.ready before it captures, so text is measured against the real face rather than a fallback. Always keep a local fallback stack — if the font cannot be fetched, the document still renders with the next family in the list.

Satori templates do not fetch fonts. They use the three bundled Inter weights only; see Engines.

options.css is extra CSS appended after the template’s own styles, at render time. Use it when you want one call to differ from the template without publishing a new version:

Terminal window
curl -sS https://api.galleyrender.com/v1/render \
-H "Authorization: Bearer $GALLEY_API_KEY" \
-H 'content-type: application/json' \
-d '{
"template": "invoice@1",
"format": "pdf",
"data": { "invoice_number": "INV-1042", "issued_on": "2026-09-16",
"seller": { "name": "Galley Render" }, "buyer": { "name": "Acme Robotics" },
"line_items": [{ "description": "Starter plan", "quantity": 1, "unit_price": 19 }] },
"options": {
"css": "body::before { content: \"DRAFT\"; position: fixed; top: 40%; left: 12%; font-size: 120pt; color: rgba(0,0,0,.08); transform: rotate(-24deg); }"
}
}'

Because it is appended last, it wins over same-specificity rules in the template without !important.

Options as a whole are merged template defaults first, request second, key by key.

POST /v1/templates creates a template at version 1. Publishing a change later is POST /v1/templates/:name/versions — see Template versions.

Create a template
curl -sS https://api.galleyrender.com/v1/templates \
-H "Authorization: Bearer $GALLEY_API_KEY" \
-H 'content-type: application/json' \
-d '{
"name": "delivery-note",
"engine": "chromium",
"description": "One-page delivery note with a packing list.",
"source": "<!doctype html><html><head><style>@page{size:A4;margin:18mm}body{font:11pt/1.5 Arial,sans-serif}</style></head><body><h1>Delivery note {{ reference }}</h1><p>{{ shipped_on | date_medium }}</p><ul>{% for item in items %}<li>{{ item.quantity }} x {{ item.description }}</li>{% endfor %}</ul></body></html>",
"schema": {
"type": "object",
"required": ["reference", "items"],
"properties": {
"reference": { "type": "string", "description": "Delivery note number.", "examples": ["DN-3007"] },
"shipped_on": { "type": "string", "format": "date", "examples": ["2026-09-16"] },
"items": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["description", "quantity"],
"properties": {
"description": { "type": "string" },
"quantity": { "type": "number", "minimum": 0 }
}
}
}
}
},
"options": { "page_size": "A4", "margin": "18mm" },
"example": { "reference": "DN-3007", "shipped_on": "2026-09-16",
"items": [{ "description": "Widget", "quantity": 2 }] },
"message": "initial"
}'

201 Created comes back with the template and its version 1, including the checksum and the ref you should pin (delivery-note@1).

FieldRequiredNotes
nameyes^[a-z0-9][a-z0-9._-]{0,62}$ — lowercase letters, digits, dot, dash, underscore; 1–63 characters; unique per account. Uppercase input is lowercased. Do not put @1 here.
sourceyesThe HTML document. Parsed as Liquid at create time; a syntax error is a 400 invalid_request naming source, not a broken render later.
enginenochromium (default) or satori. Anything else is a 400.
schemanoJSON Schema object. Compiled at create time; an invalid schema is a 400. Strongly recommended — see Data schemas.
optionsnoDefault render options for this version.
examplenoA payload that renders. Echoed back by POST /v1/templates/:ref/validate and by the MCP validate_data tool.
descriptionnoOne line, shown in GET /v1/templates.
messagenoChange note for the version, like a commit message.

Creating a template is free — you are billed for renders, not for templates.

Every account, including a keyless trial, is created with the 20-template starter library already loaded. Browse them at /templates, then copy one and publish your own version of it:

Terminal window
# Read the starter source and schema.
curl -sS https://api.galleyrender.com/v1/templates/invoice \
-H "Authorization: Bearer $GALLEY_API_KEY" > invoice.json
# Edit invoice.json, then publish it under your own name.

If a name is already taken the API answers 409 conflict and points you at /v1/templates/<name>/versions.