# 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: `, e.g. `2.0.0+41`. Older or missing: 426, and the app asks to be updated. - After pairing: `Authorization: Bearer `. 401 `device_revoked` or `token_expired`: pair again. - Errors are `{ "error": { "code": "...", "message": "..." } }`. ## Pairing: `POST /api/v1/terminal/register` ```json { "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` ```json { "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 `: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/`, public and cacheable forever.