API reference
Everything the bridge serves, at http://127.0.0.1:{port}/printol/v1, where {port} is whatever discovery found in 47600–47619.
Discovery
Find the bridge and check it is alive. No token needed.
/printol/v1/ping publicThe only endpoint a web app may call before it knows anything. Answers on every port in the range with the same body, so a client can probe all twenty in parallel and keep the first that replies. Deliberately reveals nothing beyond what is needed to decide whether to continue: no printer names, no invoice data, no machine hostname.
{
"app": "printol",
"protocol": 1,
"version": "0.1.0",
"device_id": "b0f3a1c2-9e7d-4a11-8f30-6c2f0a51d9e4",
"device_name": "Counter PC",
"port": 47600,
"paired": false
}/printol/v1/openapi.json publicServed by the bridge itself so an integration can be generated against the exact build that is running, not against whatever the website last published.
/printol/v1/status token requiredWhat to poll if you show a connection indicator. Cheap: it touches no printer hardware.
{
"ok": true,
"version": "0.1.0",
"uptime_seconds": 90412,
"default_printer_id": "prn_7Kd2",
"queue_depth": 0,
"printers_online": 2,
"paper_defaults": {
"pos": "pos80",
"document": "a4"
}
}Pairing
Trade a human click at the counter for a token bound to your origin.
/printol/v1/pair publicStarts a pairing request. The desktop app raises a window showing your app name, your origin and a six-digit code. Nothing is granted until somebody clicks Allow — a website cannot pair itself, which is the entire security model.
{
"app_name": "Anand Traders Billing",
"origin": "https://billing.example.com",
"scopes": [
"print",
"catalog:read",
"sales:write"
]
}{
"request_id": "pr_9f2a44c1",
"code": "418 203",
"expires_at": "2026-08-22T18:44:00+05:30",
"poll_after_ms": 1000
}/printol/v1/pair/{request_id} publicReturns `pending` until the human decides. On `approved` it returns the token exactly once — store it, because asking again returns `consumed`. Tokens are bound to the origin that requested them and are revocable from the desktop app at any time.
{
"status": "approved",
"token": "ptl_live_8Fh2…",
"scopes": [
"print",
"catalog:read",
"sales:write"
],
"device_name": "Counter PC"
}Printers
What is connected and what it can do.
/printol/v1/printers token requiredPrinters as the shop configured them in the desktop app, not the raw OS list: each carries the paper it is loaded with and whether it can cut and kick a cash drawer. Pick by `id`, or omit the printer on a print call and let the bridge use the default for that paper.
{
"printers": [
{
"id": "prn_7Kd2",
"name": "Counter thermal",
"transport": "system",
"target": "EPSON TM-T82",
"paper": "pos80",
"columns": 48,
"capabilities": {
"cut": true,
"drawer": true,
"qr": true,
"barcode": true
},
"is_default_for": [
"pos58",
"pos80"
],
"online": true
},
{
"id": "prn_3Lm9",
"name": "Back office A4",
"transport": "system",
"target": "HP LaserJet M1005",
"paper": "a4",
"columns": 96,
"capabilities": {
"cut": false,
"drawer": false,
"qr": false,
"barcode": false
},
"is_default_for": [
"a4",
"a5"
],
"online": true
}
]
}/printol/v1/printers/{id}/test token requiredPrints a one-off page that exercises alignment, the character width for the loaded paper, the cut and (on a POS printer) a QR code. The right first call in any integration: if this works, your problem is the payload, not the plumbing.
{
"job_id": "job_01J8…",
"status": "queued"
}/printol/v1/printers/{id}/drawer token requiredSends the ESC/POS drawer pulse. `pin` selects which of the two drawer pins to fire (2 is almost always correct); `on_ms`/`off_ms` tune the pulse for stiff solenoids.
{
"pin": 2,
"on_ms": 60,
"off_ms": 240
}Templates
The layouts your data is poured into.
/printol/v1/templates token requiredBoth the built-in templates and anything the shop has added. Each entry carries the paper it targets and a sample payload you can print straight back to see the shape it wants.
{
"templates": [
{
"id": "pos.receipt.retail",
"name": "Retail receipt",
"paper": "pos80",
"kind": "pos",
"builtin": true,
"description": "Line items, tax summary, payment mode, QR for the digital copy."
},
{
"id": "a4.invoice.gst",
"name": "GST tax invoice",
"paper": "a4",
"kind": "document",
"builtin": true,
"description": "Two-copy GST invoice with HSN-wise tax breakup and amount in words."
}
]
}/printol/v1/templates/{id} token requiredReturns the template exactly as the renderer sees it. Read this before writing a custom one — the built-ins are the reference implementation.
/printol/v1/templates token requiredUpserts by `id`. A custom template may shadow a built-in by reusing its id, which is how a shop overrides the standard receipt without you shipping a new build. Built-ins themselves are never modified and reappear if the custom one is deleted.
{
"id": "pos.receipt.mine",
"name": "My receipt",
"paper": "pos80",
"kind": "pos",
"blocks": [
{
"type": "text",
"value": "{{ seller.name }}",
"align": "center",
"bold": true,
"size": "double"
},
{
"type": "rule"
},
{
"type": "table",
"columns": [
{
"key": "name",
"label": "Item",
"weight": 5
},
{
"key": "qty",
"label": "Qty",
"weight": 1,
"align": "right"
},
{
"key": "amount",
"label": "Amount",
"weight": 2,
"align": "right",
"money": true
}
],
"rows": "{{ lines }}"
},
{
"type": "cut"
}
]
}/printol/v1/templates/{id} token requiredBuilt-ins cannot be deleted; deleting a shadow restores the built-in underneath.
Printing
Send data. Get paper.
/printol/v1/print token requiredThe endpoint the whole product exists for. You send business data, never bytes and never HTML; the bridge renders it with the named template for the paper the chosen printer is loaded with. The same payload therefore prints correctly on a 58mm roll and on A4 without your web app knowing the difference.
Returns as soon as the job is queued. Print is a physical act with a queue behind it, so a synchronous "printed" response would be a lie — poll `GET /jobs/{id}` or subscribe to `GET /events` if you need to know it landed.
{
"template_id": "pos.receipt.retail",
"printer_id": "prn_7Kd2",
"copies": 1,
"data": {
"number": "INV-2043",
"date": "2026-08-22T18:41:00+05:30",
"seller": {
"name": "Anand Traders",
"address": "14 Nehru Road, Coimbatore 641001",
"gstin": "33ABCDE1234F1Z5",
"phone": "+91 98400 11223"
},
"customer": {
"name": "Ravi Kumar",
"phone": "+91 90000 55555"
},
"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
}
],
"prices_include_tax": true,
"payment": {
"mode": "upi",
"reference": "YBL2209"
},
"footer": "Thank you. Visit again."
},
"options": {
"cut": true,
"open_drawer": false,
"idempotency_key": "INV-2043:1"
}
}{
"job_id": "job_01J8XR3M6K",
"status": "queued",
"printer_id": "prn_7Kd2"
}/printol/v1/print/raw token requiredThe escape hatch, for when you already have ESC/POS or ZPL from somewhere else. `data` is base64. The bridge does not inspect it beyond refusing a payload larger than 2 MB, so a malformed stream can leave the printer in a strange mode — send an initialise (`1B 40`) first.
{
"printer_id": "prn_7Kd2",
"encoding": "base64",
"data": "G0AxLi4u"
}{
"job_id": "job_01J8XR4A11",
"status": "queued"
}/printol/v1/preview token requiredSame inputs as `POST /print`, but returns the rendered result instead of queueing it. `format` is `text` for the monospace receipt as the thermal printer will lay it out, `html` for a styled preview you can drop in an iframe, or `pdf` for a base64 document. Use it to build a "preview before printing" screen, and in your own tests.
{
"template_id": "pos.receipt.retail",
"paper": "pos80",
"format": "text",
"data": {
"number": "INV-2043",
"date": "2026-08-22T18:41:00+05:30",
"seller": {
"name": "Anand Traders",
"address": "14 Nehru Road, Coimbatore 641001",
"gstin": "33ABCDE1234F1Z5",
"phone": "+91 98400 11223"
},
"customer": {
"name": "Ravi Kumar",
"phone": "+91 90000 55555"
},
"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
}
],
"prices_include_tax": true,
"payment": {
"mode": "upi",
"reference": "YBL2209"
},
"footer": "Thank you. Visit again."
}
}{
"format": "text",
"columns": 48,
"content": "ANAND TRADERS\\n--------\\n…"
}Jobs
What happened after you sent it.
/printol/v1/jobs token requiredNewest first. Scoped to the calling app: you see the jobs you sent, not the ones the shop printed from the desktop UI.
/printol/v1/jobs/{id} token required`status` moves queued → printing → done, or → failed with an `error` that names the transport that refused. A job that reached the OS spooler reports `done`: no consumer printer tells us the paper actually came out.
{
"id": "job_01J8XR3M6K",
"status": "done",
"template_id": "pos.receipt.retail",
"printer_id": "prn_7Kd2",
"bytes": 1284,
"created_at": "2026-08-22T18:41:02+05:30",
"completed_at": "2026-08-22T18:41:03+05:30"
}/printol/v1/jobs/{id}/pdf token requiredA4 and A5 jobs render to PDF on the way to the printer, and it is kept. This is the reliable fallback when silent spooling is not available on the machine: fetch it and hand the user a normal browser print dialog.
/printol/v1/events token requiredAn `EventSource` stream. Emits `job` on every status transition and `printers` when the configured list changes. Keeps a 15-second heartbeat so a proxy does not decide the connection is idle.
Catalog & stock
The shop’s item list, shared with your app.
/printol/v1/catalog/items token requiredSupports `q` (name or SKU prefix), `category`, `limit` and `cursor`. This is the local catalog the shop maintains in the desktop app; it exists so a web app can bill against the same item list the counter uses, rather than keeping a second one in sync.
{
"items": [
{
"id": "itm_4Rt8",
"sku": "DOSA-MSL",
"name": "Masala Dosa",
"category": "Tiffin",
"unit": "plate",
"rate": 90,
"tax_percent": 5,
"stock": null,
"track_stock": false
}
],
"next_cursor": null
}/printol/v1/catalog/items token requiredRequires the `catalog:write` scope. `sku` is unique and is what upserts match on.
{
"sku": "DOSA-MSL",
"name": "Masala Dosa",
"category": "Tiffin",
"unit": "plate",
"rate": 90,
"tax_percent": 5,
"track_stock": false
}/printol/v1/catalog/items/{id} token requiredBy `id` or by `sku:` prefix, so you can look up without storing our ids.
/printol/v1/catalog/items/{id} token requiredPartial update. Sending `rate` does not disturb stock, and vice versa.
/printol/v1/catalog/items/{id} token requiredSoft delete: the item stops appearing in the catalog but old invoices still render, because an invoice line keeps its own copy of the name and rate.
/printol/v1/catalog/stock token requiredStock is a ledger, not a number: every change is a row with a reason, and the item balance is their sum. `qty` is signed — negative for issue, positive for receipt.
{
"item_id": "itm_4Rt8",
"qty": -2,
"reason": "sale",
"reference": "INV-2043"
}/printol/v1/catalog/categories token requiredNested and shop-defined. Each entry carries `item_count`, because the first question anybody asks before deleting one is what it takes with it. The answer is nothing: deleting a category unlinks its items, it never removes them.
{
"categories": [
{
"id": "cat_2f8a",
"name": "Tiffin",
"colour": "#f59e0b",
"position": 0,
"item_count": 14
}
]
}/printol/v1/catalog/categories token required`parent_id` nests it. A category cannot be its own parent — that makes the tree infinite and is refused rather than detected later.
{
"name": "South Indian",
"parent_id": "cat_2f8a",
"colour": "#f59e0b",
"position": 1
}/printol/v1/catalog/categories/{id} token requiredUnlinks its items rather than removing them, and reports `items_kept: true` so an integration can say so. Losing a category must never look like losing stock.
Parties
Customers and suppliers, with the balance each one carries.
/printol/v1/parties token requiredFilter with `q` (name or phone) and `kind` of `customer` or `supplier`. Each party carries a live `balance` — opening balance, plus what they have been billed and not paid, less what the shop owes them on purchases. It is computed rather than stored, because a stored balance that disagrees with the ledger is the hardest thing there is to explain to a shopkeeper.
{
"parties": [
{
"id": "pty_9c31",
"kind": "customer",
"name": "Ravi Kumar",
"phone": "+91 90000 55555",
"opening_balance": 0,
"balance": 1240.5,
"created_at": "2026-08-22T18:41:00+05:30",
"updated_at": "2026-08-22T18:41:00+05:30"
}
]
}/printol/v1/parties token requiredA supplier who also buys from the shop is one row with `kind: "both"`, not two — two rows means two balances that will disagree.
{
"name": "Green Grocers",
"kind": "supplier",
"phone": "+91 98400 22334",
"gstin": "33AAAAA0000A1Z5",
"opening_balance": 500
}/printol/v1/parties/{id} token requiredThe balance is derived on read from the invoices and purchases that reference them, so it can never drift from the documents it is a summary of.
/printol/v1/parties/{id} token requiredSoft, like items: they stop appearing in searches, and every invoice that names them still renders.
Purchases
What the shop bought, and the stock it brought in.
/printol/v1/purchases token requiredFilter with `from`, `to` and `limit`. Newest first.
/printol/v1/purchases token requiredWrites the purchase, adds the stock, and updates each item’s last cost — in one transaction. Receiving goods and recording what they cost are the same event, and splitting them is how a shop ends up with stock it cannot account for.
Prices are treated as tax-exclusive here, the opposite of a retail receipt: a supplier invoice shows the goods and then adds tax.
{
"supplier": {
"name": "Green Grocers"
},
"party_id": "pty_4a12",
"supplier_invoice": "GG/119",
"date": "2026-08-22T09:10:00+05:30",
"lines": [
{
"item_id": "itm_4Rt8",
"name": "Masala Dosa Mix",
"qty": 20,
"rate": 30,
"tax_percent": 5
}
],
"paid": 0
}{
"id": "pur_71bd",
"number": "PUR-1001",
"total": 630,
"lines": []
}/printol/v1/purchases/{id} token requiredLines keep their own copy of the name and rate at the time of buying, so a purchase renders correctly years later even if the item has been repriced or deleted.
Orders
The restaurant flow: open a table, fire the kitchen, bill it.
/printol/v1/orders token requiredFilter with `status` of `open`, `billed` or `cancelled`. Each order carries its running `total` and `unsent_lines`, so a floor screen showing twelve tables is one request rather than twenty-five.
{
"orders": [
{
"id": "ord_5b90",
"number": "ORD-1001",
"status": "open",
"table_label": "7",
"covers": 4,
"server": "Suresh",
"total": 1000,
"unsent_lines": 2,
"created_at": "2026-08-22T21:02:00+05:30",
"updated_at": "2026-08-22T21:07:00+05:30"
}
]
}/printol/v1/orders token requiredA table is opened before anything is ordered, which is why this is separate from adding lines.
{
"table_label": "7",
"covers": 4,
"server": "Suresh"
}/printol/v1/orders/{id} token requiredEach line carries `kot_sent_at`, which is null until the kitchen has been told about it.
/printol/v1/orders/{id}/lines token requiredLines arrive unsent. That is what makes the next kitchen ticket print only the new ones — a table ordering a second round must not make the kitchen cook the first round again.
{
"lines": [
{
"item_id": "itm_4Rt8",
"name": "Paneer Tikka",
"qty": 2,
"rate": 320,
"note": "less spicy"
}
]
}/printol/v1/orders/{id}/kot token requiredPrints everything not yet sent — optionally filtered to one `course` — and marks those lines sent only once the job is queued. The order matters: marking first and printing second loses food whenever the printer refuses, and the kitchen never learns it was supposed to cook something.
{
"printer_id": "prn_7Kd2",
"course": "main"
}{
"job_id": "job_01J8XR",
"lines_sent": 3
}/printol/v1/orders/{id}/bill token requiredCloses the order, writes the invoice, moves the stock, and optionally prints — in one call, so a table cannot end up billed twice or served free.
{
"payment_mode": "card",
"print": {
"template_id": "pos.receipt.restaurant"
}
}Sales
Record the bill and print it in one call.
/printol/v1/sales/invoices token requiredFilter with `from`, `to`, `q` and `limit`. Newest first.
/printol/v1/sales/invoices token requiredRecords the sale in the local ledger, decrements stock for every line whose item tracks it, and — if `print` is set — queues the print in the same transaction. That combination is why this exists rather than you calling `/print` and keeping your own books: a receipt that printed but was never recorded is the failure mode shops actually complain about.
{
"number": "INV-2043",
"date": "2026-08-22T18:41:00+05:30",
"seller": {
"name": "Anand Traders",
"address": "14 Nehru Road, Coimbatore 641001",
"gstin": "33ABCDE1234F1Z5",
"phone": "+91 98400 11223"
},
"customer": {
"name": "Ravi Kumar",
"phone": "+91 90000 55555"
},
"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
}
],
"prices_include_tax": true,
"payment": {
"mode": "upi",
"reference": "YBL2209"
},
"footer": "Thank you. Visit again.",
"print": {
"template_id": "pos.receipt.retail",
"printer_id": "prn_7Kd2",
"copies": 1
}
}{
"invoice": {
"id": "inv_01J8XR",
"number": "INV-2043",
"total": 409.5
},
"job_id": "job_01J8XR3M6K"
}/printol/v1/sales/invoices/{id} token requiredLines keep their own copy of the name, rate and tax at the time of sale, so an invoice renders identically years later even if the catalog item has been repriced or deleted.
Cash book
Income and expenses that are not a bill.
/printol/v1/ledger token requiredFilter with `kind` of `income` or `expense`, plus `from` and `to`.
/printol/v1/ledger token requiredAmounts are always stored positive; `kind` carries the direction. A signed amount *and* a kind would be two sources of truth for one fact, so a negative amount is simply taken as its magnitude.
{
"kind": "expense",
"category": "Gas",
"amount": 1200,
"payment_mode": "cash",
"date": "2026-08-22T08:00:00+05:30"
}/printol/v1/ledger/{id} token requiredA hard delete, unlike items and parties. A cash book entry references nothing and nothing references it, so there is no history to preserve by keeping it.
Shop profile
The letterhead every document inherits, and how this shop taxes.
/printol/v1/shop token requiredWhat appears on every document this counter issues. Your app does not need to send any of it — the bridge folds it into every render, so a receipt printed from a web app that knows nothing about this shop still carries its name, address and tax number.
{
"brand": {
"name": "Anand Traders",
"subtitle": "Since 1994",
"address": "14 Nehru Road, Coimbatore 641001",
"gstin": "33ABCDE1234F1Z5",
"phone": "+91 98400 11223",
"email": "",
"footer": "Thank you. Visit again.",
"terms": "",
"invoice_title": "TAX INVOICE",
"logo": "data:image/png;base64,…",
"signature": "data:image/png;base64,…"
},
"tax_mode": "item",
"bill_tax_percent": 0
}/printol/v1/shop token requiredNeeds `templates:write`, not `print`: changing the letterhead changes every document the shop issues from then on, which is a larger act than sending one job.
`logo` and `signature` are base64 images or `data:` URIs. They are dithered to one bit for a thermal head and embedded properly in a PDF. Send only the keys you mean to change.
`tax_mode` is `item` for a rate per line — what GST requires — or `bill` for one rate across the whole bill, which is how a small single-rate shop actually thinks and saves setting the same percentage on four hundred items. In `bill` mode the per-line rates are overwritten rather than merely ignored, so a template printing a line’s rate shows the one that was applied.
{
"brand": {
"name": "Anand Traders",
"footer": "Thank you. Visit again."
},
"tax_mode": "bill",
"bill_tax_percent": 5
}Reports
Totals and exports out of the local ledger.
/printol/v1/reports/sales token required`from`, `to`, `group_by` of `day|item|payment_mode`, and `format` of `json|csv`. The CSV is the same file the desktop app’s Export button produces, so a web app can offer the download without the shopkeeper leaving the browser.
/printol/v1/reports/cash token requiredSales and other income against purchases and expenses. The result is called `net`, deliberately not "profit": it ignores stock movement and anything the shop has not written down, and calling it profit would invite somebody to file it.
{
"sales": 41000,
"purchases": 18500,
"other_income": 0,
"expenses": 4200,
"net": 18300
}/printol/v1/reports/low-stock token requiredOnly items that track stock and have a reorder level set. An item with no level is one the shop has decided not to be told about.
Errors
Every failure is { error: { code, message } } with
a stable code. Branch on the code, show the message to
nobody — write your own sentence for your own user.
| Code | HTTP | Means |
|---|---|---|
| not_paired | 401 | No token, or a token this bridge does not know. |
| origin_mismatch | 403 | Token was issued to a different origin. |
| scope_missing | 403 | Token is valid but was not granted this scope. |
| pair_pending | 202 | Nobody has clicked Allow yet. Keep polling. |
| pair_denied | 403 | The human said no. Do not retry automatically. |
| printer_offline | 503 | The transport could not reach the printer. |
| template_not_found | 404 | No built-in or custom template with that id. |
| render_failed | 422 | The template referenced a field your data lacks. |
| payload_too_large | 413 | Raw data above 2 MB, or a job above 8 MB. |
| rate_limited | 429 | More than 60 jobs a minute from one token. |
The SDK adds two codes of its own that never come off the wire: unreachable when nothing answered on the port range, and protocol_too_new when the bridge speaks a version the SDK does not.
Conventions
- Money
- Numbers, in the shop’s currency, with at most two decimal places. Not paise, not strings. The templates format them; you do not.
- Dates
- ISO 8601 with an offset. The bridge formats for display using the machine’s locale, so send the offset and let it decide.
- Ids
- Opaque strings with a type prefix — itm_, inv_, prn_, job_. Never parse them; they are not ordered and their shape may change.
- Pagination
- Cursor based. Pass the next_cursor you were given; a null cursor means the end.
- Idempotency
- Any write accepts options.idempotency_key. The same key within 24 hours returns the original result instead of doing the work twice.
- Rate limit
- Sixty jobs a minute per token, which is far above a human counter and far below a runaway loop.