{"openapi":"3.1.0","info":{"title":"Printol Bridge","version":"0.1.0","summary":"Local print bridge for POS and A4 printing from web applications.","description":"The Printol desktop app serves this API on the loopback interface.\n\nThe port is not fixed: the bridge binds the first free port in a twenty-port range\nand clients discover it by probing `GET /ping` on each. See\nhttps://printol.crawlink.com/docs/discovery.\n\nProtocol version 1.","license":{"name":"Free to use","url":"https://printol.crawlink.com/legal/terms"},"contact":{"name":"Printol","url":"https://printol.crawlink.com/contact"}},"servers":[{"url":"http://127.0.0.1:{port}","description":"The bridge on this machine. Discover the port; do not assume it.","variables":{"port":{"default":"47600","description":"Whichever port in the range answered GET /ping."}}}],"tags":[{"name":"discovery","description":"Find the bridge and check it is alive. No token needed."},{"name":"pairing","description":"Trade a human click at the counter for a token bound to your origin."},{"name":"printers","description":"What is connected and what it can do."},{"name":"templates","description":"The layouts your data is poured into."},{"name":"printing","description":"Send data. Get paper."},{"name":"jobs","description":"What happened after you sent it."},{"name":"catalog","description":"The shop’s item list, shared with your app."},{"name":"parties","description":"Customers and suppliers, with the balance each one carries."},{"name":"purchases","description":"What the shop bought, and the stock it brought in."},{"name":"orders","description":"The restaurant flow: open a table, fire the kitchen, bill it."},{"name":"sales","description":"Record the bill and print it in one call."},{"name":"ledger","description":"Income and expenses that are not a bill."},{"name":"shop","description":"The letterhead every document inherits, and how this shop taxes."},{"name":"reports","description":"Totals and exports out of the local ledger."}],"paths":{"/printol/v1/ping":{"get":{"operationId":"getPing","summary":"Is the bridge here, and is it ours?","description":"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.","tags":["discovery"],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"app":"printol","protocol":1,"version":"0.1.0","device_id":"b0f3a1c2-9e7d-4a11-8f30-6c2f0a51d9e4","device_name":"Counter PC","port":47600,"paired":false}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/openapi.json":{"get":{"operationId":"getOpenapiJson","summary":"This API, as OpenAPI 3.1","description":"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.","tags":["discovery"],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/status":{"get":{"operationId":"getStatus","summary":"Health, default printer and queue depth","description":"What to poll if you show a connection indicator. Cheap: it touches no printer hardware.","tags":["discovery"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"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"}}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/pair":{"post":{"operationId":"postPair","summary":"Ask the human at the counter for permission","description":"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.","tags":["pairing"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"app_name":"Anand Traders Billing","origin":"https://billing.example.com","scopes":["print","catalog:read","sales:write"]}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"request_id":"pr_9f2a44c1","code":"418 203","expires_at":"2026-08-22T18:44:00+05:30","poll_after_ms":1000}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/pair/{request_id}":{"get":{"operationId":"getPairByRequestId","summary":"Poll a pairing request","description":"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.","tags":["pairing"],"security":[],"parameters":[{"name":"request_id","in":"path","required":true,"schema":{"type":"string"},"description":"Pairing request id."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"status":"approved","token":"ptl_live_8Fh2…","scopes":["print","catalog:read","sales:write"],"device_name":"Counter PC"}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/printers":{"get":{"operationId":"getPrinters","summary":"List configured printers","description":"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.","tags":["printers"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"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}]}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/printers/{id}/test":{"post":{"operationId":"postPrintersByIdTest","summary":"Print a test page","description":"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.","tags":["printers"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Resource id."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"job_id":"job_01J8…","status":"queued"}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/printers/{id}/drawer":{"post":{"operationId":"postPrintersByIdDrawer","summary":"Kick the cash drawer","description":"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.","tags":["printers"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Resource id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"pin":2,"on_ms":60,"off_ms":240}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/templates":{"get":{"operationId":"getTemplates","summary":"List templates","description":"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.","tags":["templates"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"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."}]}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}},"post":{"operationId":"postTemplates","summary":"Create or replace a custom template","description":"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.","tags":["templates"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"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"}]}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/templates/{id}":{"get":{"operationId":"getTemplatesById","summary":"Fetch one template with its block tree and sample payload","description":"Returns the template exactly as the renderer sees it. Read this before writing a custom one — the built-ins are the reference implementation.","tags":["templates"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Resource id."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}},"delete":{"operationId":"deleteTemplatesById","summary":"Delete a custom template","description":"Built-ins cannot be deleted; deleting a shadow restores the built-in underneath.","tags":["templates"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Resource id."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/print":{"post":{"operationId":"postPrint","summary":"Print data through a template","description":"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.\n\nReturns 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.","tags":["printing"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"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"}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"job_id":"job_01J8XR3M6K","status":"queued","printer_id":"prn_7Kd2"}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/print/raw":{"post":{"operationId":"postPrintRaw","summary":"Send bytes straight to a printer","description":"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.","tags":["printing"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"printer_id":"prn_7Kd2","encoding":"base64","data":"G0AxLi4u"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"job_id":"job_01J8XR4A11","status":"queued"}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/preview":{"post":{"operationId":"postPreview","summary":"Render without printing","description":"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.","tags":["printing"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"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."}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"format":"text","columns":48,"content":"ANAND TRADERS\\n--------\\n…"}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/jobs":{"get":{"operationId":"getJobs","summary":"Recent jobs","description":"Newest first. Scoped to the calling app: you see the jobs you sent, not the ones the shop printed from the desktop UI.","tags":["jobs"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/jobs/{id}":{"get":{"operationId":"getJobsById","summary":"One job","description":"`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.","tags":["jobs"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Resource id."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"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"}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/jobs/{id}/pdf":{"get":{"operationId":"getJobsByIdPdf","summary":"Download the PDF a document job produced","description":"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.","tags":["jobs"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Resource id."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/events":{"get":{"operationId":"getEvents","summary":"Server-sent events for job and printer changes","description":"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.","tags":["jobs"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/catalog/items":{"get":{"operationId":"getCatalogItems","summary":"List catalog items","description":"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.","tags":["catalog"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"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}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}},"post":{"operationId":"postCatalogItems","summary":"Create an item","description":"Requires the `catalog:write` scope. `sku` is unique and is what upserts match on.","tags":["catalog"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"sku":"DOSA-MSL","name":"Masala Dosa","category":"Tiffin","unit":"plate","rate":90,"tax_percent":5,"track_stock":false}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/catalog/items/{id}":{"get":{"operationId":"getCatalogItemsById","summary":"Fetch one item","description":"By `id` or by `sku:` prefix, so you can look up without storing our ids.","tags":["catalog"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Resource id."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}},"patch":{"operationId":"patchCatalogItemsById","summary":"Update an item","description":"Partial update. Sending `rate` does not disturb stock, and vice versa.","tags":["catalog"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Resource id."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}},"delete":{"operationId":"deleteCatalogItemsById","summary":"Delete an item","description":"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.","tags":["catalog"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Resource id."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/catalog/stock":{"post":{"operationId":"postCatalogStock","summary":"Record a stock movement","description":"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.","tags":["catalog"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"item_id":"itm_4Rt8","qty":-2,"reason":"sale","reference":"INV-2043"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/sales/invoices":{"get":{"operationId":"getSalesInvoices","summary":"List invoices","description":"Filter with `from`, `to`, `q` and `limit`. Newest first.","tags":["sales"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}},"post":{"operationId":"postSalesInvoices","summary":"Record an invoice, and optionally print it","description":"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.","tags":["sales"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"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}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"invoice":{"id":"inv_01J8XR","number":"INV-2043","total":409.5},"job_id":"job_01J8XR3M6K"}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/sales/invoices/{id}":{"get":{"operationId":"getSalesInvoicesById","summary":"Fetch one invoice with its lines","description":"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.","tags":["sales"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Resource id."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/reports/sales":{"get":{"operationId":"getReportsSales","summary":"Sales totals for a period","description":"`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.","tags":["reports"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/parties":{"get":{"operationId":"getParties","summary":"List customers and suppliers","description":"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.","tags":["parties"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"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"}]}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}},"post":{"operationId":"postParties","summary":"Create or update a party","description":"A supplier who also buys from the shop is one row with `kind: \"both\"`, not two — two rows means two balances that will disagree.","tags":["parties"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"name":"Green Grocers","kind":"supplier","phone":"+91 98400 22334","gstin":"33AAAAA0000A1Z5","opening_balance":500}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/parties/{id}":{"get":{"operationId":"getPartiesById","summary":"Fetch one party with its balance","description":"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.","tags":["parties"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Resource id."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}},"delete":{"operationId":"deletePartiesById","summary":"Delete a party","description":"Soft, like items: they stop appearing in searches, and every invoice that names them still renders.","tags":["parties"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Resource id."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/catalog/categories":{"get":{"operationId":"getCatalogCategories","summary":"List categories","description":"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.","tags":["catalog"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"categories":[{"id":"cat_2f8a","name":"Tiffin","colour":"#f59e0b","position":0,"item_count":14}]}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}},"post":{"operationId":"postCatalogCategories","summary":"Create or update a category","description":"`parent_id` nests it. A category cannot be its own parent — that makes the tree infinite and is refused rather than detected later.","tags":["catalog"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"name":"South Indian","parent_id":"cat_2f8a","colour":"#f59e0b","position":1}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/catalog/categories/{id}":{"delete":{"operationId":"deleteCatalogCategoriesById","summary":"Delete a category","description":"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.","tags":["catalog"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Resource id."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/purchases":{"get":{"operationId":"getPurchases","summary":"List purchases","description":"Filter with `from`, `to` and `limit`. Newest first.","tags":["purchases"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}},"post":{"operationId":"postPurchases","summary":"Record a purchase","description":"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.\n\nPrices are treated as tax-exclusive here, the opposite of a retail receipt: a supplier invoice shows the goods and then adds tax.","tags":["purchases"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"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}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"id":"pur_71bd","number":"PUR-1001","total":630,"lines":[]}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/purchases/{id}":{"get":{"operationId":"getPurchasesById","summary":"Fetch one purchase with its lines","description":"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.","tags":["purchases"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Resource id."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/orders":{"get":{"operationId":"getOrders","summary":"List orders","description":"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.","tags":["orders"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"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"}]}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}},"post":{"operationId":"postOrders","summary":"Open an order","description":"A table is opened before anything is ordered, which is why this is separate from adding lines.","tags":["orders"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"table_label":"7","covers":4,"server":"Suresh"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/orders/{id}":{"get":{"operationId":"getOrdersById","summary":"Fetch one order with its lines","description":"Each line carries `kot_sent_at`, which is null until the kitchen has been told about it.","tags":["orders"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Resource id."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/orders/{id}/lines":{"post":{"operationId":"postOrdersByIdLines","summary":"Add lines to an open order","description":"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.","tags":["orders"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Resource id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"lines":[{"item_id":"itm_4Rt8","name":"Paneer Tikka","qty":2,"rate":320,"note":"less spicy"}]}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/orders/{id}/kot":{"post":{"operationId":"postOrdersByIdKot","summary":"Print a kitchen ticket for what is new","description":"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.","tags":["orders"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Resource id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"printer_id":"prn_7Kd2","course":"main"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"job_id":"job_01J8XR","lines_sent":3}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/orders/{id}/bill":{"post":{"operationId":"postOrdersByIdBill","summary":"Turn an order into an invoice","description":"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.","tags":["orders"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Resource id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"payment_mode":"card","print":{"template_id":"pos.receipt.restaurant"}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/ledger":{"get":{"operationId":"getLedger","summary":"List income and expense entries","description":"Filter with `kind` of `income` or `expense`, plus `from` and `to`.","tags":["ledger"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}},"post":{"operationId":"postLedger","summary":"Record income or an expense","description":"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.","tags":["ledger"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"kind":"expense","category":"Gas","amount":1200,"payment_mode":"cash","date":"2026-08-22T08:00:00+05:30"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/ledger/{id}":{"delete":{"operationId":"deleteLedgerById","summary":"Delete a cash book entry","description":"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.","tags":["ledger"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Resource id."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/shop":{"get":{"operationId":"getShop","summary":"The shop’s letterhead and tax setting","description":"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.","tags":["shop"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"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}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}},"post":{"operationId":"postShop","summary":"Change the letterhead or the tax mode","description":"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.\n\n`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.\n\n`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.","tags":["shop"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"brand":{"name":"Anand Traders","footer":"Thank you. Visit again."},"tax_mode":"bill","bill_tax_percent":5}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/reports/cash":{"get":{"operationId":"getReportsCash","summary":"Money in and out for a period","description":"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.","tags":["reports"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{"sales":41000,"purchases":18500,"other_income":0,"expenses":4200,"net":18300}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}},"/printol/v1/reports/low-stock":{"get":{"operationId":"getReportsLowStock","summary":"Items at or below their reorder level","description":"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.","tags":["reports"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Error"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"A token obtained through pairing. Bound to the origin it was issued to and carrying only the scopes granted: print, printers:read, catalog:read, catalog:write, sales:read, sales:write, purchases:read, purchases:write, ledger:read, ledger:write, templates:write"}},"responses":{"Error":{"description":"A failure. `code` is stable and is what you branch on: not_paired, origin_mismatch, scope_missing, pair_pending, pair_denied, printer_offline, template_not_found, render_failed, payload_too_large, rate_limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string"},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_paired","message":"This origin is not paired."}}}}}}}}