API reference

Everything the bridge serves, at http://127.0.0.1:{port}/printol/v1, where {port} is whatever discovery found in 47600–47619.

Protocol
v1
Auth
Authorization: Bearer
Machine readable

Discovery

Find the bridge and check it is alive. No token needed.

GET /printol/v1/ping public
Is the bridge here, and is it ours?

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

Response 200
body
{
  "app": "printol",
  "protocol": 1,
  "version": "0.1.0",
  "device_id": "b0f3a1c2-9e7d-4a11-8f30-6c2f0a51d9e4",
  "device_name": "Counter PC",
  "port": 47600,
  "paired": false
}
GET /printol/v1/openapi.json public
This API, as OpenAPI 3.1

Served by the bridge itself so an integration can be generated against the exact build that is running, not against whatever the website last published.

GET /printol/v1/status token required
Health, default printer and queue depth

What to poll if you show a connection indicator. Cheap: it touches no printer hardware.

Response 200
body
{
  "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.

POST /printol/v1/pair public
Ask the human at the counter for permission

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

Request
body
{
  "app_name": "Anand Traders Billing",
  "origin": "https://billing.example.com",
  "scopes": [
    "print",
    "catalog:read",
    "sales:write"
  ]
}
Response 200
body
{
  "request_id": "pr_9f2a44c1",
  "code": "418 203",
  "expires_at": "2026-08-22T18:44:00+05:30",
  "poll_after_ms": 1000
}
GET /printol/v1/pair/{request_id} public
Poll a pairing request

Returns `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.

Response 200
body
{
  "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.

GET /printol/v1/printers token required
List configured printers

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

Response 200
body
{
  "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
    }
  ]
}
POST /printol/v1/printers/{id}/test token required
Print a test page

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

Response 200
body
{
  "job_id": "job_01J8…",
  "status": "queued"
}
POST /printol/v1/printers/{id}/drawer token required
Kick the cash drawer

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

Request
body
{
  "pin": 2,
  "on_ms": 60,
  "off_ms": 240
}

Templates

The layouts your data is poured into.

GET /printol/v1/templates token required
List templates

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

Response 200
body
{
  "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."
    }
  ]
}
GET /printol/v1/templates/{id} token required
Fetch one template with its block tree and sample payload

Returns the template exactly as the renderer sees it. Read this before writing a custom one — the built-ins are the reference implementation.

POST /printol/v1/templates token required
Create or replace a custom template

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

Request
body
{
  "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"
    }
  ]
}
DELETE /printol/v1/templates/{id} token required
Delete a custom template

Built-ins cannot be deleted; deleting a shadow restores the built-in underneath.

Printing

Send data. Get paper.

POST /printol/v1/print token required
Print data through a template

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

Request
body
{
  "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"
  }
}
Response 200
body
{
  "job_id": "job_01J8XR3M6K",
  "status": "queued",
  "printer_id": "prn_7Kd2"
}
POST /printol/v1/print/raw token required
Send bytes straight to a printer

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

Request
body
{
  "printer_id": "prn_7Kd2",
  "encoding": "base64",
  "data": "G0AxLi4u"
}
Response 200
body
{
  "job_id": "job_01J8XR4A11",
  "status": "queued"
}
POST /printol/v1/preview token required
Render without printing

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

Request
body
{
  "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."
  }
}
Response 200
body
{
  "format": "text",
  "columns": 48,
  "content": "ANAND TRADERS\\n--------\\n…"
}

Jobs

What happened after you sent it.

GET /printol/v1/jobs token required
Recent jobs

Newest first. Scoped to the calling app: you see the jobs you sent, not the ones the shop printed from the desktop UI.

GET /printol/v1/jobs/{id} token required
One job

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

Response 200
body
{
  "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"
}
GET /printol/v1/jobs/{id}/pdf token required
Download the PDF a document job produced

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

GET /printol/v1/events token required
Server-sent events for job and printer changes

An `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.

GET /printol/v1/catalog/items token required
List catalog items

Supports `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.

Response 200
body
{
  "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
}
POST /printol/v1/catalog/items token required
Create an item

Requires the `catalog:write` scope. `sku` is unique and is what upserts match on.

Request
body
{
  "sku": "DOSA-MSL",
  "name": "Masala Dosa",
  "category": "Tiffin",
  "unit": "plate",
  "rate": 90,
  "tax_percent": 5,
  "track_stock": false
}
GET /printol/v1/catalog/items/{id} token required
Fetch one item

By `id` or by `sku:` prefix, so you can look up without storing our ids.

PATCH /printol/v1/catalog/items/{id} token required
Update an item

Partial update. Sending `rate` does not disturb stock, and vice versa.

DELETE /printol/v1/catalog/items/{id} token required
Delete an item

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

POST /printol/v1/catalog/stock token required
Record a stock movement

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

Request
body
{
  "item_id": "itm_4Rt8",
  "qty": -2,
  "reason": "sale",
  "reference": "INV-2043"
}
GET /printol/v1/catalog/categories token required
List categories

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

Response 200
body
{
  "categories": [
    {
      "id": "cat_2f8a",
      "name": "Tiffin",
      "colour": "#f59e0b",
      "position": 0,
      "item_count": 14
    }
  ]
}
POST /printol/v1/catalog/categories token required
Create or update a category

`parent_id` nests it. A category cannot be its own parent — that makes the tree infinite and is refused rather than detected later.

Request
body
{
  "name": "South Indian",
  "parent_id": "cat_2f8a",
  "colour": "#f59e0b",
  "position": 1
}
DELETE /printol/v1/catalog/categories/{id} token required
Delete a category

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

GET /printol/v1/parties token required
List customers and suppliers

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

Response 200
body
{
  "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"
    }
  ]
}
POST /printol/v1/parties token required
Create or update a party

A supplier who also buys from the shop is one row with `kind: "both"`, not two — two rows means two balances that will disagree.

Request
body
{
  "name": "Green Grocers",
  "kind": "supplier",
  "phone": "+91 98400 22334",
  "gstin": "33AAAAA0000A1Z5",
  "opening_balance": 500
}
GET /printol/v1/parties/{id} token required
Fetch one party with its balance

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

DELETE /printol/v1/parties/{id} token required
Delete a party

Soft, 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.

GET /printol/v1/purchases token required
List purchases

Filter with `from`, `to` and `limit`. Newest first.

POST /printol/v1/purchases token required
Record a purchase

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

Request
body
{
  "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
}
Response 200
body
{
  "id": "pur_71bd",
  "number": "PUR-1001",
  "total": 630,
  "lines": []
}
GET /printol/v1/purchases/{id} token required
Fetch one purchase with its lines

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

GET /printol/v1/orders token required
List orders

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

Response 200
body
{
  "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"
    }
  ]
}
POST /printol/v1/orders token required
Open an order

A table is opened before anything is ordered, which is why this is separate from adding lines.

Request
body
{
  "table_label": "7",
  "covers": 4,
  "server": "Suresh"
}
GET /printol/v1/orders/{id} token required
Fetch one order with its lines

Each line carries `kot_sent_at`, which is null until the kitchen has been told about it.

POST /printol/v1/orders/{id}/lines token required
Add lines to an open order

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

Request
body
{
  "lines": [
    {
      "item_id": "itm_4Rt8",
      "name": "Paneer Tikka",
      "qty": 2,
      "rate": 320,
      "note": "less spicy"
    }
  ]
}
POST /printol/v1/orders/{id}/kot token required
Print a kitchen ticket for what is new

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

Request
body
{
  "printer_id": "prn_7Kd2",
  "course": "main"
}
Response 200
body
{
  "job_id": "job_01J8XR",
  "lines_sent": 3
}
POST /printol/v1/orders/{id}/bill token required
Turn an order into an invoice

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

Request
body
{
  "payment_mode": "card",
  "print": {
    "template_id": "pos.receipt.restaurant"
  }
}

Sales

Record the bill and print it in one call.

GET /printol/v1/sales/invoices token required
List invoices

Filter with `from`, `to`, `q` and `limit`. Newest first.

POST /printol/v1/sales/invoices token required
Record an invoice, and optionally print it

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

Request
body
{
  "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
  }
}
Response 200
body
{
  "invoice": {
    "id": "inv_01J8XR",
    "number": "INV-2043",
    "total": 409.5
  },
  "job_id": "job_01J8XR3M6K"
}
GET /printol/v1/sales/invoices/{id} token required
Fetch one invoice with its lines

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

GET /printol/v1/ledger token required
List income and expense entries

Filter with `kind` of `income` or `expense`, plus `from` and `to`.

POST /printol/v1/ledger token required
Record income or an expense

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

Request
body
{
  "kind": "expense",
  "category": "Gas",
  "amount": 1200,
  "payment_mode": "cash",
  "date": "2026-08-22T08:00:00+05:30"
}
DELETE /printol/v1/ledger/{id} token required
Delete a cash book entry

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

GET /printol/v1/shop token required
The shop’s letterhead and tax setting

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

Response 200
body
{
  "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
}
POST /printol/v1/shop token required
Change the letterhead or the tax mode

Needs `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.

Request
body
{
  "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.

GET /printol/v1/reports/sales token required
Sales totals for a period

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

GET /printol/v1/reports/cash token required
Money in and out for a period

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

Response 200
body
{
  "sales": 41000,
  "purchases": 18500,
  "other_income": 0,
  "expenses": 4200,
  "net": 18300
}
GET /printol/v1/reports/low-stock token required
Items at or below their reorder level

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

CodeHTTPMeans
not_paired401No token, or a token this bridge does not know.
origin_mismatch403Token was issued to a different origin.
scope_missing403Token is valid but was not granted this scope.
pair_pending202Nobody has clicked Allow yet. Keep polling.
pair_denied403The human said no. Do not retry automatically.
printer_offline503The transport could not reach the printer.
template_not_found404No built-in or custom template with that id.
render_failed422The template referenced a field your data lacks.
payload_too_large413Raw data above 2 MB, or a job above 8 MB.
rate_limited429More 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.