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>. 401device_revokedortoken_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 inacknowledgedEventIds; 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.