A DocMake template has three kinds of dynamic content. Variables substitute a value from your data at a dotted key such as client_name or customer.address.city. Conditionals include or drop a block of the document based on a comparison against your data. Loops repeat a block, or a table row, once per item in an array.
There is no template syntax to learn. You insert these as objects in the visual editor, and under the hood the template is a JSON document tree rather than a marked-up Word file. That matters for two practical reasons: your data payload can be ordinary nested JSON with no escaping rules, and the API can tell you exactly which keys a template expects before you send anything.
This guide walks through all three using the invoice template from our gallery, which uses every one of them.
The data shape comes first
An invoice payload looks like this:
{
"invoice_number": "INV-2026-0142",
"issue_date": "2026-08-08",
"due_date": "2026-09-07",
"client_name": "Acme Corp",
"client_address": "540 Market Street, San Francisco, CA 94104",
"items": [
{ "description": "Design services", "qty": 12, "unit_price": 150.0 },
{ "description": "Development sprint", "qty": 1, "unit_price": 2600.0 }
],
"payment_terms": "30 days"
}
Scalars at the root, arrays of objects for anything repeating, and nesting wherever it is natural. Every template node addresses this object by a dotted path, so client_name and customer.billing.city are both just keys.
If you are integrating against a template someone else built, ask the API for the contract instead of guessing:
curl https://app.docmake.io/api/v1/templates/tmpl_ne1EfQns2YBH/form-schema \
-H "Authorization: Bearer dm_your_api_key"
The response is a fields array where each entry carries key, label, type, required, default and example, and where array fields nest their per-item fields under their own fields. It is a direct map of the JSON you need to send.
Variables
A variable is an inline node with a key, an optional fallback, and an optional list of formatters. In the template JSON it looks like this:
{
"type": "docmakeInline",
"attrs": {
"kind": "variable",
"id": "v-client",
"label": "Client name",
"key": "client_name",
"fallback": "Valued Customer"
}
}
If client_name is missing from your data, the engine renders the fallback and records a missing_data warning. If there is no fallback either, it renders an empty string and still warns. Warnings come back on every render in the X-DocMake-Warnings response header as a JSON array, which is the header to log if you care about silent blanks.
Formatters
Formatters transform the resolved value, applied left to right:
| Formatter | Args | Example output |
|---|---|---|
uppercase | none | ACME CORP |
lowercase | none | acme corp |
capitalize | none | Acme corp |
date | format | 2026-08-08 |
number | decimals, separator | 1,234.56 |
currency | code, locale | $1,234.56 |
truncate | length | Lorem ipsum... |
There are also five aggregate formatters (sum, avg, min, max, count) for summarizing an array. A currency amount in an invoice is a variable plus a formatter:
{
"type": "docmakeInline",
"attrs": {
"kind": "variable",
"id": "v-price",
"key": "item.unit_price",
"formatters": [{ "type": "currency", "args": { "code": "USD" } }]
}
}
Two failure modes worth knowing. An unrecognized formatter type is a hard error and blocks the render. A recognized formatter that gets the wrong kind of value (a currency formatter handed a non-numeric string, say) is a warning: the render continues. So send 150.0, not "$150.00", and let the formatter do the money.
Conditionals
A conditional wraps a block of content in a condition. The simplest condition is a comparison:
{
"field": "customer.is_vip",
"op": "eq",
"value": true
}
Supported operators:
| Operator | Meaning |
|---|---|
eq / neq | equals, not equals |
gt / gte | greater than, greater or equal |
lt / lte | less than, less or equal |
in / nin | value is (not) in an array |
empty / nempty | field is (not) null, missing, empty string or empty array |
Comparisons can be grouped with and or or, and groups can nest up to five levels deep:
{
"logic": "and",
"conditions": [
{ "field": "total", "op": "gt", "value": 1000 },
{ "field": "customer.is_vip", "op": "eq", "value": true }
]
}
Each conditional carries an if branch and an optional else branch, so you can write “show the early payment discount clause, otherwise show the standard terms” as one block instead of two templates. A field that does not resolve is treated as empty rather than as an error, which is what makes empty and nempty the safest operators for optional data: {"field": "purchase_order_number", "op": "nempty"} shows the PO line only for the clients that have one.
Loops
A loop repeats its content once per item in an array. It has a key pointing at the array and an itemAlias (default item) used to address the current row:
{
"type": "docmakeBlock",
"attrs": {
"kind": "loop",
"id": "loop-items",
"key": "items",
"itemAlias": "item"
},
"content": [ /* blocks repeated per item */ ]
}
Inside the loop, item.description resolves against the current row. Anything that is not prefixed with the alias falls through to the root data, so a per-row block can still print invoice_number.
Loop metadata
Five values are available inside any loop, namespaced so they cannot collide with your data:
$loop.index, zero-based$loop.position, one-based, the one you usually want for a numbered list$loop.firstand$loop.last, booleans$loop.count, total items
They are ordinary variable keys, which means they work with conditionals too: wrap a separator rule in a conditional on $loop.last being false and it stops appearing after the final row.
Repeating table rows
Invoice line items are a table, and a table row cannot be wrapped in a block node. So the repeat rides on the row itself:
{
"type": "tableRow",
"attrs": {
"repeat": { "key": "items", "itemAlias": "item", "id": "loop-rows" }
},
"content": [ /* tableCell per column */ ]
}
The engine clones the row for each array item, resolves variables against the item scope, and makes the same $loop.* metadata available. Two items produce two rows, twenty produce twenty, and the totals below the table stay where they are. Nested loops work the same way: a loop over departments containing a loop over that department’s employees, each with its own alias.
One hard rule: a loop key that does not resolve to an array is an error, not a warning. Send "items": [] for an empty list rather than omitting the key.
Totals without doing the arithmetic
You do not have to compute the invoice total in your application. An aggregation node takes an operation (sum, avg, min, max, count), the path to the array, and the field within each item, so a line-items total is a node, not a number you send. Aggregations always resolve from the root of your data, even when placed inside a loop, so they are not affected by the current row.
Checking a template before you render
Two endpoints are worth wiring into your integration tests:
# Structural check, plus data check when you send a payload
curl -X POST https://app.docmake.io/api/v1/templates/tmpl_ne1EfQns2YBH/validate \
-H "Authorization: Bearer dm_your_api_key" \
-H "Content-Type: application/json" \
-d '{"data": {"client_name": "Acme Corp", "items": []}}'
It returns valid plus an errors array, each entry carrying node_id, node_kind, node_label, type, message and a severity of error or warning. Errors block a render; warnings do not.
And at render time, pass "strict": true to get a 422 listing every variable with no value rather than a document full of blanks. Strict failures do not consume your document quota. Full details in how to generate a PDF invoice from JSON with an API.
Where to go from here
Browse the template gallery for real examples: the quote and purchase order templates use the same repeating-row pattern, the ISO controlled procedure drives four separate repeating lists (steps, responsibilities, records and revisions) from one payload, and the timesheet repeats a row per day. Then read the developers page for the endpoints that render them.