Preparing the data
A render request has two parts: the template and the data.
- The template comes from the designer. Your application stores it and sends it back unchanged. You don't need to understand its format, and you shouldn't edit it in code.
- The data is yours: a JSON object your back end builds for one document (one shipment, one invoice, one report run). This page is about the data.
POST /api/v1/render
{ "template": { …saved from the designer… }, "data": { …built by your back end… } }
The author puts placeholders into the template, such as {{ shipTo.name }} or {{ formatCurrency(totals.total, 'USD') }}.
At render time each one is looked up in your data. So the data's field names and nesting are the contract between your back end
and the template author. Agree on them first, and describe them in a JSON Schema so the author can drag the
fields from the designer's Fields tab.
The five examples below go from a single label to a multi-page report. Each has a template, the data and its schema, which you can download or open in the Playground (Open template, category Data guide). Every printed value quoted on this page is checked against a real render of these files.
The rules in short#
- Data is one JSON object. Its top-level fields are the names placeholders start with:
{{ invoiceNumber }},{{ billTo.city }}. - Objects nest with dots:
shipTo.address1. Keys that are not plain names use brackets:{{ address['zip-code'] }}. - Arrays feed tables and repeaters. Inside a table row,
itemis the current element andindexits position (0-based):{{ item.sku }},{{ index + 1 }}. Seeitem,indexandrows. - Send raw values, not formatted text. Send
1314.56and"2026-10-21". The template formats them (formatCurrency,formatDate,formatNumber) in the document's locale. Every function is listed in the user guide's function reference, which authors open from the designer. - A missing field prints nothing. It doesn't cause an error. Use
coalesce(field, 'default')in the template for a default. - Do money and business math in your back end. Send line amounts and totals. The template can add up a column
(
sum(rows, 'amount')) and do simple math, but your system of record should compute what you bill.
item, index and rows#
A table (or repeater) has a Data setting that names the array it prints, for example lines, activities or
shipment.cartons. The engine prints one row per element of that array. Inside each row it defines three names. They are
always the same, whatever the array is called, and can't be renamed:
| Name | Where | Is |
|---|---|---|
item | each row's cells | the current element of the array |
index | each row's cells | its position, starting at 0 |
rows | the table's footer (and group footers) | the whole array (or the group's rows) |
For a table whose Data setting is activities:
{
"orderNumber": "SO-1",
"activities": [
{ "date": "2026-10-20", "user": "jdoe", "action": "Picked" },
{ "date": "2026-10-21", "user": "akim", "action": "Packed" }
]
}
| Column | Placeholder | Row 1 prints | Row 2 prints |
|---|---|---|---|
| # | {{ index + 1 }} | 1 | 2 |
| Date | {{ formatDate(item.date, 'MM/DD/YYYY') }} | 10/20/2026 | 10/21/2026 |
| User | {{ item.user }} | jdoe | akim |
| Action | {{ item.action }} | Picked | Packed |
| Order | {{ orderNumber }} | SO-1 | SO-1 |
| footer | {{ count(rows) }} activities | 2 activities |
- The Data setting only chooses which array.
item.usermeans "theuserof the element this row prints":activities[0].user, thenactivities[1].user, and so on. - Everything else in the data stays reachable inside a row with its normal path (
{{ orderNumber }}above). Outside the table,itemdoesn't exist. - Authors rarely type
item: dragging an array from the designer's Fields tab creates the table with its Data setting filled in and its columns written asitem.<field>. - If your data has a top-level field that is itself called
item, the row'sitemwins inside rows. Use$.itemto reach your field there ($is the root of the data). - There's no name for an outer row. Tables don't nest (see example 5), so a value a row needs from its parent must be on the row itself.
Example 1: a shipping label (single values)#
The simplest case: one document with no lists. Fields are strings, numbers, a date, a boolean and nested objects.
Files: template.json · data.json · schema.json
{
"shipmentId": "SH-20261021-0042",
"service": "Ground",
"shipDate": "2026-10-21",
"weightLb": 12.5,
"fragile": true,
"reference": null,
"package": { "number": 1, "count": 2 },
"shipFrom": { "name": "Summit Fulfillment Co.", "address1": "4100 Commerce Pkwy, Building C", "city": "Atlanta", "state": "GA", "postalCode": "30336" },
"shipTo": { "name": "Northwind Retail #1187", "address1": "2250 Lakeshore Blvd", "city": "Columbus", "state": "OH", "postalCode": "43215" },
"trackingNumber": "1Z999AA10123456784"
}
| In the template | Prints |
|---|---|
{{ shipTo.name }} | Northwind Retail #1187 |
{{ shipTo.city }}, {{ shipTo.state }} {{ shipTo.postalCode }} | Columbus, OH 43215 |
{{ upper(service) }} | GROUND |
Package {{ package.number }} of {{ package.count }} | Package 1 of 2 |
Ship date: {{ formatDate(shipDate, 'MMM D, YYYY') }} | Ship date: Oct 21, 2026 |
Weight: {{ formatNumber(weightLb, 1) }} lb | Weight: 12.5 lb |
Reference: {{ coalesce(reference, 'none') }} | Reference: none |
Barcode with value {{ trackingNumber }} | a Code 128 barcode of 1Z999AA10123456784 |
"FRAGILE: HANDLE WITH CARE", shown only if fragile | FRAGILE: HANDLE WITH CARE (hidden when fragile is false) |
reference is null, so coalesce prints the default. Without coalesce, {{ reference }} would print nothing.
Example 2: an invoice with line items (an array)#
An order with several lines is the most common document. The lines are an array of objects. The author binds a table to
the array (the table's Data setting is lines), and each column shows a field of item, the current line.
Files: template.json · data.json · schema.json
{
"invoiceNumber": "INV-2026-0001",
"invoiceDate": "2026-10-21",
"dueDate": "2026-11-20",
"poNumber": "PO-4401",
"terms": "Net 30",
"company": { "name": "Summit Fulfillment Co.", "address1": "4100 Commerce Pkwy, Building C", "city": "Atlanta", "state": "GA", "postalCode": "30336" },
"billTo": { "name": "Northwind Retail #1187", "address1": "2250 Lakeshore Blvd", "city": "Columbus", "state": "OH", "postalCode": "43215" },
"lines": [
{ "sku": "APP-TS-1002", "description": "Organic cotton crew tee, navy, L", "qty": 6, "unitPrice": 9.7, "amount": 58.2 },
{ "sku": "HOM-MG-0450", "description": "Stoneware coffee mug, 14 oz, matte white", "qty": 22, "unitPrice": 5.9, "amount": 129.8 }
],
"totals": { "subtotal": 1205.2, "taxRate": 0.07, "tax": 84.36, "shipping": 25, "total": 1314.56 },
"notes": "Thank you for your business. Please include the invoice number with your payment."
}
The example file has six lines; two are shown here.
| In the template | Where | Prints (first line) |
|---|---|---|
{{ index + 1 }} | table column | 1 |
{{ item.sku }} | table column | APP-TS-1002 |
{{ item.qty }} | table column | 6 |
{{ formatCurrency(item.unitPrice, 'USD') }} | table column | $9.70 |
{{ formatCurrency(item.amount, 'USD') }} | table column | $58.20 |
{{ sum(rows, 'qty') }} | table footer (rows is the whole array) | 89 |
{{ formatCurrency(totals.total, 'USD') }} | totals block | $1,314.56 |
Tax ({{ formatNumber(totals.taxRate * 100, 1) }}%) | totals block | Tax (7.0%) |
{{ notes }}, shown only if notes | below the table | the note; hidden when notes is null |
The table grows with the array: 6 lines or 60, the template stays the same, and everything below the table moves down. An empty
array ("lines": []) prints the header with no rows. Leave the key out entirely and the table is empty as well.
Example 3: a report that spans pages#
A long list is just a longer array. Paging is the template's job, so the data doesn't change. This inventory report sends 160 items, and the engine:
- splits the table between rows, and repeats the column headers on every page;
- prints the page header and footer on every page, with Page {{page}} of {{pages}} (
pageandpagesare built in); - sorts and groups the rows by category (the author sets Sort by
item.categoryand Group byitem.category), with a subtotal row per group; - prints the grand totals and the report footer once, after the last row.
Files: template.json · data.json · schema.json
{
"warehouse": { "code": "ATL1", "name": "Atlanta DC 1" },
"asOf": "2026-10-21T06:00:00",
"items": [
{ "sku": "APP-TS-1001-01", "description": "Organic cotton crew tee, navy, M", "category": "Apparel", "location": "A-12-3", "onHand": 216, "reserved": 9 },
{ "sku": "APP-TS-1002-01", "description": "Organic cotton crew tee, navy, L", "category": "Apparel", "location": "A-34-1", "onHand": 644, "reserved": 86 }
]
}
The example file has 160 items (5 pages); two are shown here.
| In the template | Where | Prints |
|---|---|---|
{{ warehouse.name }} ({{ warehouse.code }}) | page header, every page | Atlanta DC 1 (ATL1) |
As of {{ formatDate(asOf, 'MM/DD/YYYY h:mm A') }} | page header | As of 10/21/2026 6:00 AM |
{{ count(items) }} | report header, page 1 | 160 |
{{ group.key }} · {{ group.count }} items | group header row | Apparel · 36 items |
{{ formatNumber(item.onHand - item.reserved, 0) }} | table column "Available" | 207 for APP-TS-1001-01 |
{{ formatNumber(sum(rows, 'onHand'), 0) }} | group footer: the group's rows; table footer: all rows | subtotal per category, grand total |
Page {{page}} of {{pages}} | page footer | Page 1 of 5 … Page 5 of 5 |
End of report: {{ count(items) }} items in {{ warehouse.name }}. | report footer, after the last row | End of report: 160 items in Atlanta DC 1. |
The order of items in your data doesn't matter here, because the table sorts it. Without Sort by, rows print in array order and
Group by groups consecutive rows with the same value. In that case, send the array already sorted.
Example 4: many documents in one PDF (a batch)#
To print a day's invoices as one PDF, the author sets One copy per (recordsPath) in the designer's Report tab, here to
invoices. You then send the documents as an array under that key. Each element is a complete invoice, exactly like example 2.
Files: template.json · data.json
{
"invoices": [
{ "invoiceNumber": "INV-2026-0002", "billTo": { "name": "Blue Ridge Outfitters", "…": "…" }, "lines": [ "…4 lines…" ], "totals": { "…": "…" } },
{ "invoiceNumber": "INV-2026-0003", "billTo": { "name": "Harbor & Pine Home Goods", "…": "…" }, "lines": [ "…46 lines…" ], "totals": { "…": "…" } },
{ "invoiceNumber": "INV-2026-0004", "billTo": { "name": "Cedar Valley Pet Co.", "…": "…" }, "lines": [ "…3 lines…" ], "totals": { "…": "…" } }
]
}
- Inside each copy, the record is the root: the template still says
{{ invoiceNumber }}, not{{ invoices[0].invoiceNumber }}. - Page numbers restart per record: the 46-line invoice prints "Page 1 of 2" and "Page 2 of 2", and the other two "Page 1 of 1". The PDF has 4 pages.
- Each copy sees only its own record. A value that every copy prints (a run date, who printed it) must be repeated in every record.
- A template with One copy per set always expects the array. To print one invoice with it, send
{ "invoices": [ {…} ] }.
The CLI does the same with --records and a plain JSON array (Command line).
Example 5: orders with their lines in one report (nested lists)#
Your database probably has orders that contain lines. On a report that lists several orders with their lines, a table can't contain another growing table. Repeater items have a fixed height, and a table inside one is cut off at its box. Send the lines as one flat array, each row carrying its order's fields, and let the table group by order. The table header, the group headers and the paging then work as in example 3.
Files: template.json · data.json (what you send) · orders.json (the nested shape, before flattening)
Nested, as stored:
{ "orders": [
{ "orderNumber": "SO-104410", "orderDate": "2026-10-14", "customer": "Northwind Retail #1187",
"lines": [ { "sku": "APP-TS-1001", "description": "Organic cotton crew tee, navy, M", "qty": 5, "shipped": 0 },
{ "sku": "APP-SK-0310", "description": "Merino hiking socks, 3-pack", "qty": 33, "shipped": 1 } ] }
] }
Flattened, as sent:
{ "reportDate": "2026-10-21",
"lines": [
{ "orderNumber": "SO-104410", "orderDate": "2026-10-14", "customer": "Northwind Retail #1187", "sku": "APP-TS-1001", "description": "Organic cotton crew tee, navy, M", "qty": 5, "shipped": 0 },
{ "orderNumber": "SO-104410", "orderDate": "2026-10-14", "customer": "Northwind Retail #1187", "sku": "APP-SK-0310", "description": "Merino hiking socks, 3-pack", "qty": 33, "shipped": 1 }
] }
The flattening is a few lines in any language:
const orders = [
{ orderNumber: 'SO-104410', orderDate: '2026-10-14', customer: 'Northwind Retail #1187', lines: [{ sku: 'APP-TS-1001', qty: 5, shipped: 0 }, { sku: 'APP-SK-0310', qty: 33, shipped: 1 }] },
{ orderNumber: 'SO-104411', orderDate: '2026-10-15', customer: 'Blue Ridge Outfitters', lines: [{ sku: 'ELE-CB-0102', qty: 29, shipped: 1 }] },
];
const lines = orders.flatMap(({ lines, ...order }) => lines.map((line) => ({ ...order, ...line })));
const data = { reportDate: '2026-10-21', lines };
console.log(data.lines.length, data.lines[2].orderNumber, data.lines[2].customer); // 3 SO-104411 Blue Ridge Outfitters
| In the template | Where | Prints |
|---|---|---|
Group by item.orderNumber | table setting | one group per order |
Order {{ group.key }} · {{ item.customer }} · ordered {{ formatDate(item.orderDate, 'MM/DD/YYYY') }} | group header (item is the group's first row) | Order SO-104410 · Northwind Retail #1187 · ordered 10/14/2026 |
{{ item.qty - item.shipped }} | table column "Open" | 5 |
{{ sum(rows, 'qty') }} | group footer | 38 |
If each order should be a separate document instead (one packing slip per order), use a batch (example 4) and keep the lines nested.
Value types#
| Send | For | Notes |
|---|---|---|
| String | text, barcodes, QR codes | Line breaks (\n) are kept in text boxes |
| Number | quantities, money, weights | Send numbers, not strings: "6" + 1 prints 61. formatNumber and formatCurrency add separators and decimals |
| Boolean | checkboxes, "show only if" | true / false. The string "false" counts as true. 0, "", null and missing count as false |
Date "2026-10-21" | dates | Prints as that date, whatever the server's time zone |
Date-time "2026-10-21T14:30:00" | times | Without an offset it prints as written. With Z or -05:00 it is converted to the render server's time zone, so send the local time you want printed |
| Epoch milliseconds | dates | Converted to the render server's time zone |
null or missing | anything | Prints nothing; coalesce(x, 'default') or x ?? 'default' gives a default |
| Array of objects | tables, repeaters, sum/count/avg/min/max | Up to 100,000 rows per table |
Image data: URI, or an https URL | logos, signatures, photos | URLs load only from hosts in RD_IMAGE_URL_ALLOWLIST (Security); otherwise send a data: URI |
Formatting uses the template's locale (set by the author) unless the request passes options.locale (Render API).
Checklist#
- Field names and nesting match the schema you gave the author. When a field is renamed, update the schema and tell the author. An unknown field prints nothing, so a typo shows up as a blank, not an error.
- Watch
X-Render-Warningsin the response. A non-zero count means something printed empty or was skipped (a table bound to something that isn't an array, a broken expression, an image that couldn't load). - Size limits: the request body (template + data) is limited to 20 MB by default (
RD_BODY_LIMIT_MB), and a document to 2,000 pages (RD_MAX_PAGES). Split very large runs into several requests. - Test with the edge cases: an empty list, a very long list (several pages), missing optional fields, long names and descriptions. The examples on this page are a starting point, and the Playground renders any of them with their data.