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

template.json
{
  "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

TypeFieldsDoes
textvalue, align, bold, underline, size, invertA 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.
rulechar, styleA horizontal line across the full width. `style` of solid, dashed or double; `char` overrides the character used on thermal paper.
feedlinesBlank lines. On thermal paper this is also how you get the cut clear of the print head — the built-ins feed 3 before cutting.
keyvaluepairs[], gapLabel 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.
tablerows, columns[], header, separatorThe 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.
totallabel, value, emphasisA 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.
qrvalue, size, eccA 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.
barcodevalue, symbology, height, hricode39, code128 or ean13. `hri` prints the human-readable digits underneath.
griditems, blocks[], across, down, asN-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.
imagevalue, align, widthA 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.
rowleft, rightTwo expressions on one line, pushed to opposite edges. Useful for “Paid by UPI … Ref YBL2209”.
ifwhen, 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.
eachitems, as, blocks[]Repeat the child blocks for every element. For anything a table cannot express — a line with a note under it, say.
cutpartialCut the paper. Ignored by a printer that cannot.
drawerpin, on_ms, off_msKick the cash drawer as part of the job rather than separately.
pagebreakStart 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.

table block
{
  "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 }
  ]
}
Item Qty Rate Amount ------------------------------------------------ Masala Dosa 2 90.00 180.00 Filter Coffee 3 40.00 120.00 Curd Rice 1 110.00 110.00
48 columns · exactly what the printer receives

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.

FilterExampleGives
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.

before and after
// 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. Set prices_include_tax: false for B2B invoicing, where tax is added on top and total comes out higher than subtotal.

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.

a credit sale block
{ "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.

65 stickers to an A4 sheet
{
  "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.js
// 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
// 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.