Templates
A template is a JSON list of blocks. The same list is walked three ways — to ESC/POS bytes, to a PDF page, and to an HTML preview — which is the reason one payload can print correctly on a 32-column roll and on A4 without your code knowing.
Why not HTML
Because a thermal printer has no browser in it. Anything expressed in CSS has to be simulated for the roll, and the simulation is always slightly wrong — a margin becomes a space, a font size becomes nothing at all, and a table that fits in Chrome runs off a 58mm receipt. Blocks are less expressive on purpose: everything in the format has an honest rendering on every device.
The shape
{
"id": "pos.receipt.mine",
"name": "My receipt",
"paper": "pos80",
"kind": "pos",
"blocks": [ … ]
}paper is what it is designed for; the bridge will still render it on a different
paper, recomputing every width. kind is pos or document and decides which renderer is preferred when both could apply.
Block types
| Type | Fields | Does |
|---|---|---|
text | value, align, bold, underline, size, invert | A line of text. `size` is normal, double_width, double_height, double or quad — on a thermal printer these are real ESC/POS modes, and in a PDF they map to point sizes. |
rule | char, style | A horizontal line across the full width. `style` of solid, dashed or double; `char` overrides the character used on thermal paper. |
feed | lines | Blank lines. On thermal paper this is also how you get the cut clear of the print head — the built-ins feed 3 before cutting. |
keyvalue | pairs[], gap | Label on the left, value on the right, dot-leadered to the full width. For the bill number, date, table and server block at the top of a receipt. |
table | rows, columns[], header, separator | The workhorse. `rows` is an expression yielding an array; each column has a key, a label, a weight and an alignment. Weights are proportions, not character counts, so one definition lays out at every paper width. |
total | label, value, emphasis | A right-aligned total line. `emphasis` prints it double height on thermal and bold in a PDF, which is what makes the grand total findable at a glance. |
qr | value, size, ecc | A QR code, drawn with the printer’s own native command rather than as an image, so it comes out at the head’s true resolution and actually scans. |
barcode | value, symbology, height, hri | code39, code128 or ean13. `hri` prints the human-readable digits underneath. |
grid | items, blocks[], across, down, as | N-up labels. Each element of `items` renders the child blocks once, and the results are placed across and down the sheet — each cell laid out at its own width, so a long name wraps inside its own label rather than across its neighbour. On a continuous roll the cells print one after another with a cut between, which for single-across stock is exactly right. |
image | value, align, width | A logo or a signature — base64 or a `data:` URI, usually `{{ brand.logo }}`. Dithered to one bit for a thermal head and embedded properly in a PDF. `width` is a fraction of the paper, 0.1 to 1.0. |
row | left, right | Two expressions on one line, pushed to opposite edges. Useful for “Paid by UPI … Ref YBL2209”. |
if | when, blocks[] | Include the child blocks only when the expression is truthy. This is how one template covers both a cash sale and a credit sale. |
each | items, as, blocks[] | Repeat the child blocks for every element. For anything a table cannot express — a line with a note under it, say. |
cut | partial | Cut the paper. Ignored by a printer that cannot. |
drawer | pin, on_ms, off_ms | Kick the cash drawer as part of the job rather than separately. |
pagebreak | — | Start a new page. Documents only; ignored on a roll, which has no pages. |
Tables, and why weights
A column defined as “18 characters wide” is a column that is wrong on every other paper size. Weights are proportions of the available width, so the definition below produces sensible output at 32, 48 and 96 columns from the same four lines.
{
"type": "table",
"rows": "{{ lines }}",
"header": true,
"columns": [
{ "key": "name", "label": "Item", "weight": 5 },
{ "key": "qty", "label": "Qty", "weight": 1, "align": "right" },
{ "key": "rate", "label": "Rate", "weight": 2, "align": "right", "money": true },
{ "key": "amount", "label": "Amount", "weight": 2, "align": "right", "money": true }
]
}When a value does not fit its column it wraps onto a continuation line indented under the first, rather than being truncated. Losing the end of an item name is worse than one extra line.
Expressions and filters
Any string field may contain {{ }} expressions over the payload. The syntax is
the familiar one: dotted paths, comparisons, and/or, and filters after
a pipe.
| Filter | Example | Gives |
|---|---|---|
money | {{ total | money }} | 12,34,567.50 — grouped the Indian way, always two decimals. |
date | {{ date | date("dd-MM-yyyy HH:mm") }} | Formats an ISO timestamp in the machine’s zone. |
words | {{ total | words }} | Four Hundred Ten Rupees Only — lakh and crore, not million. |
upper / lower | {{ seller.name | upper }} | Case. Thermal receipts read better in caps at the top. |
round | {{ qty | round(3) }} | Fixed decimal places, for quantities in kg or litres. |
pad | {{ code | pad(8) }} | Pads to a width, for a column you are laying out by hand. |
default | {{ customer.name | default("Cash") }} | A fallback when the field is missing or empty. |
What the bridge adds to your payload
Line amounts, tax splits, totals and the amount in words are computed before rendering, so every template agrees and you are not reimplementing rounding in JavaScript. Send the lines; the arithmetic is done for you.
// You send this:
{
"lines": [
{ "name": "Masala Dosa", "qty": 2, "rate": 90, "tax_percent": 5 },
{ "name": "Filter Coffee", "qty": 3, "rate": 40, "tax_percent": 5 },
{ "name": "Curd Rice", "qty": 1, "rate": 110, "tax_percent": 5 }
]
}
// Templates see this. Prices are treated as tax-inclusive unless you say
// otherwise, so the tax is extracted from the total rather than added to it:
{
"prices_include_tax": true,
"lines": [ {
"name": "Masala Dosa", "qty": 2, "rate": 90, "tax_percent": 5,
"amount": 180.00, // qty × rate
"taxable": 171.43, // the amount less the tax it already contains
"tax_amount": 8.57
}, … ],
"subtotal": 410.00, // what the customer pays for the goods
"discount": 0,
"service_charge": 0,
"taxable_total": 390.48, // what the tax was computed on
"tax_total": 19.52,
"tax_summary": [
{ "percent": 5, "taxable": 390.48, "cgst": 9.76, "sgst": 9.76, "igst": 0, "total": 19.52 }
],
"interstate": false, // decided from the two GSTINs, not from a flag
"rounding": 0,
"total": 410.00,
"total_words": "Four Hundred Ten Rupees Only",
"item_count": 3,
"paper": "pos80",
"columns": 48,
"printed_at": "2026-08-22T18:41:03+05:30",
"copy": 1
}Tax-inclusive is the default, which is the Indian retail convention and what the built-in receipts assume: a ₹90 dosa costs ₹90 at the counter and the tax comes out of it. Setprices_include_tax: falsefor B2B invoicing, where tax is added on top andtotalcomes out higher thansubtotal.
Conditionals
One template usually wants to cover several cases. if keeps that in the template rather
than forcing your app to pick between four near-identical ids.
{ "type": "if", "when": "{{ payment.mode == 'credit' }}", "blocks": [
{ "type": "rule", "style": "dashed" },
{ "type": "text", "value": "CREDIT — due {{ due_date | date('dd-MM-yyyy') }}", "bold": true },
{ "type": "keyvalue", "pairs": [
{ "label": "Outstanding", "value": "{{ customer.balance | money }}" }
] }
] }Labels and stickers
A sheet of stickers is not one document, it is sixty-five of them on one piece of paper. That is
what grid models: the paper carries a grid, each cell is laid out independently at the
cell's own column count, and more labels than a sheet holds start a second sheet rather than being
dropped.
{
"id": "label.mine",
"name": "My stickers",
"paper": "label.a4.65", // five across, thirteen down
"kind": "document",
"blocks": [{
"type": "grid",
"items": "{{ labels }}",
"as": "label",
"blocks": [
{ "type": "text", "value": "{{ label.name | upper }}" },
{ "type": "barcode", "value": "{{ label.barcode }}", "symbology": "code128" },
{ "type": "text", "value": "{{ label.rate | money }}", "align": "right", "bold": true }
]
}]
}The paper decides the grid, so the same template prints twenty-four large stickers or sixty-five
small ones by changing one word. GET /papers on a running bridge lists what that
build knows; anything else is custom:80 for a roll or custom:100x150 for a label, always in millimetres.
Multi-across label stock exists only as sheets, never as a roll. Laying two labels side by side on a thermal roll would mean composing the whole strip as one raster image — ESC/POS text and barcode commands each occupy a full line — and shipping a preset that printed them stacked instead would be a promise the renderer quietly breaks.
Your logo and signature
Set them once under the shop profile and every template picks them up; you never send them in a payload. On a thermal head the image is reduced to one bit per dot with ordered dithering, because a flat threshold turns every anti-aliased edge into a blob. It is scaled down to fit the head but never up — enlarging a small logo at 203 dpi produces a smear, and a shop that wants it bigger should supply a bigger file.
Pushing your own
// Templates live on the machine. Push one and it stays until deleted.
await printol.saveTemplate(JSON.parse(myTemplateJson));
// Reusing a built-in's id shadows it for this shop only.
await printol.saveTemplate({ ...mine, id: 'pos.receipt.retail' });
// And to get the built-in back:
await printol.deleteTemplate('pos.receipt.retail');Templates you push are stored on that machine only and need the templates:write scope. This is the mechanism for “this one customer wants their logo
bigger” — you do not fork anything and you do not ship a build.
Developing one
Use preview(). It is the same renderer, so what it returns is what the printer gets
— and checking the narrow paper is worth the extra call, because 32 columns is where a layout
that looked fine at 48 falls apart.
// Iterate without wasting a roll of paper.
const { content } = await printol.preview({
templateId: 'pos.receipt.mine',
paper: 'pos58', // check the narrow one too — it is where layouts break
format: 'text',
data: sampleBill
});Or open the playground, which does exactly this against your own machine with an editable payload.