11 KiB
API Documentation
Base URL: http://localhost:3001/api/v1
Swagger UI: http://localhost:3001/api/docs
Authentication
Dashboard Users
- Login to receive JWT access token (24h) + refresh token (30 days)
- Include access token in
Authorization: Bearer <token>header
Devices
- Register to receive device bearer token (90 days)
- Include device token in
Authorization: Bearer <token>header - Device ID must match URL parameter
- Send the app version in an
X-App-Versionheader (e.g.1.4.2+37, the build number is ignored) on every/devices/...call. Without it, or below the minimum version (MIN_APP_VERSION), the call fails with426 app_update_requiredbefore the token is checked: the app must be updated to use the backoffice again
Endpoints
Health
GET /health - Server health check (503 when the database is unreachable)
Auth
POST /auth/login - Login with email/password
POST /auth/refresh - Refresh access token
POST /auth/logout - Logout and revoke refresh token
POST /auth/change-password - Change the signed-in user's password { currentPassword, newPassword }. Revokes all
their refresh tokens and returns new tokens, like a login. 400 wrong_password when the current one is wrong.
Venues
GET /venues - List venues
GET /venues/:venueId - Get venue details
GET /venues/:venueId/devices - The venue's paired terminals { devices: [...] }
GET /venues/:venueId/users - List dashboard users (admin)
POST /venues/:venueId/users - Create a user (admin)
DELETE /venues/:venueId/users/:userId - Delete a user and revoke their refresh tokens (admin; not yourself)
Devices
POST /devices/register - Pair a terminal; every pairing adds one
POST /devices/:deviceId/sync - Sync device state (device auth)
POST /devices/:deviceId/images - Upload a picture taken on the terminal (device auth, multipart field file):
{ imageId }. Processed like a dashboard upload (see Images); the terminal links it with a product_updated event
POST /devices/:deviceId/commands/:commandId/ack - Acknowledge command (device auth)
GET /devices/generation-code - Generate registration code (admin)
Sync semantics. The server holds the venue's open tabs and stock; a venue can have several terminals. Each terminal
works offline, queues what it does as events, and on every sync replaces its tabs and stock with the server's and
replays its unsent events on top. The full rules are in the app repository: docs/server-tabs-source-of-truth.md
and docs/server-stock-source-of-truth.md.
-
Every write to a venue's tabs, stock and settlements (syncs, dashboard settles, stock adjustments) runs in one transaction that locks the venue row, so simultaneous terminals apply their changes one after the other.
-
events(at most 1000): terminal-generated UUIDid,type,occurredAt(ISO-8601, UTC; capped at the server time when more than 5 minutes ahead) and per type:tab_opened(tabId,customerName,openedAt),tab_renamed(newcustomerName),tab_deletedline_added/line_removed:lineId,productId,productName,unitPriceInCents,quantity(≥ 1) andorderId: the order on the terminal the drinks belong to. Every unit keeps theorderIdof itsline_added, and the dashboard and terminals show a tab per order.line_removedwith anorderId(ornull: drinks without an order) only takes that order's drinks off; without the field (older app versions) the latest of the linesettled:amountInCents,paymentMethod(cash,payconiq,payment_pendingorother), optionalcommandIdanditems([{ productId?, productName, quantity, unitPriceInCents }])stock_set:productId,quantity(a count ≥ 0, ornullto stop tracking); notabIdproduct_created/product_updated/product_archived:productId(the terminal makes the id of a new product) andproductwith the fields that changed: any ofname,category,priceInCents,lowStockThreshold,archived,imageId.product_createdneedsnameandpriceInCents; notabId. Every field keeps the time it last changed, and an event only changes a field it changed later (byoccurredAt), so an offline rename and a dashboard price change both survive. AnimageIdthe server doesn't have is ignored; an update of a product the venue doesn't have is acknowledged and dropped
Each id is applied once (tab state and stock in the same transaction).
acknowledgedEventIdslists the applied ones, including repeats;rejectedEvents([{ id, reason }]) were invalid and never applied. The terminal drops both from its outbox. An invalid event never fails the sync. -
The response's
tabsis the venue's complete open tab state after this request's events:[{ id, customerName, openedAt, version, lines: [{ id, productId, productName, unitPriceInCents, quantity, units: [{ id: "<event id>:<index>", addedAt, removedAt }] }] }]. -
stockis every product of the venue:[{ productId, stock, countedAt }](stocknull when not tracked, may be negative). Line events change it unless they happened beforecountedAt; the latest count wins. -
settlementslists the venue's settlements aftersettlementsCursor(or of the last 30 days without one), oldest first, at most 500; the response'ssettlementsCursoris sent back next time. -
openTabsis only used once per venue, on the first sync after the server started holding the tabs: the terminal's own tabs (and catalog stock) are taken over and the events of that request are stored without applying them again. -
Products are changed on the dashboard and, through the product events above, on terminals.
productsin the request (at most 2000) is only used on that same first sync, to take over the terminal's catalog with its stock; after that it is ignored. The response'scataloglists every product of the venue:[{ id, name, category, priceInCents, lowStockThreshold, archived, imageId }]. The terminal adds and updates its products from it (replaying its unsent product events on top), hides archived products and products the catalog doesn't list, and downloads pictures it doesn't have yet fromGET /images/:imageId. -
Planned customers are opened as tabs by the server, once;
plannedTabNamesis always empty. -
Dashboard stock adjustments are applied by the server right away;
stockAdjustmentsis always empty.stockAdjustmentResultsfrom older app versions are still recorded and acknowledged. -
Every sync returns all pending commands for the device (only
close_tabcommands created before the server settled tabs itself);cursoris still returned but no longer filters. The first ack for a command wins.
Tabs
GET /venues/:venueId/tabs - List open tabs
DELETE /venues/:venueId/tabs/:tabId?expectedVersion= - Delete a tab without settling it, like tab_deleted on a
terminal (admins and managers). 409 version_mismatch when it changed since expectedVersion.
Planned Tabs (customer list)
GET /venues/:venueId/planned-tabs - List planned customers
POST /venues/:venueId/planned-tabs - Add a customer (admin)
POST /venues/:venueId/planned-tabs/import - Bulk import { entries: [{ customerName, email? }], mode: 'merge' | 'replace' } (admin).
Names match case-insensitively and keep their existing spelling; replace also removes customers missing from the import.
PUT /venues/:venueId/planned-tabs/:id - Rename a customer / set email (admin)
DELETE /venues/:venueId/planned-tabs/:id - Remove a customer (admin)
Reminders
GET /venues/:venueId/reminders/candidates - Customers with an unpaid balance, with their reminder count, last reminder
and whether they are eligible (have an email and weren't reminded in the last 7 days)
POST /venues/:venueId/reminders/send - Send payment reminders (admin). Body { "tabIds": [...] } picks the
recipients, including customers still in their cooldown; without it every eligible customer is reminded
GET /venues/:venueId/reminders?customerName= - Reminder history, optionally for one customer
POST /venues/:venueId/reminders/test/:plannedTabId - Send a test email (admin)
Stock
GET /venues/:venueId/stock - The venue's products (archived ones too, with archivedAt) with stock, low stock
level and pending adjustments
GET /venues/:venueId/stock/adjustments?productId=&limit=&offset= - Stock adjustment history, newest first
POST /venues/:venueId/stock/adjustments - Request stock changes (admins and managers): { adjustments: [{ productId, type: 'add' | 'set', quantity, note? }] }.
add takes a non-zero quantity (negative for breakage) and needs a tracked product; set takes 0 or more.
POST /venues/:venueId/stock/adjustments/:id/cancel - Cancel an adjustment the terminal hasn't received yet (admins and managers)
POST /venues/:venueId/stock/products - Add a product (admins and managers): { name, category?, priceInCents, stock?, lowStockThreshold? }. stock is the starting count (recorded as an applied set adjustment); null or left out:
not tracked. product_name_exists when an active product has the same name (ignoring case). Terminals may still
create products with the same name; the products page marks those as duplicates.
PATCH /venues/:venueId/stock/products/:productId - Change a product (admins and managers): any of { name, category, priceInCents, lowStockThreshold, archived }. archived: true deletes it for the terminals (kept for tabs and history),
archived: false brings it back. The stock itself changes through adjustments.
POST /venues/:venueId/stock/products/:productId/image - Set a product's picture (admins and managers; multipart
field file, see Images): the product with its new imageId
DELETE /venues/:venueId/stock/products/:productId/image - Remove a product's picture (admins and managers)
Images
GET /images/:imageId - A product picture (public, image/webp, cached for a year: an id never changes content).
Uploads (JPEG, PNG, WebP, GIF or AVIF, at most 10 MB) are turned upright and stored as a 512×512 WebP without
metadata. A product on a plain background (all four edges one colour, or transparent) is trimmed and centred
with a 40 px margin on that background. Any other photo is cropped around its centre when roughly square, or
fitted whole on a transparent background when not (bottles, cans). The id is the SHA-256 of the stored file.
invalid_image when the upload can't be read.
Commands
POST /venues/:venueId/commands - Settle a tab: { tabId, type: "close_tab", expectedVersion, paymentMethod }. The
server settles everything on the tab at once (also with every terminal offline) and records the command as
completed. version_mismatch when the tab changed since it was shown, tab_empty when there is nothing on it.
GET /venues/:venueId/commands - List command history
POST /venues/:venueId/commands/devices/:deviceId/revoke - Unpair a terminal (admin)
All /venues/:venueId/... routes return 403 venue_forbidden unless the user belongs to that venue.
Error Format
{
"error": {
"code": "error_code",
"message": "Human-readable message"
}
}
HTTP Status Codes
- 200 Success
- 201 Created
- 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 409 Conflict
- 426 Upgrade Required (
app_update_required: the terminal app is too old) - 429 Too Many Requests