Modeling your variables
A great template has the smallest variable surface possible. Everything else lives inline in the HTML. This guide is the rule of thumb for where to draw that line.
The rule
If you'd send the same value on every generation, it's not a variable.
That's it. Section titles, legal notices, column headers, footer copyright, button labels — all inline. They never change between two invoices for the same template.
What SHOULD be a variable
Anything that genuinely differs from one generation to the next:
- Customer / recipient data (name, email, address)
- Document identifiers (invoice number, order id, ticket id)
- Dates (issue date, due date, period)
- Monetary amounts and line items
- Status flags (paid / unpaid, trial / billed)
- Image URLs that change per recipient (avatar, signature)
What should NOT be a variable
Anything that's static for the template:
- Section headings ("Billed to", "Items", "Notes")
- Column headers ("Item", "Qty", "Amount")
- Legal text and footers
- The brand name, logo URL (unless multi-tenant)
- Visual decorations
- Button text on receipt CTAs
A practical example
Bad — variables doing too much:
{
"title": "Invoice",
"billedToLabel": "Billed to",
"fromLabel": "From",
"itemColumnHeader": "Item",
"qtyColumnHeader": "Qty",
"amountColumnHeader": "Amount",
"totalLabel": "Total",
"footerLegal": "© 2026 Acme Corp",
"paidLabel": "PAID",
"customer": { "name": "Customer SAS" },
"invoice": { "number": "INV-001", "total": 100 }
}
Good — variables only for what varies:
{
"customer": { "name": "Customer SAS" },
"invoice": { "number": "INV-001", "issuedOn": "2026-05-23", "total": 100 },
"items": [
{ "label": "Pro plan", "qty": 1, "amount": 100 }
],
"paid": true
}
The other strings ("Invoice", "Billed to", "Total", "PAID") live in the HTML as plain text.
Multi-language is the exception
If the same template needs to render in multiple languages, then yes — promote the labels:
{
"labels": {
"invoice": "Facture",
"billedTo": "Adressé à",
"total": "Total",
"paid": "PAYÉ"
},
"customer": { "name": "Customer SAS" },
"invoice": { "number": "INV-001", "total": 100 }
}
But group them under a labels (or i18n) key so the rest of variables stays clean. Don't sprinkle individual *Label keys at the top level.
Naming conventions
- camelCase keys. The template is JavaScript-land; match it.
- Group related fields under an object:
customer.namenotcustomerName. It scales better when you addcustomer.email,customer.address. - Arrays for repeatable items:
items: [{...}, {...}], notitem1,item2. - Booleans express state, not display:
paid: true, notshowPaidBadge: true. Let the template decide what to render.
Sample variables in the editor
The dashboard's editor has a Variables panel. Seed it with realistic example data so the live preview is meaningful:
{
"customer": { "name": "Acme Corp" },
"invoice": {
"number": "INV-2026-0142",
"issuedOn": "2026-05-23",
"dueOn": "2026-06-22",
"total": 1280.50
},
"items": [
{ "label": "Pro subscription", "qty": 1, "amount": 1200 },
{ "label": "Top-up — 1k PDFs", "qty": 1, "amount": 80.50 }
],
"paid": false
}
The integration docs panel reads this same shape and embeds it verbatim in the code snippets it generates — so the snippets your developers copy already match your contract.
Discovering the shape from code
If you're integrating from an unfamiliar template, GET /v1/documents/{id} returns the variables field with the sample shape. Use that as the canonical schema:
const doc = await fetch(`https://api.transactional.dev/v1/documents/${id}`, {
headers: {'x-api-token': process.env.TRANSACTIONAL_API_TOKEN!},
}).then(r => r.json())
console.log(JSON.stringify(doc.variables, null, 2))
// Now you know exactly what to pass to /v1/generate.
The AI assistant in the editor and the MCP get_document tool do exactly this to learn what variables a template expects.
Next steps
- Handlebars cheat sheet → — what works inside templates.
- Designing templates that survive PDF rendering →.