Files
kooltab-backoffice/docs/terminal-protocol.md
brammie15 314717ba3a
CI / build-and-test (push) Canceled after 0s
feat: move to V2
2026-10-29 11:21:52 +01:00

4.9 KiB

Terminal protocol (v2)

How a bar terminal (the Flutter app) talks to the backoffice. The schemas are the source of truth: packages/shared/src/terminal.ts. pnpm schema writes them as JSON Schema to packages/shared/schema/ for generating the app's models.

v2 is not compatible with v1: the API refuses apps older than 2.0.0 with 426 app_update_required, also on the v1 routes (/devices/..., GET /venues), so a 1.x tablet keeps its unsent sales and asks to be updated.

Every request

  • x-app-version: <the app's version>, e.g. 2.0.0+41. Older or missing: 426, and the app asks to be updated.
  • After pairing: Authorization: Bearer <token>. 401 device_revoked or token_expired: pair again.
  • Errors are { "error": { "code": "...", "message": "..." } }.

Pairing: POST /api/v1/terminal/register

{ "registrationCode": "8F3A21C0", "name": "Bar terminal 1" }

The code comes from the dashboard (Settings, or the superadmin page) and works once, for 10 minutes. It says which venue the terminal is for. Returns { deviceId, venueId, venueName, token, tokenExpiresAt }; the token is shown only this once and lasts 90 days.

Sync: POST /api/v1/terminal/sync

{
  "events": [ ... ],
  "settlementsCursor": "1234",
  "catalogVersion": "6.1761657600000"
}
  • events: the outbox, oldest first, at most 1000. Keep an event until its id is in acknowledgedEventIds; sending one again is harmless.
  • settlementsCursor: from the previous response; leave it out on a new terminal to get the last 30 days.
  • catalogVersion: from the previous response; the catalog is only sent when it changed.

The response:

Field
acknowledgedEventIds Applied now or before: drop them from the outbox
rejectedEvents { id, reason }: invalid and never applied. Log and drop them, sending them again won't help
tabs Every open tab of the venue, with its lines and drinks and the email of the customer with that name: replace your own with these
catalog Every product, archived ones included (hide those), or null when catalogVersion is current
catalogVersion Send it next time
stock { productId, stock, countedAt } for every product; stock null: not tracked. Can be negative
settlements New settlements of the venue (from every terminal and the dashboard), oldest first
settlementsCursor Send it next time

Then replay the outbox events that were not acknowledged on top of tabs and stock, with the rules below, so the terminal shows what it did offline.

Events

Every event has id (a UUID the terminal makes), type and occurredAt (ISO 8601). A clock that runs more than 5 minutes ahead is capped at the server's time. Tab, line, product and event ids are UUIDs; order ids are any string unique within the tab. Unknown fields are ignored.

type fields effect
tab_opened tabId, customerName, email? Opens the tab, unless it is open already. With an email, adds the customer to the venue (or fills in their email)
tab_renamed tabId, customerName
tab_deleted tabId Deletes the tab without settling: what is on it is not paid and stays out of the stock
line_added tabId, customerName, lineId, orderId, productId, productName, unitPriceInCents, quantity ≥ 1 Adds quantity drinks with ids <event id>:0, :1, … Reopens a tab deleted elsewhere. Counts the stock down
line_removed tabId, lineId, orderId, quantity ≥ 1 Takes off the latest drinks of that order on the line, never more than are on it; those go back into the stock
settled tabId, customerName, amountInCents, paymentMethod, items: [{ lineId, productId, productName, unitPriceInCents, quantity }] Settles the oldest quantity drinks of each line, with the drinks taken off it. A line the server doesn't know is kept in the history as it was sent. paymentMethod: cash, payconiq, other
stock_set productId, quantity (≥ 0, or null to stop tracking) A count. The latest count wins, also when an older one arrives later; drinks rung up before it are in it
product_created productId, product: { name, priceInCents, category?, lowStockThreshold?, imageId? } A product made on the terminal, sellable right away under its id. Its starting stock is a stock_set
product_updated productId, product: { ...only the fields that changed } Per field, the latest change wins, so offline edits of different fields both survive
product_archived, product_restored productId Hides (deletes) or brings back a product

Drinks a terminal rings up on a tab that was settled elsewhere meanwhile stay on the tab: no sale is lost.

Pictures: POST /api/v1/terminal/images

Multipart, the picture as file (at most 10 MB). Returns { imageId }; link it with a product_updated event. The picture is at GET /api/v1/images/<imageId>, public and cacheable forever.