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:
| Part | What it is |
|---|---|
source | The HTML document, with Liquid expressions. |
schema | A JSON Schema for the data payload. See Data schemas. |
options | Default render options, merged under per-request options. |
engine | chromium or satori. See Engines. |
A minimal template
Section titled “A minimal template”<!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:
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" } }'Interpolation
Section titled “Interpolation”{{ … }} 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 %}Conditionals
Section titled “Conditionals”{% 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 %}Assignment and arithmetic
Section titled “Assignment and arithmetic”{% 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:
{%- 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.
Filters
Section titled “Filters”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 }}| Argument | Position | Default | Notes |
|---|---|---|---|
currency | 1st | "USD" | Any ISO 4217 code. |
locale | 2nd | "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.
| Expression | Output |
|---|---|
{{ 1999.5 | money }} | $1,999.50 |
{{ 19 | money }} | $19.00 |
{{ 1999.5 | money: "EUR", "de-DE" }} | 1.999,50 € |
{{ "" | money }} | (empty) |
date_medium
Section titled “date_medium”{{ value | date_medium }}{{ value | date_medium: locale }}| Argument | Position | Default | Notes |
|---|---|---|---|
locale | 1st | "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.
| Expression | Output |
|---|---|
{{ "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" }}.
Why there are no partials
Section titled “Why there are no partials”{% 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/invoicereturns 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.
CSS for print
Section titled “CSS for print”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; }| Property | Effect |
|---|---|
break-inside: avoid / page-break-inside: avoid | Keep a row, card or section on one page. |
break-before: page / page-break-before: always | Start a new page. |
break-after: avoid | Keep a heading with the block that follows it. |
orphans / widows | Minimum 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>@page versus options.page_size
Section titled “@page versus options.page_size”These two interact, and the rule is worth memorising:
| Request | What decides the paper |
|---|---|
No options.page_size | Your @page { size: … } wins — the PDF uses the CSS page size. |
options.page_size set | The 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.
The options.css escape hatch
Section titled “The options.css escape hatch”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:
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.
Creating a template
Section titled “Creating a template”POST /v1/templates creates a template at version 1. Publishing a change later is
POST /v1/templates/:name/versions — see Template versions.
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).
Fields
Section titled “Fields”| Field | Required | Notes |
|---|---|---|
name | yes | ^[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. |
source | yes | The HTML document. Parsed as Liquid at create time; a syntax error is a 400 invalid_request naming source, not a broken render later. |
engine | no | chromium (default) or satori. Anything else is a 400. |
schema | no | JSON Schema object. Compiled at create time; an invalid schema is a 400. Strongly recommended — see Data schemas. |
options | no | Default render options for this version. |
example | no | A payload that renders. Echoed back by POST /v1/templates/:ref/validate and by the MCP validate_data tool. |
description | no | One line, shown in GET /v1/templates. |
message | no | Change note for the version, like a commit message. |
Creating a template is free — you are billed for renders, not for templates.
Start from a starter
Section titled “Start from a starter”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:
# 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.