This page is the endpoint and WebSocket contract for the three HTTP servers FloCafe runs. It describes what each server exposes, which role each endpoint requires, and the parameters each handler reads. It is the API surface only. Behaviour that belongs to another subject is linked rather than restated, at the end of each section.
Three servers run inside the POS process:
| Port | Server | What it serves | Source |
|---|---|---|---|
3001 |
Main API | Every /api/* route, plus the static frontend export |
main/server.ts |
3002 |
KDS | The standalone kitchen display: a small REST surface plus the static KDS bundle | main/kds-server.ts |
3003 |
Server App | A filtered proxy onto a fixed list of 3001 routes, plus the static Server App bundle |
main/server-app.ts |
Each server walks its port upward from its base when the port is taken, so the bound port can differ from the default. System overview covers the retry ladder and how the running ports are reported to the UI.
The /kds WebSocket is upgraded on both 3001 and 3002. A KDS display pointed at either
port gets the same frames from main/services/kds.ts. Any other
upgrade path on either port is answered with 404.
Base URLs:
- Main API:
http://flo.local:3001orhttp://<local-ip>:3001 - KDS:
http://flo.local:3002 - Server App:
http://flo.local:3003
All three servers authenticate with a bearer JWT. Send it as Authorization: Bearer <token>. Each
server issues its own token through a POST /api/auth/login on its own port, using the same signing
secret, so a token from one server is accepted by the others. The scheme, token lifecycle, revocation, staleness handling, the
Master PIN second factor, and audit attribution are described in
authentication and authorization. That page
also explains why the three servers each write their own token-verification middleware and what
diverges between them.
Almost every protected endpoint is gated by requirePermission(id), resolved live from SQLite —
see Configurable permissions.
For brevity, the Authorization column below names the endpoint's permission by its shipped
default role group instead of its permission id, using the same ROLE_ACCESS keys from
shared/role-permissions.ts that
shared/permissions.ts assigns as each permission's
defaultRoles:
| Group | Roles |
|---|---|
owner |
owner |
ownerManager |
owner, manager |
ownerManagerCashier |
owner, manager, cashier |
sales |
owner, manager, cashier, server |
cashierServer |
cashier, server |
kitchen |
owner, manager, chef |
orderStatus |
owner, manager, cashier, server, chef |
allStaff |
owner, manager, cashier, server, chef |
serverApp |
server, manager, owner |
This is the default, not a fixed gate. An owner can grant or deny any of these per role or per
user from Staff > Role permissions, without a code change, and the endpoint enforces whatever
is currently configured — not the table below. To find the exact permission id an endpoint checks,
read the route source or shared/permissions.ts; to see the current effective configuration for
an install, use GET /api/authorization/roles. authorization.manage and
staff.privileged.manage are the two permissions marked configurable: false in the catalog:
those two rows are not owner-configurable, unlike every other row in this reference.
The roles and permissions page holds the in-app permission editor and capability matrix — a UI view and its owner-only management API, not this backend gate list.
A row reading any authenticated role means the endpoint has no permission or role gate beyond
requireAuth. A row reading + KDS-enabled also passes through requireKdsEnabled, which returns
403 when kds_enabled is off. + Master PIN adds requireMasterPin, which reads master_pin
from the body.
Router: main/routes/authorization.ts. Full path: /api/authorization. Every route below
requires authorization.manage — active owner only, protected, not configurable.
| Method | Path | Parameters | Response |
|---|---|---|---|
GET |
/catalog |
none | { permissions: PERMISSION_DEFINITIONS, roles: ROLE_KEYS } — the stable permission catalog and role identities. |
GET |
/roles |
none | { roles: [{ role, revision, overrides, permissions }, ...] } for every role. |
PUT |
/roles/:role |
body: revision, overrides: [{ permission_id, effect }] |
Atomically replaces that role's override set. 409 with the current role payload on a stale revision; 400 on an unknown, duplicate, or protected permission id. |
GET |
/users |
none | { users: [...] } — the safe staff list this editor's per-user picker uses. |
GET |
/users/:userId |
path: userId |
{ user, revision, overrides, permissions } — one user's effective values and exceptions. 404 if absent. |
PUT |
/users/:userId |
body: revision, overrides: [{ permission_id, effect }] |
Atomically replaces that user's override set. Same 409/400 behavior as the role route. |
DELETE |
/users/:userId/overrides |
body: revision |
Clears every override for that user, restoring inheritance. 409 on a stale revision. |
GET |
/audit |
query: ?limit (max 200), ?before_id |
{ audit: [...] }, newest first: actor, target type/id, permission id, previous/next effect, timestamp. |
Every write is one SQLite transaction and appends rows to authorization_audit_log, keyed under a
shared batch_id per save so a multi-permission change reads as one event.
main/server.ts applies a 100 requests per minute ceiling to all of /api on 3001, skipping
loopback, RFC 1918, and Tailscale addresses. 3002 applies 100 per minute to its own /api through
the same LAN-exempt helper, and 3003 applies 150 per minute. express-rate-limit emits RateLimit-* headers; the in-repo
rateLimit helper in main/middleware/security.ts does the
same.
loginAuthRateLimit and authRateLimit deliberately do not exempt private addresses, so
LAN clients are counted against authentication rate limits.
loginAuthRateLimit is the shared volume limiter across all three default login endpoints
(POST /api/auth/login on 3001, 3002, and 3003). It reuses a single shared budget
(default 10 requests per 15 minutes, configurable via FLO_AUTH_RATE_LIMIT_MAX).
In addition, the three password login endpoints share an install-wide failed-login attempt lockout keyed to
canonical client IP (normalizeIpForRateLimit), and POST /api/auth/pin-login applies the same 5-attempt, 15-minute lockout per user (tracked both per client IP + user and per target account across source IPs; see PIN quick user switch and terminal lock). Successful login
resets the corresponding counter(s) (non-credential 403 responses preserve accumulated failures). See
docs/architecture/authentication-and-authorization.md
for architecture and role/permission handling details.
authRateLimit provides independent per-route volume limiters (also defaulting to 10 attempts
per 15 minutes, overridable with FLO_AUTH_RATE_LIMIT_MAX) for non-login credential and staff
routes on 3001: POST /api/auth/password/change, POST /api/auth/recover-password,
/api/auth/recover-password/cloud/*,
POST /api/staff, and PUT /api/staff/:id.
Per-resource limiters named in the tables below are express-rate-limit instances declared in
their own route file. Unless noted otherwise, they apply to every caller including LAN addresses.
In main/routes/orders.ts, orderWriteRateLimit and
orderItemCancelRateLimit remain separate 60-requests-per-minute buckets (configurable via
FLO_ORDER_WRITE_RATE_LIMIT_MAX when set to a positive integer) and exempt authenticated callers
connecting from private, loopback, or Tailscale socket addresses (isAllowedPrivateIp on
req.socket.remoteAddress, ignoring caller-supplied forwarding headers), while still rate-limiting
public or unauthenticated traffic and returning { error: "Too many requests, please try again later." }
on 429.
registerRoutes in main/routes/index.ts mounts 38 routers under 39
paths. staffRoutes is the same router mounted at both /api/staff and /api/users, so the two
prefixes expose an identical surface. Seven further endpoints are registered inline on app in that
same file, outside any router, and are listed under
Inline endpoints. GET /api/health is registered
directly in main/server.ts.
A search for router. in the route files misses all eight of those, so a route inventory built on that
pattern alone is incomplete.
Router: main/server.ts.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/api/health |
none | none | { status, db, service, version, timestamp }. 503 when the database reports unhealthy. |
Router: main/routes/auth.ts. Full path: /api/auth.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
POST |
/login |
none, loginAuthRateLimit + shared failed-login limiter |
body: email, password, rememberMe |
{ access_token, token_type: "bearer", expires_in, user: { id, name, email, role, category_ids }, tenants: [ ... ] }. 401 on bad credentials with attempts_remaining; 429 after 5 failed attempts across local login listeners. |
GET |
/pin-users |
none, auth limiter (600 per window) | none | { users: [ { id, name, role } ] } for active users with a PIN configured. |
POST |
/pin-login |
none, auth limiter (600 failed per window) + per-user failed-login limiter | body: user_id, pin, rememberMe |
{ access_token, token_type: "bearer", expires_in, user: { id, name, email, role, category_ids }, tenant, tenants: [ ... ] }. 400 when user_id or pin is missing; 401 on bad credentials with attempts_remaining; 429 after 5 failed attempts for that user_id (per client IP or across source IPs). |
POST |
/tenants/select |
any authenticated role | none | { access_token, token_type: "bearer", tenant }. |
POST |
/logout |
any authenticated role | none | { message }. Revokes the presented token. |
POST |
/refresh |
any authenticated role | none | { access_token, token_type: "bearer", expires_in }. |
GET |
/me |
any authenticated role | none | { user: { id, name, email, role }, tenants: [ ... ] }. |
POST |
/password/change |
any authenticated role + auth limiter | body: current_password, password |
{ message }. A wrong current password returns 400 with attempts_remaining; the fifth consecutive failure locks that user out for 5 minutes, and attempts during the lockout return 429. The new password must satisfy validatePassword. |
POST |
/recover-password |
any authenticated role + auth limiter | body: email, master_pin, new_password |
Local-setup recovery gated on email, master_pin, new_password. 409 before first-run setup finishes. |
GET |
/recover-password/cloud/verification |
none, loopback only, auth limiter (600 per window) | none | { verified }. |
POST |
/recover-password/cloud/request |
none, loopback only, auth limiter | none; sends the verification email first when the owner email is unverified, otherwise asks FloAdmin to email the owner a reset link | 202 { step: "verify_email", email_hint } or { step: "reset", reset_id, expires_at, email_hint }. 409 when email recovery is unavailable, 429 on FloAdmin rate limit, 502 when FloAdmin is unreachable. |
GET |
/recover-password/cloud/status |
none, loopback only, auth limiter (600 per window) | query: reset_id; returns the FloAdmin approval status |
{ status } where status is pending, approved, expired, or consumed. |
POST |
/recover-password/cloud/complete |
none, loopback only, auth limiter | body: reset_id, new_password; redeems the approved reset, then sets the password |
{ message }. 404 when no active owner matches the registered email, 409 when the reset is not approved or has expired. The grant is redeemed only after the owner is resolved. |
GET |
/setup/status |
any authenticated role | none | { needsSetup, userCount, initialRole, schemaVersion, masterPinAvailable, cloudRecoveryAvailable, currencyReset }. |
POST |
/setup/initialize |
any authenticated role | body: name, password, business_type, setup_profile, service_model, language, business_name, country, currency, currency_symbol, timezone, business_address, business_phone, tax_registration_number, state_code, tax_registered, billing_type, terms_accepted, master_pin, owner_approval_pin, owner_approval_pin_confirmation, cloud_server_url, email_product_updates, email_marketing |
Creates the first owner. Refuses once any user exists. |
POST |
/setup/seed |
any authenticated role | none | Always 410; directs the caller to /api/auth/setup/initialize. |
Router: main/routes/categories.ts. Full path: /api/categories.
Categories support at most two levels: root categories (parent_id IS NULL) and one subcategory level (parent.parent_id IS NULL). Sibling names are unique after trimming, case-insensitively, under the same parent (parent_id IS NULL for roots or matching parent_id for subcategories), while identical child names are allowed under different root parents. Categories do not inherit taxes, permissions, or kitchen routing from their parent.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/ |
any authenticated role | query: ?active, ?root, ?parent_id |
Category list, soft-deleted rows excluded, ordered depth-first with root siblings sorted by sort_order, name and each parent's subcategories grouped immediately after in sibling sort_order, name order. ?root=true and ?parent_id narrow to a tree level. ?active=true (or 1) restricts returned categories and nested children to active categories whose ancestors, if any, are all active and non-deleted. |
GET |
/:id |
any authenticated role | path: id |
Single category; 404 when absent or soft-deleted. |
POST |
/ |
ROLE_ACCESS.ownerManager + category write limiter |
body: name, description, parent_id, sort_order, is_active, color, icon, addon_group_ids |
201 with created category. 400 on blank name, Unicode case-insensitive duplicate sibling name, missing/inactive parent, or grandchild depth (parent.parent_id !== null). |
POST |
/reorder |
ROLE_ACCESS.ownerManager + category write limiter |
body: parent_id (nullable for roots), category_ids |
200 with { categories } ordered by updated sort_order, name. Validates that category_ids is a duplicate-free list matching the complete non-deleted sibling set for parent_id before updating sort_order in one SQLite transaction (400 with zero partial updates on mismatch). |
PUT |
/:id |
ROLE_ACCESS.ownerManager + category write limiter |
path: id; body: name, description, parent_id, sort_order, is_active, color, icon, addon_group_ids |
400 on blank name, cycle, grandchild depth, re-parenting a category that already has subcategories, assigning a new or changed inactive parent, changing normalized name or parent_id into a duplicate sibling name, or deactivating (is_active: false) an active category that has non-deleted subcategories. |
DELETE |
/:id |
ROLE_ACCESS.ownerManager + category write limiter |
path: id; query: ?action, ?reassign_to |
400 when the category has non-deleted subcategories (subcategories are never silently re-parented or flattened), or when active products exist without a valid reassign or delete_all action. |
Router: main/routes/addon-groups.ts. Full path: /api/addon-groups.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/ |
any authenticated role + addon-group read limiter | none | { addon_groups: [ ... ] } with add-ons attached. |
GET |
/:id |
any authenticated role + addon-group read limiter | path: id |
Single group with add-ons. |
POST |
/ |
ROLE_ACCESS.ownerManager + addon-group write limiter |
body: name, description, is_required, min_selection, max_selection, allow_multiple_quantities, sort_order, is_active, addons |
201 with the created group. |
PUT |
/:id |
ROLE_ACCESS.ownerManager + addon-group write limiter |
path: id; body: name, description, is_required, min_selection, max_selection, allow_multiple_quantities, sort_order, is_active, addons |
- |
DELETE |
/:id |
ROLE_ACCESS.ownerManager + addon-group write limiter |
path: id |
- |
POST |
/:groupId/addons |
ROLE_ACCESS.ownerManager + addon-group write limiter |
path: groupId; body: name, price, tax_category_id, tax_behavior, inherit_parent_tax_category, is_active, sort_order |
201 with the created add-on. |
PUT |
/:groupId/addons/:addonId |
ROLE_ACCESS.ownerManager + addon-group write limiter |
path: addonId, groupId; body: name, price, tax_category_id, tax_behavior, inherit_parent_tax_category, is_active, sort_order |
- |
DELETE |
/:groupId/addons/:addonId |
ROLE_ACCESS.ownerManager + addon-group write limiter |
path: groupId, addonId |
- |
Router: main/routes/products.ts. Full path: /api/products.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/ |
any authenticated role | query: ?category_id, ?active, ?search, ?barcode, ?low_stock |
Menu list. Variant objects include recipe_multiplier. ?category_id matches direct product assignment. ?active=true (or 1) requires an active product that is uncategorized or in an active, non-deleted category chain. ?low_stock returns only items at or below their threshold. |
GET |
/:id/image |
any authenticated role | path: id |
Product image bytes. Unauthenticated by design so <img> tags work; subject to SSRF and path-containment checks on the stored URL. |
GET |
/:id |
any authenticated role | path: id |
Single product; variant objects include recipe_multiplier. |
POST |
/fetch-url |
ROLE_ACCESS.ownerManager |
body: url |
Body url is fetched server-side and stored as the product image. Subject to the SSRF blocklist. |
POST |
/ |
ROLE_ACCESS.ownerManager |
body: category_id, name, sku, barcode, description, price, cost_price, sale_unit, allow_fractional_quantity, weight_precision, inventory_product_id, inventory_deduction_quantity, tax_category_id, tax_behavior, track_inventory, stock_quantity, low_stock_threshold, is_active, image_url, sort_order, cb_percent, tags, addon_group_ids, variants, reason |
400 if category_id names a missing, soft-deleted, or inactive category or ancestor. |
PUT |
/:id |
ROLE_ACCESS.ownerManager |
path: id; body: category_id, name, sku, barcode, description, price, cost_price, sale_unit, allow_fractional_quantity, weight_precision, inventory_product_id, inventory_deduction_quantity, tax_category_id, tax_behavior, track_inventory, stock_quantity, low_stock_threshold, is_active, image_url, sort_order, cb_percent, tags, addon_group_ids, variants, reason |
400 if a new or changed category_id names a missing, soft-deleted, or inactive category or ancestor; retaining the product's current non-deleted category_id is allowed. |
DELETE |
/:id |
ROLE_ACCESS.ownerManager |
path: id |
- |
POST |
/:id/stock |
ROLE_ACCESS.ownerManager |
path: id; body: action, quantity, reason |
- |
GET |
/loyalty/global-rate-candidates |
ROLE_ACCESS.ownerManager |
none | - |
POST |
/loyalty/apply-global-rate |
ROLE_ACCESS.ownerManager |
none | Applies one cashback rate across products. |
In product create/update requests, each variants entry may include a positive finite
recipe_multiplier. If omitted, a new variant uses 1 and an existing variant keeps its stored
value. Product reads return the field on each variant; see product invariants
for its recipe-depletion behavior.
Router: main/routes/recipes.ts. Full path: /api/recipes.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/ |
ROLE_ACCESS.ownerManager |
none | { recipes: [ ... ] }. |
GET |
/product/:productId |
ROLE_ACCESS.ownerManager |
path: productId |
Recipe snapshot for one product. |
PUT |
/product/:productId |
ROLE_ACCESS.ownerManager |
path: productId; body: yield_quantity, is_active, items |
- |
DELETE |
/product/:productId |
ROLE_ACCESS.ownerManager |
path: productId |
- |
Router: main/routes/supplies.ts. Full path: /api/supplies.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/ |
ROLE_ACCESS.ownerManager |
query: ?include_inactive, ?low_stock, ?search |
Supply list. ?low_stock and ?include_inactive narrow it. |
POST |
/ |
ROLE_ACCESS.ownerManager |
body: name, base_unit, stock_quantity, low_stock_threshold, unit_cost, unit_cost_unit, is_active |
- |
GET |
/movements |
ROLE_ACCESS.ownerManager |
query: ?supply_id, ?movement_type, ?search, ?before_id, ?per_page |
Supply movement ledger, newest first; ?search matches the supply name, reason, or actor across the whole ledger; ?before_id pages backwards. |
GET |
/:id |
ROLE_ACCESS.ownerManager |
path: id |
- |
PUT |
/:id |
ROLE_ACCESS.ownerManager |
path: id; body: name, is_active, low_stock_threshold, unit_cost, unit_cost_unit |
- |
DELETE |
/:id |
ROLE_ACCESS.ownerManager |
path: id |
- |
POST |
/:id/movements |
ROLE_ACCESS.ownerManager |
path: id; body: movement_type, quantity, unit, unit_cost, total_cost, reason |
Body movement_type, quantity, unit, reason, plus optional unit_cost or total_cost on receive movements. |
Router: main/routes/inventory.ts. Full path: /api/inventory.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/movements |
ROLE_ACCESS.ownerManager |
query: ?product_id, ?reference_type, ?reference_id, ?movement_type, ?before_id, ?per_page |
- |
Router: main/routes/tables.ts. Full path: /api/tables.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/ |
any authenticated role | query: ?status, ?floor, ?section, ?kitchen_station_id, ?active |
Table list; ?status, ?floor, ?section, ?kitchen_station_id, ?active. |
GET |
/:id |
any authenticated role | path: id |
Single table. |
PATCH |
/floors/:name |
ROLE_ACCESS.ownerManager |
path: name; body: newName |
- |
DELETE |
/floors/:name |
ROLE_ACCESS.ownerManager |
path: name |
- |
POST |
/ |
ROLE_ACCESS.ownerManager |
body: number, name, capacity, floor, section, position_x, position_y, kitchen_station_id |
- |
PATCH |
/positions |
ROLE_ACCESS.ownerManager |
body: positions |
Bulk floor-plan position update. |
PUT |
/:id |
ROLE_ACCESS.ownerManager |
path: id; body: number, name, capacity, floor, section, position_x, position_y, kitchen_station_id |
- |
POST |
/:id/deactivate |
ROLE_ACCESS.ownerManager |
path: id |
Soft-deactivates the table rather than deleting it. |
POST |
/:id/reactivate |
ROLE_ACCESS.ownerManager |
path: id |
- |
POST |
/:id/move-order |
ROLE_ACCESS.sales |
path: id; body: target_table_id, order_id |
{ order, sourceTable, targetTable }. 400 when target_table_id is missing, matches id, or names an inactive table; 404 when either table or the active order is not found; 409 when the destination table has an active or held order or is reserved. |
PATCH |
/:id/status |
ROLE_ACCESS.ownerManager |
path: id; body: status, reservation_customer_id |
Body status, plus reservation_customer_id for reservation state. |
Router: main/routes/kitchen-stations.ts. Full path: /api/kitchen-stations.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/ |
any authenticated role | none | { kitchen_stations: [ ... ] }, active stations only. |
GET |
/:id |
any authenticated role | path: id |
Single station. |
POST |
/ |
ROLE_ACCESS.ownerManager |
body: name, description, category_ids, printer_id, printer_ip, printer_port, printer_name, sort_order |
- |
PUT |
/:id |
ROLE_ACCESS.ownerManager |
path: id; body: name, description, category_ids, printer_id, printer_ip, printer_port, printer_name, sort_order, is_active |
- |
PUT |
/:id/users |
ROLE_ACCESS.ownerManager |
path: id; body: user_ids |
Replaces the station's assigned user_ids. |
DELETE |
/:id |
ROLE_ACCESS.ownerManager |
path: id |
- |
Router: main/routes/counters.ts. Full path: /api/counters.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/ |
any of pos.use, orders.read, orders.create, payments.take, settings.view, settings.manage (or owner) |
optional query: ?include_inactive (or ?all) |
{ counters: [ ... ] }, ordered by sort_order ASC, name ASC. Returns active counters by default, or all counters when requested. |
GET |
/:id |
any of pos.use, orders.read, orders.create, payments.take, settings.view, settings.manage (or owner) |
path: id |
{ counter }. 404 when counter is not found. |
POST |
/ |
active owner with settings.manage |
body: name (required, trimmed), role (ordering | payment | combined, default combined), sort_order (integer, default 0), is_active (boolean, default true) |
201 with { counter }. 400 on missing name or invalid role; 403 for non-owner. |
PUT |
/:id |
active owner with settings.manage |
path: id; body: name, role, sort_order, is_active |
{ counter }. 400 on invalid payload; 403 for non-owner; 404 when counter is not found. |
DELETE |
/:id |
active owner with settings.manage |
path: id |
{ success: true }. 403 for non-owner; 404 when counter is not found. |
Router: main/routes/orders.ts. Full path: /api/orders.
The cart-to-payment path these endpoints drive - held carts, the pricing pipeline, discounts, bills, settlement, and KDS-driven order-status reconciliation - is in Order lifecycle.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/ |
orders.read (default roles: ROLE_ACCESS.sales) + order read limiter |
query: ?view, ?status, ?board_status, ?type, ?today, ?start_date, ?end_date, ?table_id, ?before_id, ?per_page, ?search |
{ orders, nextCursor? }, newest page first. view accepts all, active, or unpaid and defaults to all; active (and ?status=active) includes pending, preparing, ready, and served orders plus eligible KDS-unfinished completed orders (see Order lifecycle); unpaid includes unbilled orders and orders with an unpaid or partial bill with a positive balance, excluding cancelled orders. ?board_status accepts a comma-separated non-empty subset of pending, preparing, and ready (400 otherwise) and narrows before pagination to released (release_status = 'released') active orders in those statuses plus eligible released KDS-unfinished completed orders whose projected board status matches, preserving stored orders.status on returned rows. Status, board status, type, table, view, date, and search filters apply before pagination. ?before_id pages backwards using the numeric id cursor and stable created_at, id ordering. ?search matches order number, customer name, or phone. The Orders screen requests 15 matches per page; the API default remains 50. |
GET |
/scheduled |
orders.read (default roles: ROLE_ACCESS.sales) + scheduled order read limiter |
optional query: cursor, limit (max 200) |
{ orders, next_cursor, scheduled_count, blocked_count }. Returns active unreleased (release_status IN ('scheduled', 'blocked'), non-cancelled, non-completed) orders with a non-null scheduled_for, ordered with blocked first and scheduled_for ASC, hydrated with items, bills, and customer details for the POS scheduled queue. next_cursor is returned when additional pages exist; scheduled_count and blocked_count report total active unreleased counts across all pages. |
GET |
/awaiting-payment |
orders.read (default roles: ROLE_ACCESS.sales) + order read limiter |
optional query: cursor, limit (max 200), search |
{ orders, tickets, next_cursor, has_more, awaiting_count, blocked_count, total_count, counts }. Returns active unreleased tickets in checkout_stage = 'awaiting_payment' or blocked for on_payment dispatch, ordered with blocked tickets first, then oldest created_at ASC, id ASC. Each ticket is a flat row containing id, order_number, type, table_id, table_name, customer_id, customer_name, customer_phone, scheduled_for, total, total_amount, round_off, checkout_stage, dispatch_policy, release_status, release_blocked_reason, created_at, updated_at, bill_id, bill_number, paid_amount, balance, bill_payment_status, ticket_balance, and ticket_paid_amount. Ticket balances and paid amounts are aggregated across all bills with refund-aware payment totals; bill_id, balance, and paid_amount continue to identify the selected payable bill. Supports cursor pagination and text/ticket search. |
GET |
/by-ticket/:ticket |
orders.read (default roles: ROLE_ACCESS.sales) + order read limiter |
path: ticket; optional query: by (id) |
{ order, bill, bills }. Hydrated order and associated bills. ticket accepts the raw order number or scanned QR payload (FLOCAFE-TICKET:<order_number>), or numeric order id when ?by=id is supplied. 404 when the ticket is not found or already paid. |
GET |
/:id |
orders.read (default roles: ROLE_ACCESS.sales) + order read limiter |
path: id |
{ ...order, items, table }. |
POST |
/ |
ROLE_ACCESS.sales + order write limiter |
body: items, table_id, customer_id, type, guest_count, special_instructions, scheduled_for, dispatch_policy, checkout_stage, packaging_charge, delivery_charge, service_charge, online_platform, external_order_id, delivery_address, expected_payment_method, expected_payment_method_id, delivery_note; header: Idempotency-Key |
201 with the created order. Repeating the same request body with an Idempotency-Key returns its stored response; reusing the key with a different body returns 409. Optional scheduled_for defers kitchen release on takeaway and delivery orders (400 on dine_in/online, past times, or skipped/repeated DST times in the store timezone), saving quoted prices and totals with release_status = 'scheduled' without touching stock, KDS, or KOT; see Scheduled orders and kitchen release. Optional dispatch_policy (immediate or on_payment, default immediate) supports multi-counter ordering for takeaway and dine_in (400 on other types); new on_payment orders require multi_counter_enabled = 'true' (409 with MULTI_COUNTER_DISABLED otherwise), while committed idempotent replays remain available after disabling. When on_payment, creates the order with checkout_stage = 'awaiting_payment', release_status = 'scheduled', defers kitchen stock deduction, creates an initial bill, and returns qr_payload (FLOCAFE-TICKET:<order_number>) and qr_data_url for ticket scanning; see Multi-counter ordering. If supplied, optional checkout_stage must match the dispatch policy: 'awaiting_payment' for on_payment, or 'paid' for immediate (400 otherwise). expected_payment_method and delivery_note are stored only for type: 'delivery': without an id, the method keeps its legacy string/sentinel resolution (unknown by default, pending, cash, card, or an active custom method name), and is never recorded as a payment; 400 for any other method or a note over 200 characters. A custom method whose name matches a built-in or sentinel needs its id to be identified as custom. An optional expected_payment_method_id must be a positive safe integer naming an active configured method; the server stores its canonical name and requires any supplied non-empty name to match case-insensitively. Invalid, unknown, or inactive ids return 400. The id is a historical marker, not a foreign key, so renaming or deleting the method later never rewrites an order; only orders.expected_payment_method_id decides that the stored name prints literally on a delivery slip. Non-delivery orders persist both expected-method fields as null. An item's variant_id is required when the product has active variants, and 400 when it names an unknown, foreign, or inactive variant; unit_price is never taken from the client. |
PATCH |
/:id/schedule |
orders.create or orders.status.update + order write limiter |
path: id; body: scheduled_for |
{ order }. Updates scheduled_for (validated in the store timezone). For unreleased orders in release_status = 'scheduled', resets release_status to 'scheduled' and clears release_blocked_reason; for held orders in release_status = 'blocked', preserves release_status = 'blocked' and release_blocked_reason so only explicit POST /:id/release retries release. Records schedule_updated in order_audit_log. Allowed before release even after payment; 409 once released, completed, or cancelled. |
POST |
/:id/release |
orders.create or orders.status.update + order write limiter |
path: id |
{ order, already_released, kot_auto_printed } on release (order_released in order_audit_log), or 409 with code: 'RELEASE_BLOCKED' and { order } when tracked product/variant/add-on stock, catalog validation, or fully refunded bills hold the whole order in release_status = 'blocked'. A full refund sets release_blocked_reason to refunded; cancellation is the recovery path. Returns 409 (ORDER_NOT_PAID) when an on_payment multi-counter order is not fully paid and does not meet the full-refund hold condition. |
POST |
/:id/items |
ROLE_ACCESS.sales + order write limiter |
path: id; body: items, special_instructions; header: Idempotency-Key |
Appends items and returns { order: { ...order, items }, added_item_ids, added_items } (where added_item_ids and added_items list only the newly inserted lines). Honours Idempotency-Key. Items resolve a variant_id under the same rules as order creation, priced with variant.online_price when the order carries an online_platform. Returns 409 (SCHEDULED_ORDER_PAID) on a scheduled order once a payment has been recorded. |
PATCH |
/:id/status |
orders.status.update (default roles: ROLE_ACCESS.orderStatus) + order write limiter |
path: id; body: status, reason, override_pin, free_table, approver_id, manager_id, user_id |
Order-level transition. Allowed targets: pending to preparing, ready, served, completed, cancelled; preparing to ready, served, completed, cancelled; ready to served, completed, cancelled; served to completed, cancelled. completed and cancelled are terminal. Repeating the current status is a no-op. See the status safeguard rules below. |
PATCH |
/:id/customer |
ROLE_ACCESS.ownerManager + order write limiter |
path: id; body: customer_id |
{ order: { ...order, customer } }. Returns 409 (SCHEDULED_ORDER_PAID) on a paid scheduled order. See Order lifecycle for active-order, bill-state, tax-recompute, and audit rules. |
PATCH |
/:id/convert-to-takeaway |
ROLE_ACCESS.sales + order write limiter |
path: id |
{ order: { ...order, items, table: null } }. See Order lifecycle for active dine-in, bill-state, charge-recompute, and audit rules. |
PATCH |
/:id/discount |
ROLE_ACCESS.ownerManagerCashier + order write limiter |
path: id; body: discount_type, discount_value, discount_reason, override_pin, approver_id, manager_id, user_id |
400 when the order is completed or cancelled (order_not_discountable). 409 on a paid scheduled order (SCHEDULED_ORDER_PAID), on a split or refunded bill, or when the discount would reduce a partially paid bill's total below collected payments (below_collected_funds). Auto-settles the bill to paid, completes the order, and frees the table when remaining balance reaches zero on a partially paid bill (gated by kitchen delivery; see Settlement and completion). Audited as order_discount_applied. |
PATCH |
/:id/items/:itemId/discount |
ROLE_ACCESS.ownerManagerCashier + order write limiter |
path: id, itemId; body: discount_type, discount_value, override_pin, approver_id, manager_id, user_id |
Returns { item } with updated edit_version. 400 on a completed or cancelled order, or a cancelled, voided, void-adjustment, or refunded item. 409 on a paid scheduled order (SCHEDULED_ORDER_PAID), on a split or refunded bill, or when the discount would reduce a partially paid bill's total below collected payments (below_collected_funds). Auto-settles the bill to paid, completes the order, and frees the table when remaining balance reaches zero on a partially paid bill (gated by kitchen delivery; see Settlement and completion). |
PATCH |
/:id/items/:itemId/price |
orders.item.price_override (owner only, not configurable) + order write limiter |
path: id, itemId; body: price (non-negative, 0 allowed), reason (required, at most 200 characters) |
{ item }. Replaces the line's unit price, keeps the first menu price in original_unit_price, flags price_overridden, records the actor and time, and recomputes item tax, order totals, and any open bill on the backend. An existing item discount stays, capped at the new line total. 403 for any other role, 400 on a cancelled or completed order or item, 409 on a paid scheduled order (SCHEDULED_ORDER_PAID) or once a bill has payments, a split, or a refund. Audited as item_price_overridden. |
PATCH |
/:orderId/items/:itemId/cancel |
any authenticated role + item cancel limiter, then an in-handler permission check | path: orderId, itemId; body: override_pin, reason, approver_id, manager_id, user_id |
See the note below. Returns { order: { ...order, items } }. |
PATCH |
/:orderId/items/:itemId/edit |
orders.item.edit (shipped to ROLE_ACCESS.owner) + order write limiter |
path: orderId, itemId; header: Idempotency-Key; body: expected_edit_version (non-negative integer matching the item's edit_version, derived from the latest order_audit_log.id for the item or 0), and exactly one of quantity or replacement (product_id, quantity, optional variant_id, modifier_selection, addons, special_instructions), optional reason |
Returns { order: { ...order, items } } where hydrated items carry edit_version and, on cancelled or voided rows marked superseded: true in item_edited, superseded: true. Honours Idempotency-Key (replaying a matching key + request before stale-version or terminal-item checks, 409 on key reuse with a different request) and rejects a stale expected_edit_version with 409. On a pending line, restores recorded product/add-on/recipe stock once, cancels the old line as superseded (superseded: true), and inserts and deducts the new pending line; the new line keeps the stored variant and add-on selections even if the catalog has since disabled them (reductions preserve stored selections and scale historical recipe snapshots and inventory deductions onto the new pending line; replacements and increases re-validate against the current catalog, and the carried variant_selection snapshot is persisted verbatim). On a line the kitchen already started (preparing, ready, served), a quantity increase keeps the original line and inserts/deducts only the extra units as a new pending line (superseded: false); a quantity decrease updates the existing line's billable quantity in place without restoring or re-deducting stock, scaling its held inventory_deducted_quantity, add-on deductions, and recipe_snapshot to the remaining quantity so wasted units stay out of available stock, while preserving its kitchen status and stored unit/add-on prices and recording wasted_quantity in item_edited; product replacement is disallowed once kitchen preparation has started (400). Re-syncs unpaid and partially paid bills. 409 on a paid scheduled order (SCHEDULED_ORDER_PAID), on a split, paid, or refunded order, or when the new total falls below the amount already collected. Audited as item_edited. |
PATCH |
/:orderId/items/:itemId/restore |
in-handler orders.item.restore (shipped to ROLE_ACCESS.ownerManager) + order write limiter |
path: orderId, itemId |
Returns { order: { ...order, items } }. 400 on a completed or cancelled order, 409 on a paid scheduled order (SCHEDULED_ORDER_PAID), when a bill is paid, partially paid, or refunded, or when the cancelled line was superseded by an item edit. Re-deducts inventory and recipe components, and recomputes any order-level discount (scaling a percentage discount or capping a flat discount at the new subtotal). |
Order creation and item append accept an optional version-4 UUID X-Operation-ID correlation
header. A validation rejection with a registered failure point includes reason_code, operation, stage,
classification, outcome, and correlation_id alongside error; display_code may also be
present. A valid supplied UUID is returned as the correlation id, otherwise the server generates
one. Failures without a registered tuple may retain the { error } response.
PATCH /:id/status evaluates order, bill/payment, and kitchen state inside the transaction:
- Ordinary
completedtransitions require every payable bill for the order to be settled (paidwith zero unpaid balance) and billed totals (accounting forround_off) to coverorder.total. An unbilled positive-total order is not considered paid. Whenrequire_kitchen_delivered_before_settlementis enabled (kds_enablednotfalseandbilling_typenotprepaid), any item still inpending,preparing, orreadyalso blocks ordinary completion. - When unsettled payment or the applicable kitchen-delivery requirement blocks
completed, an explicitoverride_pinfrom an activeownerormanagerholdingorders.status.update(pluspayments.takewhen overriding unsettled payment) is required (400when missing,403when invalid or unauthorized,429after 5 failed attempts). Being logged in as an owner or manager does not bypassoverride_pin. Overriding completion never marks bills paid, erases balances, or creates payment records. cancelledtransitions require an explicitoverride_pinfrom an activeownerormanagerholdingorders.status.updateandorders.item.cancelwhenever the order has advanced pastpending, any item is inpreparing,ready,served, orcompleted, or any bill holds recorded collected funds. Cancelling a scheduled order that holds net collected funds returns409(REFUND_REQUIRED_BEFORE_CANCEL) until refunded in full. Cancellation preserves bill payment and refund records (refunds remain a separate operation via/api/refunds) and restores product, variant, add-on, and recipe stock only for items still inpendingstatus that actually deducted inventory (unreleased scheduled orders deduct no stock on creation, so none is restored).- When
approver_id(ormanager_id,user_id) is supplied on an overridden transition, PIN verification is restricted to that active user without falling back to scanning other managers. When omitted, candidate managers are evaluated, skipping users who lack the required role or permissions (includingpayments.takewhen completion is blocked by unsettled payment). Every overriddenstatus_changedaudit log entry recordsactor_user_id,approved_by,overridden_safeguards,bill_ids,outstanding_amount,outstanding_amounts, andcollected_amount(plusundelivered_itemswhen overriding kitchen delivery), and never records the PIN.
PATCH .../cancel and PATCH .../restore resolve the caller's effective permissions from the
database inside a withTxn (deferred, not immediate) transaction rather than from the JWT claim,
and re-run every policy check there. Each opens the single transaction scope it runs in; the route
does not wrap the handler again.
PATCH .../cancel is a policy switch, not a plain status write:
403withoutorders.item.void(shipped toROLE_ACCESS.sales) when the item is already inpreparingorready, and403without it again for an item that is alreadyvoided,void_adjustmentorrefunded. Every other status, includingpending,servedand an item alreadycancelled, needsorders.item.cancel(shipped toROLE_ACCESS.ownerManager). Voiding an in-progress item additionally requiresoverride_pin. When an approver is explicitly supplied (approver_id,manager_id, oruser_id), PIN verification is restricted to that active user without falling back to scanning other managers. A caller who already holds the required permission can repeat the call on a terminal item without a PIN, in which case it is a no-op that returns the current state.409on a paid scheduled order (SCHEDULED_ORDER_PAID) or when any bill for the order is paid, partially paid, or carries payment details.400when the order iscompletedorcancelled.- Voiding an item in preparation writes a mirrored negative
order_itemsrow with statusvoid_adjustmentand marks the originalvoided, so the bill total adjusts while the original line stays visible. Inventory is not restored on the void path. A plain cancel sets the item tocancelledand restores the recorded inventory deduction and recipe components. - Cancelling the last active item cancels the order and frees its table.
override_pinattempts are rate limited per canonical client IP, not per item, to slow brute force (counting only failed attempts; 429 after 5 failed attempts within 15 minutes; serialized to prevent concurrent brute-force races).
Router: main/routes/order-items.ts. Full path: /api/order-items.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
PATCH |
/:id/status |
kitchen.status.update (default roles: ROLE_ACCESS.kitchen) + KDS-enabled |
path: id; body: status, expected_status |
{ order }. Body status in pending, preparing, ready, served, plus optional expected_status. Re-checks kitchen.status.update and station/category scope inside the write transaction and reconciles active parent orders.status (see Order lifecycle). Returns 409 (Scheduled order has not been released to the kitchen yet) when the item belongs to an unreleased scheduled order. |
Router: main/routes/held-orders.ts. Full path: /api/held-orders.
Held-cart behavior and the POS resume sequence are described in Order lifecycle.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/ |
ROLE_ACCESS.sales + held-order read limiter |
none | Held carts in updated_at order, each with waivedChargeIds and optedInChargeIds; a hold saved without them reads back as an empty list. A row whose stored selection JSON is damaged is still returned with empty selections. |
POST |
/ |
ROLE_ACCESS.sales + held-order write limiter |
body: held-cart fields plus optional waivedChargeIds, optedInChargeIds |
{ success, id }. Selections are bounded charge ids (CHARGE_ID_PATTERN, MAX_CHARGE_ID_LENGTH) and are deduplicated; an omitted, null, or empty list means no choices, and a malformed list is a 400 that leaves the stored cart untouched. Unknown but well-formed ids persist, because the charges engine decides applicability when the order is created. |
DELETE |
/:tableId |
ROLE_ACCESS.sales + held-order write limiter |
path: tableId; query: ?heldOrderId |
heldOrderId must match the table's current hold; a missing or stale id returns { success: true, deleted: false }. A successful deletion sets the table to available only if it is still held. |
Router: main/routes/bills.ts. Full path: /api/bills.
Bill generation, settlement, and completion are described end to end in Order lifecycle.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/ |
ROLE_ACCESS.ownerManagerCashier |
query: ?status, ?order_id, ?customer_id, ?today, ?per_page, ?limit, ?offset |
{ bills, pagination }. |
GET |
/:id |
ROLE_ACCESS.ownerManagerCashier |
path: id |
- |
GET |
/order/:orderId |
ROLE_ACCESS.ownerManagerCashier |
path: orderId |
- |
POST |
/generate |
ROLE_ACCESS.ownerManagerCashier |
body: order_id |
See Order lifecycle for bill creation and re-sync behavior. |
POST |
/:id/split-check |
ROLE_ACCESS.ownerManagerCashier |
body: checks |
Body checks must hold between 2 and 20 entries and the resulting split group is capped at 20 checks. An unpaid untouched dine-in check is divided by allocating every active order item quantity; a split-group child may be divided again while it stays unpaid with no payment recorded, keeping its bill id, bill number and group, while paid siblings keep their own rows and payments. 400 when a request does not allocate exactly the source's own quantities. 403 when split_checks_enabled is off. 409 when the source has a recorded payment, a legacy already-split bill is re-split, the source has no divisible allocation, or the resulting split group would exceed 20 checks. |
POST |
/:id/payment |
ROLE_ACCESS.ownerManagerCashier |
path: id; body: payment line object (accepts optional override_pin, approver_id, manager_id, user_id), customer_id; header: Idempotency-Key |
See Order lifecycle for input, replay, gate, and settlement behavior. |
POST |
/:id/payments |
ROLE_ACCESS.ownerManagerCashier |
path: id; body: payments, customer_id, optional override_pin, approver_id, manager_id, user_id; header: Idempotency-Key |
See Order lifecycle for input, replay, gate, and settlement behavior. |
PATCH |
/:id/payments/:index/method |
ROLE_ACCESS.ownerManager + payments.take + active owner or manager Staff Approval PIN |
path: id, index; body: method, override_pin, optional payment_method_id, transaction_id, reason, approver_id, manager_id, user_id; header: Idempotency-Key |
{ bill, audit } (with idempotentReplay: true on Idempotency-Key or natural replay). Corrects the tender method on payment line :index of a paid bill without changing the amount (400 amount_immutable if a differing amount is supplied, 400 wallet_method_immutable if changing to wallet, 400 card_transaction_id_required when required for card, 400 payment_method_unavailable for unknown methods). Caller must be an authenticated active owner or manager holding payments.take (403 insufficient_role_permissions for cashier, server, or chef callers, or when payments.take is revoked) and must provide an active owner or manager Staff Approval PIN (403 when missing, invalid, or belonging to a cashier, server, or chef; 429 when rate-limited). Returns 404 when the bill or payment line is not found, and 409 when the bill is not paid (bill_not_paid), the existing line is wallet (wallet_method_immutable), the payment's cash shift is closed, missing/unverifiable, or was recorded without an open shift (shift_closed), the store business day is closed (day_closed), the bill, line, or a split sibling has been refunded (bill_refunded, payment_line_refunded, split_sibling_refunded) or settled in a closed or different shift (split_sibling_shift_closed), the target custom payment method is inactive (payment_method_inactive), transaction_id is already used (transaction_reference_used, transaction_reference_reused), or Idempotency-Key conflicts (idempotency_key_conflict). Audited in order_audit_log as bill.payment_method.corrected. |
POST |
/:id/applyDiscount |
ROLE_ACCESS.ownerManager |
path: id; body: type, value, reason, override_pin, approver_id, manager_id, user_id |
400 when the bill is paid (paid). 409 on a paid scheduled order (SCHEDULED_ORDER_PAID), when the order is completed or cancelled (order_not_discountable), the bill is split (split_check) or refunded (refunded), or the discount would reduce the bill total below collected payments (below_collected_funds). Auto-settles the bill to paid, completes the order, and frees the table when remaining balance reaches zero on a partially paid bill (gated by kitchen delivery; see Settlement and completion). Audited as order_discount_applied. |
PATCH |
/:id/charges |
bills.discount.apply |
path: id; body: charge_id, waived or applied, optional override_pin, approver_id, manager_id, user_id |
{ bill } with updated charge breakdown and totals. Only for unpaid, unsplit bills with no payments; the charge must be active and apply to the order type. Waiving a charge when the caller is outside ROLE_ACCESS.ownerManager (or when override_pin is supplied) requires an active owner or manager Staff Approval PIN holding bills.discount.apply (403 with requiresApproval: true when missing, 403 on invalid PIN, 429 when rate-limited, 400 on conflicting approver ids). 409 on a paid scheduled order (SCHEDULED_ORDER_PAID) or when the order is cancelled. |
POST |
/:id/markPrinted |
bills.print |
path: id |
Stamps bills.printed_at. |
POST |
/:id/print |
ROLE_ACCESS.ownerManagerCashier |
path: id; body: print_type |
- |
GET |
/:id/print-history |
ROLE_ACCESS.ownerManagerCashier |
path: id |
Print jobs recorded for the bill, newest first. |
Bill detail reads and bills embedded in hydrated orders include computed payment_method_correction metadata. Its correctable flag is true when at least one payment line is eligible; reason is null in that case, otherwise it is the first applicable correction failure code. Each entry in lines carries its original payment index, correctable flag, and nullable reason. This metadata describes financial eligibility only and is advisory: the correction request still checks current eligibility, caller permissions, and approval inside the write flow.
Router: main/routes/refunds.ts. Full path: /api/refunds.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
POST |
/ |
ROLE_ACCESS.ownerManager |
body: bill_id, order_item_id, amount, reason, approver_id, manager_id, method, shift_id, override_pin; header: Idempotency-Key |
Creates a refund against bill_id, or a single order_item_id. Approval tiers and cutoff are documented in product invariants. Honours Idempotency-Key (replaying a committed refund returns its stored 201 response without re-verifying PINs or incrementing rate limits, including for queued retries; reusing the key with a different request returns 409 idempotency_conflict). |
GET |
/ |
ROLE_ACCESS.ownerManagerCashier |
query: ?bill_id, ?limit, ?offset |
Refund rows, newest first. |
Router: main/routes/expenses.ts. Full path: /api/expenses. Reads need expenses.view. Expense
creation, voiding, and replacement need expenses.manage and expenses.view, while payment and
reversal mutations need expenses.pay and expenses.reverse (alongside expenses.view, plus
cash.movements.manage for cash methods) so an actor can always read back the row they touched.
Amounts are positive safe-integer minor units in the expense's own currency_code; no endpoint
converts currencies or assumes a minor-unit factor. Active incurred totals per currency (after
subtracting the replaced source on replacement) and returned aggregate totals must fit within
Number.MAX_SAFE_INTEGER, or the operation fails with 422 amount_range_exceeded; legacy rows with
an out-of-range stored amount project amount_minor and due_minor as null so list, detail, and
recovery mutations stay usable without rounding.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/context |
expenses.view |
none | { currency_code, business_date, cash_session_open }: store currency, store business date, and whether a shift is open for the caller. |
GET |
/categories |
expenses.view |
query: ?include_inactive |
{ categories, truncated }. Deactivated categories stay readable for history and never accept a new expense. |
POST |
/categories |
expenses.manage + expenses.view; header Idempotency-Key |
body: name, optional is_active (defaults to true) |
201 { category }. Names are unique after trimming, case-insensitively. |
PATCH |
/categories/:id |
expenses.manage + expenses.view; header Idempotency-Key |
path: id; body: name and/or is_active |
{ category }. Explicit values only - there is no toggle - and renaming never rewrites the category label snapshotted on existing expenses. |
GET |
/ |
expenses.view |
query: ?from, ?to, ?category_id, ?status, ?currency, ?limit, ?cursor |
{ expenses, limit, maxLimit, nextCursor? }, newest incurred_on first with a stable id tiebreak. status is active (default), voided, replaced, or all; limit defaults to 50 and caps at 100. |
GET |
/summary |
expenses.view |
query: ?from, ?to, ?category_id, ?currency |
{ basis, filters, groups, totals }; SQL aggregation over every matching expense, not just the fetched page. |
GET |
/:id |
expenses.view |
path: id; query: ?payments_limit, ?payments_cursor |
{ expense, payments, paymentsNextCursor? }; payments are newest first, with reversals kept as their own entries. |
POST |
/ |
expenses.manage + expenses.view; header Idempotency-Key |
body: category_id, description, amount_minor, incurred_on, optional currency_code, payee, notes |
201 { expense }. A new expense never starts paid, replaces_expense_id cannot be set here, and a supplied currency_code must match the store currency. |
POST |
/:id/void |
expenses.manage + expenses.view; header Idempotency-Key |
path: id; body: reason |
{ expense }. Requires net paid of zero, preserves the original expense fields and created_at, and adds void metadata. |
POST |
/:id/replace |
expenses.manage + expenses.view; header Idempotency-Key |
path: id; body: replacement fields plus reason |
201 { expense, replaced_expense_id }. Voids the source and creates the linked replacement in one transaction; a source can be replaced once. |
POST |
/:id/payments |
expenses.view + expenses.pay, plus cash.movements.manage when method is cash; header Idempotency-Key |
path: id; body: amount_minor, method (cash, card, bank_transfer, other), optional reference, currency_code |
201 { expense, payment }. Amounts are positive minor units that may not exceed the current due; a supplied currency_code must match the expense. New payments require the expense currency to match the current store currency or return 409 currency_mismatch. A cash payment needs a real open shift regardless of require_open_shift and writes exactly one linked Pay Out. |
POST |
/:id/payments/:paymentId/reverse |
expenses.view + expenses.reverse, plus cash.movements.manage when the reversed payment was cash; header Idempotency-Key |
path: id, paymentId; body: reason |
201 { expense, payment }. Copies the committed payment's amount and method, requires the expense currency to match the current store currency (409 currency_mismatch otherwise), is allowed once per payment, and leaves the original payment and its drawer movement untouched; a cash reversal needs today's open shift and writes one linked Pay In. |
Mutation bodies are hashed into an immutable fingerprint under the authenticated actor and the
Idempotency-Key: replaying the same key with the same normalized request returns the committed
result (with an Idempotent-Replay: true header) without writing again, and reusing a key for a
different operation, resource, or payload is a 409. Replays resolve before current balance,
category, or currency checks, while current access is still enforced, and only committed mutations
are recorded. Omitted currency on expense creation or replacement resolves against the committed
expense snapshot during replay, so it remains equivalent to that explicit currency after regional
settings change.
incurred_on is a validated Gregorian YYYY-MM-DD business-date label: it may precede today (a
closed day is fine) and may not be in the future, so audit timestamps record the real creation
time rather than a midnight back-dated one. The summary covers active expenses incurred inside the inclusive range and reports current net paid
and due against them, grouped by currency and category, so a payment recorded after the range still
counts as paid to date. Those are not payments-made-in-period figures, they are never subtracted
from sales reports, and voided or replaced expenses stay out of the headline totals while remaining
readable through the status history filter. Payments settle against the immutable ledger and are
never edited in place: a reversal is a separate reasoned row that copies the original amount and
method, and cash is always corrected by that reversal: POST /api/cash-closures/movements/:id/void
refuses a drawer movement that backs an expense payment or reversal, so the original Pay Out is
never voided out from under the ledger.
Router: main/routes/payment-methods.ts. Full path: /api/payment-methods.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/merge-history |
ROLE_ACCESS.ownerManager |
none | Record of method merges. |
GET |
/ |
ROLE_ACCESS.allStaff |
query: ?include_inactive |
{ payment_methods: [ ... ] }. |
POST |
/ |
ROLE_ACCESS.ownerManager |
body: name |
- |
PUT |
/:id |
ROLE_ACCESS.ownerManager |
path: id; body: name, is_active, sort_order |
- |
DELETE |
/:id |
ROLE_ACCESS.ownerManager |
path: id |
- |
POST |
/:id/merge |
ROLE_ACCESS.ownerManager |
path: id; body: target_type, target_id |
Folds a method into another; paid history is rewritten. |
Router: main/routes/cash-sessions.ts. Full path: /api/cash-sessions.
See business time for single-shift and per-staff shift (one_shift_per_staff) lifecycle and shared-drawer rules.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
POST |
/open |
ROLE_ACCESS.ownerManagerCashier |
body: opening_float_cents |
Body opening_float_cents (integer minor units, defaults to 0). 409 (session_already_open) when a shift is already open (one_shift_per_staff off) or the caller already has an open shift (one_shift_per_staff on), or 409 (day_already_closed) when the current store business day is already closed. |
GET |
/open |
ROLE_ACCESS.ownerManagerCashier |
none | { sessions, one_shift_per_staff }: open shifts visible to the caller (all open shifts for owner/manager, own open shift for other roles) with live expected_cash_cents. |
GET |
/current |
ROLE_ACCESS.ownerManagerCashier |
query: optional ?session_id, ?user_id |
The open shift (for the caller by default, or the specified session_id/user_id for owner/manager; 400 session_id_invalid for a non-positive integer session_id; 403 session_owner_required if a non-owner/manager requests another user's shift), or null when none is open. |
GET |
/current/close-preview |
ROLE_ACCESS.ownerManagerCashier |
query: optional ?session_id |
Informational carry-over preview of open/unpaid bills created during the shift window (unpaid_in_shift with count, bill_count, pending_cents, sample, and would_block_close: false). Defaults to the caller's open shift; owner/manager may pass ?session_id (400 session_id_invalid for a non-positive integer session_id; 404 session_unavailable when no open shift matches; 403 session_owner_required if a non-owner/manager previews another user's shift). |
POST |
/:id/close |
ROLE_ACCESS.ownerManagerCashier |
path: id; body: counted_cash_cents, optional notes |
Closes the shift; returns { closure_id, variance_cents, expected_cash_cents, counted_cash_cents }. Open/unpaid orders from the shift remain open and carry over to the next shift while expected_cash_cents reconciles only cash collected during the shift. 409 when the shift is already closed (session_already_closed). 403 when a non-owner/manager attempts to close another user's shift (session_owner_required). |
Router: main/routes/cash-closures.ts. Full path: /api/cash-closures.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/movements |
ROLE_ACCESS.ownerManagerCashier |
query: ?business_date |
Append-only cash-drawer history, newest first, including soft-voided rows. |
POST |
/movements |
ROLE_ACCESS.ownerManagerCashier |
body: business_date, movement_type, amount_cents, reason, optional cash_session_id |
Attaches the movement to the caller's open shift, or to cash_session_id when specified (400 if not a positive integer, 404 cash_session_not_found if not found, 409 session_already_closed if already closed, 403 session_owner_required if a non-owner/manager targets another user's shift). |
POST |
/movements/:id/void |
ROLE_ACCESS.ownerManager |
path: id; body: reason |
- |
POST |
/ |
ROLE_ACCESS.owner |
body: business_date, opening_float_cents, counted_cash_cents, notes |
Closes a business date. counted_cash_cents is the physical count; the response carries the variance and the next z_number. 409 with open_shift_blocks_day_close until all shifts opened on or before that business date are closed (response includes blocking_shift and blocking_shifts). Claims any configured z_report_day_close attempt in a savepoint during the close transaction and dispatches it asynchronously after commit without affecting the close result (see WhatsApp receipt delivery). |
POST |
/:id/print |
ROLE_ACCESS.ownerManagerCashier |
path: id; body: isReprint |
Prints the close receipt. Day-close rows require owner (403); shift-session rows (scope = 'session') require the shift owner (opened_by on the linked shift session, or closed_by when no linked session exists), manager, or owner (403). |
Router: main/routes/customers.ts. Full path: /api/customers.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
DELETE |
/admin/cleanup |
ROLE_ACCESS.owner + customer write limiter |
none | Owner-only customer cleanup. |
POST |
/admin/repair-phones |
ROLE_ACCESS.ownerManager + customer write limiter |
none | Rewrites stored phone values into canonical E.164 form. |
GET |
/alerts |
ROLE_ACCESS.sales + customer read limiter |
none | Customers with phone or data-quality problems. |
GET |
/ |
ROLE_ACCESS.sales + customer read limiter |
query: ?search, ?filter, ?sort, ?order, ?per_page |
Paginated list; ?search, ?filter, ?sort, ?order, ?per_page. |
GET |
/:id |
ROLE_ACCESS.sales + customer read limiter |
path: id |
- |
GET |
/:id/wallet |
ROLE_ACCESS.sales + customer read limiter |
path: id |
Wallet balance and loyalty state for the customer. |
GET |
/:id/top-items |
ROLE_ACCESS.sales + customer read limiter |
path: id |
{ items }: up to 5 { product_id, product_name, total_quantity, order_count, available }, the products the customer bought most on completed orders. Variants and modifiers count under their product. Cancelled, voided, refunded, and refund-adjustment lines are excluded, as is an order whose every bill is refunded. Ranked by total quantity, then order count, then name (case-insensitive), then product id. available is false for a deleted or inactive product or one whose category or any ancestor category is deleted or inactive; such products stay listed. 404 for an unknown customer. |
POST |
/ |
ROLE_ACCESS.sales + customer write limiter |
body: phone, name, email, address, notes, country_code |
- |
PUT |
/:id |
ROLE_ACCESS.ownerManagerCashier + customer write limiter |
path: id; body: phone, name, email, address, notes, country_code |
- |
Router: main/routes/staff.ts. Full path: /api/staff, also mounted at /api/users.
Mounted at both /api/staff and /api/users from one router, so a request to either prefix reaches the same handlers.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/ |
ROLE_ACCESS.ownerManager |
query: ?role, ?active |
{ users: [ ... ] }. |
GET |
/:id |
ROLE_ACCESS.ownerManager |
path: id |
- |
POST |
/ |
ROLE_ACCESS.ownerManager + auth limiter |
body: name, email, password, role, pin, station_ids |
201 with the new user id. |
PUT |
/:id |
ROLE_ACCESS.ownerManager + auth limiter |
path: id; body: name, email, password, role, pin, is_active |
- |
POST |
/:id/deactivate |
ROLE_ACCESS.ownerManager |
path: id |
- |
POST |
/:id/reactivate |
ROLE_ACCESS.ownerManager |
path: id |
- |
Router: main/routes/printers.ts. Full path: /api/printers.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/ |
printers.manage or printing.execute |
none | { printers: [ ... ] }; printers.manage receives the full list, while printing.execute receives only the default printer's id, name, connection_type, is_default, and readiness_state. |
GET |
/detect |
any authenticated role | none | - |
GET |
/supported |
any authenticated role | none | - |
GET |
/:id |
any authenticated role | path: id |
Single printer. |
POST |
/ |
ROLE_ACCESS.ownerManager |
body: connection_type, ip_address, port, paper_width, text_size, is_default, cash_drawer_pulse_enabled, name |
- |
PUT |
/:id |
ROLE_ACCESS.ownerManager |
path: id; body: connection_type, ip_address, port, paper_width, text_size, is_default, cash_drawer_pulse_enabled, name |
- |
DELETE |
/:id |
ROLE_ACCESS.ownerManager |
path: id |
- |
POST |
/:id/set-default |
ROLE_ACCESS.ownerManager |
path: id |
- |
POST |
/:id/test |
ROLE_ACCESS.ownerManager |
path: id; body: rasterProbe |
- |
POST |
/kick-drawer |
cash.movements.manage |
none | Requires an open cash shift for the caller (or any open shift for owner/manager); returns 409 with { error: "open_cash_shift_required" } otherwise. Success is { success: true } for USB/network printers or includes webusb: true, bytes, and printerName for browser delivery. 404 if no printer is configured, 400 if the selected connection is unsupported or lacks an IP address, and 502 if the pulse fails. |
POST |
/test-active |
printing.execute |
none | Sends a standard test page to the default printer; returns { success: true } or includes webusb: true, bytes, and printerName for browser delivery. 404 if no default printer is configured, 400 if the selected connection is unsupported or lacks an IP address, 409 if regional settings are missing, and 502 if printing fails. |
POST |
/print-menu |
catalog.view and printing.execute |
body: boolean includeInactive, includeOutOfStock, includeHidden, includeDescriptions, includeModifiers; optional paperWidth (58 or 80) and printerId |
200 physical-print success, or webusb: true with byte array for browser delivery; 400 invalid input or printer_not_configured, 404 unknown printerId (printer_not_found), 409 missing regional configuration, 422 empty selection, 502 print failure. Prints to printerId when given, otherwise to the configured default, across connection types. |
POST |
/print-bill |
printing.execute |
body: billId, orderId, isReprint, preview, useUnicode, arabicShaping |
Body billId or orderId, plus isReprint, preview, useUnicode, arabicShaping. preview returns the rendered payload without sending it to the device. Returns 403 (BILL_PRINTING_DISABLED) for the server role when server_app_bill_printing_enabled is off. |
POST |
/print-kot |
printing.execute |
body: orderId, stationName, items, useUnicode, arabicShaping |
Body orderId, optional stationName and items, plus useUnicode and arabicShaping. Returns 403 (KOT_PRINTING_DISABLED) when kot_printing_enabled is off, and 409 when orderId is an unreleased scheduled order. |
POST |
/print-payment-ticket |
printing.execute |
body: orderId (or order_id), optional printerId (or printer_id), useUnicode, preview |
Prints a payment ticket slip with order number, non-terminal items, totals, and QR code to the default or specified printer, or returns { webusb: true, bytes, printerName } for browser delivery, or { preview: true }. 400 on missing order id or when no printer is configured, 404 when order is not found, 409 if regional settings are missing, and 502 on print failure. |
GET |
/delivery-slip-payment/:orderId |
printing.execute |
path: orderId |
200 with optional payment fields status, amount, amountDue, formattedAmount, formattedAmountDue, method, methods, and expectedMethod (the order's expected collection method, absent when unknown); 404 if the order does not exist. Uses the order total when no bill exists, otherwise the latest bill or all same-order bills in that bill's split group and their stored balances. |
POST |
/print-delivery-slip |
printing.execute |
body: orderId, useUnicode, arabicShaping |
Body orderId plus optional useUnicode and arabicShaping. Includes the full customer number when either bill_delivery_show_customer_phone_always or bill_show_customer_phone is on, and omits it only when both are off. Prints the delivery address, delivery note, order note, items, and payment status; an unpaid balance names the expected collection method. No bill is required: without a bill the slip shows the order total; otherwise it uses the selected bill balance(s), including any positive balance on refund-marked bills, as the amount due. Paid bills show the captured method(s) and total; refunds retain their refund status; see product invariants. |
Router: main/routes/print-templates.ts. Full path: /api/print-templates.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/ |
ROLE_ACCESS.ownerManager |
none | - |
POST |
/ |
ROLE_ACCESS.owner + template write limiter |
body: name, payload, origin, derivedFrom |
- |
PUT |
/:id |
ROLE_ACCESS.owner + template write limiter |
path: id; body: name, payload |
- |
POST |
/:id/activate |
ROLE_ACCESS.owner + template write limiter |
path: id |
- |
POST |
/:id/archive |
ROLE_ACCESS.owner + template write limiter |
path: id |
- |
POST |
/:id/rollback |
ROLE_ACCESS.owner + template write limiter |
path: id |
- |
GET |
/:id/payload |
ROLE_ACCESS.ownerManager |
path: id |
- |
GET |
/:id/export |
ROLE_ACCESS.owner + template write limiter |
path: id |
- |
POST |
/import |
ROLE_ACCESS.owner + template write limiter |
body: file |
- |
Router: main/routes/menu-csv.ts. Full path: /api/menu-csv.
Category, product, and add-on exports include flo_csv_encoding=quote-v1 for reversible formula protection; see product invariants for marked, unmarked legacy, and unsupported encoding rules.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/template/:type |
catalog.import-export |
path: type |
CSV header template for categories (includes parent), products (includes category_parent), or addons. |
GET |
/export/categories |
catalog.import-export |
none | text/csv attachment categories-export.csv with parent (live parent category name, empty for roots or when a legacy parent is missing/soft-deleted) and flo_csv_encoding=quote-v1. |
GET |
/export/products |
catalog.import-export |
none | text/csv attachment products-export.csv with category_parent (live immediate parent name when category has a live parent, empty when root or orphaned) and flo_csv_encoding=quote-v1. |
GET |
/export/addons |
catalog.import-export |
none | text/csv attachment addons-export.csv with flo_csv_encoding=quote-v1. |
POST |
/import/categories |
catalog.import-export |
body: csv |
Body csv is the raw file text. Additive import (create or skip): resolves parent independently of row order, treats a legacy dangling parent_id pointing to a missing or soft-deleted category as an effective root during CSV lookup without modifying stored parent_id, accepts flat CSVs without a parent column when category lookup is unambiguous, and rejects cycles, grandchildren, missing/inactive/ambiguous parents, ambiguous lookups, and duplicate sibling names. Decodes quote-v1 rows, preserves unmarked category names, and returns created, updated, reactivated, skipped, and failed counts plus per-row errors. |
POST |
/import/products |
catalog.import-export |
body: csv |
Body csv is the raw file text. Resolves (category_parent, category) against root parents first, falling back to an existing live non-root parent -> child pair only when no root parent matches and the pair uniquely identifies one live category; when the category_parent column is present and blank, matches an effective root category (including a legacy category whose parent is missing/soft-deleted) first or falls back to an unambiguous subcategory; when category_parent is omitted (flat CSVs), resolves category if unambiguous across all live categories and rejects ambiguous category names explicitly. New product rows and category-changing updates require an active category with an active, non-deleted ancestor chain, whereas an existing product identified by id may retain its unchanged non-deleted category_id. A supplied id matching a non-deleted product updates it; a missing or unrecognized id creates a product with a generated id unless the name and category already exist, in which case the row is skipped. Decodes quote-v1 rows and ID-proven legacy product fields. Returns created, updated, reactivated, skipped, and failed counts plus per-row errors. |
POST |
/import/addons |
catalog.import-export |
body: csv |
Body csv is the raw file text. Decodes quote-v1 rows and ID-proven legacy add-on fields, and reports counts plus per-row errors. |
Router: main/routes/settings.ts. Full path: /api/settings.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/offline-mode |
settings.view |
none | { offline_mode }. |
PUT |
/offline-mode |
offline-mode.manage (owner only, not configurable) |
body: offline_mode (boolean) |
{ offline_mode }. 400 for a non-boolean value. |
GET |
/charges |
settings.view, orders.create, or bills.discount.apply |
none | { charges }; fee definitions only. |
PUT |
/charges |
settings.manage |
body: charges or a charge-definition array |
{ charges }; replaces the validated fee definitions. Fee amounts remain configured in Settings. |
GET |
/business |
settings.view, bills.read, bills.print, or bills.generate |
none | - |
PUT |
/business |
ROLE_ACCESS.ownerManager |
body: business_name, timezone, business_day_start_time, currency, country, language, tax_registration_number, state_code, business_address, business_phone, instagram_handle, billing_type, tables_required, tax_registered, bill_show_name, bill_show_address, bill_show_phone, bill_show_tax_id, bill_show_tax_breakdown, bill_show_customer_name, bill_show_customer_phone, bill_show_table_number, bill_show_logo, receipt_logo, receipt_logo_raster, currency_display, number_digits, calendar, country_selected |
A currency that differs from the stored value returns 409 with error: "currency_change_requires_reset" plus current_currency and requested_currency. Currency changes go through the reset flow, not this route. |
GET |
/tax |
ROLE_ACCESS.allStaff |
none | - |
PUT |
/tax |
ROLE_ACCESS.ownerManager |
body: tax_registered, tax_registration_number, state_code, tax_scheme, country |
- |
GET |
/loyalty |
ROLE_ACCESS.allStaff |
none | - |
PUT |
/loyalty |
ROLE_ACCESS.ownerManager |
body: loyalty_enabled, global_cashback_percent |
- |
GET |
/discount |
ROLE_ACCESS.allStaff |
none | - |
PUT |
/discount |
ROLE_ACCESS.ownerManager |
body: discount_max_percentage, discount_max_amount, discount_mode, discount_requires_approval |
- |
GET |
/kds |
any authenticated role | none | KDS settings for the pre-login and display surfaces. Readable by any authenticated role. |
PUT |
/kds |
ROLE_ACCESS.ownerManager |
body: kds_default_view |
- |
GET |
/product-analytics |
settings.manage |
none | { level, choice_pending, email, diagnostics_enabled }: the PostHog sharing level (full, anonymous or off), whether the one-time question is due, the email full would link, and diagnostics transmission. |
PUT |
/product-analytics |
settings.manage |
body: level |
Stores the level and writes telemetry_enabled (false for off); 400 for any other value. Same response as GET. |
GET |
/pos-sort |
settings.view, orders.read, or orders.create + settings read limiter |
none | { category_sort, item_sort } for POS catalog display ordering. |
GET |
/customer-display |
settings.view, orders.read, or orders.create + settings read limiter |
none | { statuses, refresh_seconds, show_updated } for the order-status board; defaults when unset. |
PUT |
/customer-display |
settings.manage + settings write limiter |
body: statuses (non-empty subset of pending, preparing, ready), refresh_seconds (integer 2-60), show_updated (boolean) |
The stored config; 400 for any other shape. |
GET |
/customer-display/tokens |
settings.manage + settings read limiter |
none | { tokens }: { id, label, created_at, last_seen_at } for each active paired display. Never includes the token or its hash. |
POST |
/customer-display/tokens |
settings.manage + settings write limiter |
body: label (1-60 characters) |
201 with { id, label, created_at, last_seen_at, token }. The plaintext token appears only in this response. 400 for a bad label, 409 customer_display_token_limit at 20 active tokens. |
DELETE |
/customer-display/tokens/:id |
settings.manage + settings write limiter |
path: id |
{ success: true }; 404 when the token is unknown or already revoked. Takes effect on the next feed request. |
GET |
/order-numbering |
ROLE_ACCESS.allStaff |
none | - |
PUT |
/order-numbering |
ROLE_ACCESS.ownerManager |
body: order_number_prefix, order_number_include_date, order_number_reset_daily, invoice_number_prefix, invoice_number_include_period, invoice_number_reset_period, invoice_financial_year_start_month, invoice_financial_year_start_day |
- |
GET |
/cloud |
ROLE_ACCESS.ownerManager |
none | - |
PUT |
/cloud |
ROLE_ACCESS.ownerManager (owner for cloud_server_url, cloud_api_key, cloud_store_id) |
body: cloud_server_url, cloud_api_key, cloud_store_id, cloud_sync_enabled, cloud_orders_enabled, cloud_reports_enabled, cloud_command_polling_enabled |
403 when a non-owner supplies cloud_server_url, an unmasked cloud_api_key, or cloud_store_id. |
POST |
/cloud/register |
ROLE_ACCESS.ownerManager (owner for cloud_server_url) |
body: cloud_server_url |
403 when a non-owner supplies cloud_server_url. |
POST |
/cloud/test |
ROLE_ACCESS.ownerManager |
none | - |
GET |
/cloud/account |
ROLE_ACCESS.owner |
none | - |
PUT |
/cloud/account/preferences |
ROLE_ACCESS.owner |
body: product_updates, email_marketing |
Body product_updates and email_marketing. 409 when no cloud account is linked. |
POST |
/cloud/account/verification |
ROLE_ACCESS.owner |
none | - |
GET |
/cloud/delete-data/status |
ROLE_ACCESS.owner |
none | - |
POST |
/cloud/stop-all |
ROLE_ACCESS.owner |
none | - |
POST |
/cloud/delete-data |
ROLE_ACCESS.owner + Master PIN |
body: confirmation |
Body confirmation must equal DELETE CLOUD DATA. |
POST |
/cloud/delete-data/cancel |
ROLE_ACCESS.owner + Master PIN |
none | - |
GET |
/google-drive |
ROLE_ACCESS.owner |
none | - |
PUT |
/google-drive |
ROLE_ACCESS.owner |
body: frequency, retention_count, destination_folder_id, warning_acknowledged |
- |
GET |
/google-drive/destinations |
ROLE_ACCESS.owner |
none | - |
POST |
/google-drive/destinations |
ROLE_ACCESS.owner |
none | - |
POST |
/google-drive/connect |
ROLE_ACCESS.owner |
body: allow_switch, warning_acknowledged |
- |
POST |
/google-drive/disconnect |
ROLE_ACCESS.owner |
none | - |
POST |
/google-drive/backup-now |
ROLE_ACCESS.owner |
body: warning_acknowledged |
- |
GET |
/google-drive/jobs/:jobId |
ROLE_ACCESS.owner |
path: jobId |
- |
POST |
/google-drive/jobs/:jobId/cancel |
ROLE_ACCESS.owner |
path: jobId |
- |
GET |
/google-drive/backups |
ROLE_ACCESS.owner |
query: ?limit, ?cursor |
{ backups, nextCursor, hasMore }, newest first by created_at with an id tiebreak. limit accepts 1-100; omitting pagination returns all remote backups with nextCursor: null and hasMore: false. Invalid pagination parameters return 400. |
POST |
/google-drive/restore |
ROLE_ACCESS.owner + Master PIN |
body: confirmation, file_id, expected_sha256 |
Body confirmation must equal the drive restore confirmation phrase, plus file_id and optional expected_sha256. 202 with a job handle. |
GET |
/google-drive/qr-menu |
catalog.manage or google-drive.manage |
none | QR menu connection, publication, sharing, file, link, QR images, and saved-generator status. |
PUT |
/google-drive/qr-menu/preferences |
catalog.manage or google-drive.manage |
body: template, page size, description/modifier/photo toggles, and catalog visibility filters | Validated saved generator preferences and current QR menu status. |
POST |
/google-drive/qr-menu/generate |
catalog.manage or google-drive.manage |
body: html, pageSize, preferences |
Renders the bounded HTML in an isolated offscreen window, publishes the PDF, and returns current QR menu status. |
POST |
/google-drive/qr-menu/upload |
catalog.manage or google-drive.manage |
body: .pdf fileName, mimeType: "application/pdf", base64 data |
Validates the 15 MB limit and %PDF- header, replaces the stable Drive file, and returns current status. |
PUT |
/google-drive/qr-menu/sharing |
catalog.manage or google-drive.manage |
body: enabled (boolean) |
Adds or removes the file-only public reader permission without changing the file ID. |
GET |
/auto_lock_minutes |
any authenticated role + settings read limiter | none | { setting: { key: "auto_lock_minutes", value, updated_at } } (defaults to "0" when unset). Readable by any authenticated staff member independently of feature permissions. |
GET |
/ |
ROLE_ACCESS.allStaff |
none | All settings. |
GET |
/bill-templates |
ROLE_ACCESS.ownerManager |
none | - |
PUT |
/printing |
ROLE_ACCESS.ownerManager + settings write limiter |
none | Body is the whole printing settings object; rejected with 400 when it is not an object. |
GET |
/:key |
ROLE_ACCESS.allStaff + settings read limiter |
path: key |
One setting. Registered after every named route, so a named key resolves to its named handler. |
PUT |
/:key |
ROLE_ACCESS.ownerManager + settings write limiter |
path: key; body: value |
Body value. Only the allow-listed non-sensitive keys are accepted (including pos_category_sort, pos_item_sort, require_open_shift, one_shift_per_staff, scheduled_orders_enabled, auto_lock_minutes in 0, 1, 5, 15, or 30, and multi_counter_enabled which requires the owner role); sensitive keys need their named route. key: one_shift_per_staff with value: false returns 409 multiple_shifts_open while more than one shift is open. key: currency returns 409 currency_change_requires_reset on any value change. |
diagnostics_consent and diagnostics_transmission_enabled are owner/manager-writable Boolean
settings. Their delivery gates and consent synchronization are described in store-attributed
diagnostics, including how
telemetry_enabled affects anonymous diagnostic delivery. PostHog usage follows the sharing level
from /product-analytics, and the installation email is sent only at full; PostHog exceptions
follow diagnostics transmission at every level. See
PostHog.
Router: main/routes/tax-packs.ts. Full path: /api/tax-packs.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/ |
ROLE_ACCESS.ownerManager |
none | Installed tax packs. |
GET |
/audit |
ROLE_ACCESS.ownerManager |
query: ?limit |
Tax pack audit trail; ?limit bounds the page. |
GET |
/catalog |
ROLE_ACCESS.ownerManager |
none | Available packs and versions. |
GET |
/updates |
ROLE_ACCESS.ownerManager |
none | Installed packs with newer upstream versions. |
POST |
/ensure-country |
ROLE_ACCESS.ownerManager |
body: acknowledge_community_disclaimer |
Resolves the pack for the configured country. |
POST |
/manual-config |
ROLE_ACCESS.owner |
body: override |
Owner-only manual tax configuration. |
POST |
/catalog/install |
ROLE_ACCESS.owner |
body: pack_id, version |
- |
POST |
/test-calculation |
ROLE_ACCESS.ownerManager |
body: category_id, amount, tax_behavior |
- |
POST |
/overrides |
ROLE_ACCESS.owner |
body: entity_type, entity_id, category_id |
- |
PUT |
/overrides/:overrideId |
ROLE_ACCESS.owner |
path: overrideId; body: entity_type, entity_id, category_id |
- |
DELETE |
/overrides/:overrideId |
ROLE_ACCESS.owner |
path: overrideId |
- |
POST |
/:packId/versions/:versionId/activate |
ROLE_ACCESS.owner |
path: packId, versionId; body: acknowledge_community_disclaimer |
- |
POST |
/:packId/versions/:versionId/reinstall |
ROLE_ACCESS.owner |
path: packId, versionId |
- |
POST |
/:packId/rollback |
ROLE_ACCESS.owner |
path: packId |
Rolls a pack back to an earlier version. |
GET |
/:packId |
ROLE_ACCESS.ownerManager |
path: packId |
- |
Router: main/routes/kds.ts. Full path: /api/kds.
The role gate is a router.use layer below the /pairing mount, so it also applies to the /pairing reads.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/orders |
ROLE_ACCESS.kitchen + KDS-enabled |
query: ?station_id |
{ orders, counts }, same projection as the WebSocket initial_data frame. |
GET |
/pairing |
ROLE_ACCESS.kitchen + KDS-enabled (404) |
none | Paired display state for the caller's stations. |
POST |
/pairing |
ROLE_ACCESS.ownerManager |
body: station_id |
Registers a display against station_id. |
GET |
/display |
ROLE_ACCESS.kitchen + KDS-enabled |
query: ?station_id |
Display-mode view of the same snapshot. |
PATCH |
/items/:id/status |
ROLE_ACCESS.kitchen + KDS-enabled |
path: id; body: status, expected_status |
Body status in pending, preparing, ready, served, plus optional expected_status for a compare-and-set. Station and category scope are re-checked inside the write transaction, and active parent orders.status is reconciled (see Order lifecycle). |
Router: main/routes/kitchen.ts. Full path: /api/kitchen.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/orders |
ROLE_ACCESS.kitchen + KDS-enabled |
none | { orders, counts }. The :3001 REST equivalent of the KDS WebSocket snapshot. |
Router: main/routes/kds-info.ts. Full path: /api/kds-info.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/ |
any authenticated role | none | { mdns_url, ip_url, qr_url, qr_data_url, ips_data }. URLs point at the KDS port with no path suffix. ips_data is one { ip, url, qr_data } per local address. |
Router: main/routes/customer-display-feed.ts. Full path: /api/customer-display.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/feed |
a customer display token (Authorization: Bearer cdt_...); exempt from the JWT middleware by exact path and method |
query: ?cursor | { business_name, config, orders, next_cursor, has_more } where orders is { id, number, status } for up to 100 eligible orders per page (active orders matching the configured statuses, plus eligible KDS-unfinished completed orders with projected board status; see Order lifecycle), sorted oldest first within the page, with next_cursor paging older eligible orders. Carries no customer, staff, or item data. 400 for an invalid cursor; 401 for an unauthenticated token. Rate limited to 120 requests per minute. |
A display token is not a JWT, so it cannot authorize any other endpoint. Only its SHA-256 hash is stored in customer_display_tokens; the table is excluded from database export and cleared on restore.
Router: main/routes/pos-info.ts. Full path: /api/pos-info.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/ |
any authenticated role | none | { mdns_url, ip_url, qr_url, qr_data_url, ips_data } for the main API port. |
Router: main/routes/server-app-info.ts. Full path: /api/server-app-info.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/ |
any authenticated role | none | - |
Router: main/routes/more-apps.ts. Full path: /api/more-apps.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/ |
any authenticated role | none | - |
GET |
/revflo |
any authenticated role | none | - |
Router: main/routes/whatsapp.ts. Full path: /api/whatsapp.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/status |
ROLE_ACCESS.ownerManagerCashier |
none | Connection state. Deliberately omits the raw QR string; /qr renders it. |
POST |
/settings |
ROLE_ACCESS.ownerManager |
none | - |
GET |
/qr |
ROLE_ACCESS.ownerManager |
none | Rendered QR image for pairing. |
GET |
/pairing-code |
ROLE_ACCESS.ownerManager |
none | - |
POST |
/enable |
ROLE_ACCESS.ownerManager |
none | - |
POST |
/disable |
ROLE_ACCESS.ownerManager |
none | - |
POST |
/connect |
ROLE_ACCESS.ownerManager |
body: method, phone |
- |
POST |
/cancel-pairing |
ROLE_ACCESS.ownerManager |
none | Cancels an in-progress QR or pairing-code attempt without logging out or wiping existing registered credentials. |
POST |
/disconnect |
ROLE_ACCESS.ownerManager |
none | - |
POST |
/send |
whatsapp.use (+ reports.view on report kinds; + reports.daily-sales.export on daily_sales_report) |
body: bill_id, phone_e164, body, kind |
Body bill_id or phone_e164, body, kind (bill_receipt, manual_reply, auto_followup, z_report, or daily_sales_report). |
GET |
/automation |
whatsapp.use |
none | { config, placeholders } for message templates, trigger rules, and safety settings. |
PUT |
/automation |
whatsapp.manage (modifying z_report_day_close requires role owner and reports.view) |
body: templates, rules, safety |
{ ok: true, config } after merging omitted sections and rules with current config, validating any z_report_day_close recipient in E.164 format (400 invalid_phone or phone_required; 403 owner_required or PERMISSION_DENIED when a non-owner or a user without reports.view modifies z_report_day_close, while saving other automation settings preserves the stored z_report_day_close rule), normalizing, and saving whatsapp_automation_config. |
POST |
/automation/broadcast |
ROLE_ACCESS.ownerManager |
body: template_id, min_visits, inactive_days, min_points, limit |
{ ok: true, matched, attempted, sent, skipped, errors } for up to limit (1-50, default 25) matching active customers; 503 when disconnected. |
POST |
/automation/run-sweep |
ROLE_ACCESS.ownerManager |
none | { ok: true, feedbackSent, winbackSent } from an immediate scheduled-rule sweep. |
GET |
/messages |
whatsapp.use |
query: ?limit, ?offset, ?direction, ?status, ?phone, ?bill_id |
Message history with trigger_rule attribution; kind: "z_report" entries are omitted unless the caller also has reports.view, and kind: "daily_sales_report" entries are omitted unless the caller also has both reports.view and reports.daily-sales.export. These permission filters are applied before pagination. |
POST |
/messages/:messageId/retry |
whatsapp.use |
path: messageId |
{ ok: true, messageId } after retrying a failed outbound message in place (403 unless the caller also has reports.view on report kinds, plus reports.daily-sales.export when kind: "daily_sales_report"); 503 when disconnected, in cooldown, or in Offline mode; 404 when not found; 400 when invalid, not failed, or blocked by safety checks. |
GET |
/inbox |
ROLE_ACCESS.ownerManager |
query: ?limit, ?offset |
- |
POST |
/inbox/:messageId/reply |
ROLE_ACCESS.ownerManager |
path: messageId; body: body |
Replies to an inbound message. |
GET |
/blocklist |
ROLE_ACCESS.ownerManager |
none | - |
POST |
/blocklist |
ROLE_ACCESS.ownerManager |
body: phone_e164, reason |
Body phone_e164 and reason. |
DELETE |
/blocklist/:phone |
ROLE_ACCESS.ownerManager |
path: phone |
Removes a blocked number. |
Template rendering, automated trigger rules, and anti-ban safety controls are described in WhatsApp receipt delivery.
Router: main/routes/support-ticket.ts. Full path: /api/support-ticket.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/profile |
ROLE_ACCESS.allStaff |
none | - |
GET |
/diagnostics-preview |
ROLE_ACCESS.allStaff |
query: ?category |
- |
GET |
/:clientTicketId/status |
ROLE_ACCESS.allStaff |
path: clientTicketId |
- |
POST |
/ |
ROLE_ACCESS.allStaff |
body: support-ticket fields; optional operation_failure |
Queues the ticket; the structured failure context is validated and normalized as described in cloud integrations. |
GET |
/pre-login/profile |
any authenticated role + pre-login limiter | none | Unauthenticated tenant profile for the login-screen ticket form. Body limit 300kb on this subtree. |
GET |
/pre-login/:clientTicketId/status |
any authenticated role + pre-login limiter | path: clientTicketId |
Unauthenticated ticket status lookup; clientTicketId must be a UUID. |
POST |
/pre-login |
any authenticated role + pre-login limiter | none | Unauthenticated ticket creation from the login screen. Body limit 300kb on this subtree. |
Router: main/routes/diagnostics.ts. Full path: /api/diagnostics.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
POST |
/event |
support.use + diagnostics write limiter |
body: legacy event fields | Local-only legacy event intake; free-form messages are never automatically transmitted. |
POST |
/capture |
authenticated API caller + diagnostics write limiter | body: capture_point, operation_id, outcome, optional allowlisted facts |
202 with the server-registered reason, operation, stage, classification, outcome, and correlation ID; rejects unregistered points or invalid fields. |
GET |
/health |
support.use |
none | Safe consent, transport, queue, rejection, and delivery-gap state without report bytes or error text. |
GET |
/recent |
support.use |
query: ?limit (clamped to 200) |
{ failures: [ ... ] }, locally captured failures, newest first. |
DELETE |
/recent |
settings.manage |
none | { removed }, the number of local failures dropped. This clears local evidence only. |
GET |
/support-bundle |
support.use |
query: ?limit (clamped to 200), ?category |
{ bundle: { system, recent_failures } } for copy-for-support. Never carries the raw log tail. |
Router: main/routes/database.ts. Full path: /api/db.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/export |
ROLE_ACCESS.owner |
none | Full database snapshot as a downloadable JSON attachment. Excludes virtual tables, FTS shadow tables, and virtual generated columns. |
POST |
/import |
ROLE_ACCESS.owner + Master PIN when overwrite or a schema-version mismatch |
body: data, overwrite |
Body data plus overwrite. Master PIN is required when overwrite is set or the snapshot's schema_version (or schemaVersion) differs from the local one. Omitted addons.stock_quantity values default to 0 before inventory-ledger validation, and product_variants, addons, and order_item_addons insert only defined per-row columns so omitted legacy stock fields fall back to their SQLite defaults. Re-aligns sequence counters, marks sequence repair complete, and rebuilds search indexes before commit. Non-application/virtual tables and virtual generated columns in the import payload are ignored, and inventory movement rows are applied in durable append order (id ASC). |
POST |
/backup |
ROLE_ACCESS.owner + Master PIN |
none | - |
GET |
/download |
ROLE_ACCESS.owner + Master PIN |
none | - |
GET |
/tables |
ROLE_ACCESS.owner |
none | { tables: [ ... ] }, every non-internal SQLite table name. |
POST |
/restore |
ROLE_ACCESS.owner (database.manage) |
body: confirmation, selection_token |
Restores a backup file the operator picked in the native dialog. confirmation must equal RESTORE BACKUP, and selection_token must be the single-use token the main process issued for that dialog; a path with no outstanding selection is refused with 400, so the route never accepts a path the renderer invented. The token is validated before checking that the file exists (404 when missing) and carries schema-version metadata (400 when missing), and consumed only once those preflight checks pass. Replaces the live database with the selected backup and migrates supported older backups forward to the current schema. Returns 422 with the reason when the mechanism refuses the file. The pre-restore data is retained as a listed flo-backup-*-pre-restore-*.db copy. |
Router: main/routes/database-tools.ts. Full path: /api/db-tools.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/health-check |
ROLE_ACCESS.owner |
none | - |
POST |
/apply-safe-fixes |
ROLE_ACCESS.owner |
none | - |
GET |
/backups |
ROLE_ACCESS.owner |
query: ?limit, ?cursor |
{ backups, nextCursor, hasMore }, newest first by createdAt with a fileName tiebreak. limit accepts 1-100; omitting pagination returns all managed backups with nextCursor: null and hasMore: false. Invalid pagination parameters return 400. |
POST |
/backups/:fileName/delete |
ROLE_ACCESS.owner + Master PIN |
path: fileName |
- |
GET |
/local-backup |
ROLE_ACCESS.owner |
none | { enabled, directory, retention, lastBackupAt } for the daily local backup. |
PUT |
/local-backup |
ROLE_ACCESS.owner |
body: enabled (boolean) |
Turns the daily local backup on or off and returns the same status. |
GET |
/master-pin/status |
ROLE_ACCESS.owner |
none | - |
POST |
/master-pin/reset |
ROLE_ACCESS.owner (database.manage) + current Master PIN when one is configured |
body: pin, confirm_pin, and master_pin when a PIN is already configured |
First-time setup requires no master_pin. |
GET |
/currency-reset-impact |
ROLE_ACCESS.owner |
none | Active currency plus the invoice, order, refund, customer, product, and add-on counts the destructive warning uses. |
POST |
/currency-reset |
ROLE_ACCESS.owner + Master PIN |
body: currency |
Also needs current_currency and confirmation_phrase of the form CHANGE TO <CODE>. Creates a recovery backup, recreates the local database, preserves local_backup_enabled and the sanitized menu catalog with monetary fields zeroed, and returns { success, currency, backupPath, cleanupPending, localBackupFailed }; localBackupFailed is true when the daily local backup is on but the pre-reset copy could not be kept there. The active session becomes invalid. |
POST |
/initialize |
ROLE_ACCESS.owner + Master PIN |
body: confirmation_phrase |
A stale Drive restore boundary classified as ambiguous by recovery may be discarded only by this owner-authorized reset with the exact INITIALIZE phrase (stored Drive credentials are dropped); an active in-process reset still conflicts. Returns { success, backupPath, cleanupPending, localBackupFailed }; localBackupFailed is true when the daily local backup is on but the pre-reset copy could not be kept there, which never fails the committed reset. |
Router: main/routes/reports.ts. Full path: /api/reports.
Report date parameters are tenant business dates. Each YYYY-MM-DD value is interpreted in the
store's configured timezone and business-day start time, and omitted dates default to the tenant's
current business date. See business time for the storage and
day-boundary rules.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/daily-stats |
ROLE_ACCESS.ownerManager |
none | Today only; no date parameter. |
GET |
/summary |
ROLE_ACCESS.ownerManager |
query: ?date |
Summary for one business date. Order, bill, collection, and payment-method totals exclude cancelled orders; ordersByStatus counts all statuses. |
GET |
/financial-summary |
ROLE_ACCESS.owner |
query: ?start_date, ?end_date |
Owner-only collection summary, refund audit, and period profitability summary (profitability) for a range. Refunds are attributed to the original bill payment date, so gross, refund, net, and payment-method totals reconcile over the selected period. |
GET |
/tax-components |
ROLE_ACCESS.ownerManager |
query: ?start_date, ?end_date |
- |
GET |
/price-exceptions |
reports.view |
query: ?start_date, ?end_date, ?shift_id |
{ priceExceptions: { startDate, endDate, shiftId, shifts, count, complimentaryCount, totalDifference, rows } }. Each row is a non-cancelled line with a manual price override whose override time falls in the range: order, item, menu price, billed price, difference, reason, who changed it, and when. shift_id narrows the list to that cash shift's open-to-close window (404 when unknown); shifts lists up to 20 shifts overlapping the range. |
GET |
/sales |
ROLE_ACCESS.ownerManager |
query: ?start_date, ?end_date |
Daily order and sales series, paid payment-method totals, and order-type totals over a business-date range, excluding cancelled orders. |
GET |
/topProducts |
ROLE_ACCESS.ownerManager |
query: ?start_date, ?end_date, ?limit |
Top products by quantity over a business-date range, excluding cancelled orders and cancelled, voided, void-adjustment, or refunded items. When the caller also holds reports.financial.view, each product row includes ex-tax profitability metrics and the response includes period profitability. |
GET |
/recentOrders |
ROLE_ACCESS.ownerManager |
query: ?limit, ?date, ?start_date, ?end_date |
- |
GET |
/tables |
ROLE_ACCESS.ownerManager |
none | Table-level utilisation and non-cancelled order totals for the current business date; no date parameter. |
GET |
/insights |
ROLE_ACCESS.ownerManager |
query: ?days, ?start_date, ?end_date |
- |
GET |
/x-report |
ROLE_ACCESS.ownerManager |
query: ?date, optional ?session_id |
Live day or shift report. Recomputed on every read. When ?session_id is omitted, aggregates the full business date across all shifts and includes openShifts and blockingOpenShift (session: null). When ?session_id is provided, scopes the X report (grossCollected, refunded, netCollected, paymentMethods, staffSales, taxComponents, cashMovements, openingFloatCents, expectedCashCents, alreadyClosed, zNumber, openShifts, blockingOpenShift, and session) to that shift and its business date (400 session_id_invalid for a non-positive integer; 404 session_unavailable when not found; 403 shift_access_forbidden when a non-owner/non-manager requests another staff member's shift). openingFloatCents is reported separately and excluded from expectedCashCents; the stored Z adds the opening float at close. |
GET |
/z-report |
ROLE_ACCESS.ownerManager |
query: ?date |
The immutable close snapshot for one business date. 404 with { error: "Day not closed", alreadyClosed: false, businessDate } when no close row exists. The zReport object carries integer-minor-unit *_cents fields, payment_methods, staff_sales, tax_components, and cash_movements arrays, plus z_number, closed_by, closed_by_name, notes, created_at, business_date, period_start, and period_end. variance_cents is counted_cash_cents - expected_cash_cents and may be negative. |
GET |
/x-report/export |
reports.view (ROLE_ACCESS.ownerManager by default) |
query: ?date, ?format (required: csv, xlsx, or html), optional ?session_id |
Exports a live X-report snapshot for one business date or, when ?session_id is provided, for that shift (Shift #<id> - <staff> in the Summary rows and format=html header, with the same 400 session_id_invalid, 404 session_unavailable, and 403 shift_access_forbidden checks as GET /x-report; print-styled HTML for PDF/browser print when format=html). Returns 400 with { error: "format must be xlsx, csv, or html" } when format is missing or invalid. Sets Content-Disposition to attachment; filename="x-report-<date>.<format>" (or attachment; filename="x-report-<shiftDate>-shift-<id>.<format>" when ?session_id is provided). |
GET |
/z-report/export |
reports.view (ROLE_ACCESS.ownerManager by default) |
query: ?date, ?format (required: csv, xlsx, or html) |
Exports the stored Z-report snapshot for a closed business date (or print-styled HTML for PDF/browser print when format=html). Returns 400 with { error: "format must be xlsx, csv, or html" } when format is missing or invalid, and 404 with { error: "Day not closed" } when the day has not been closed. Sets Content-Disposition to attachment; filename="z-report-Z<z_number>-<date>.<format>". |
POST |
/cash-closes/:id/whatsapp |
reports.view + whatsapp.use |
path: id; body: phone_e164, body (non-empty, max 4096 chars) |
Sends the submitted report body and returns { success: true, messageId }; when WhatsApp is disconnected, returns { fallback: true, reason: "not_connected" } so the client can open WhatsApp for sharing. Invalid input returns 400; an unknown day close returns 404. |
POST |
/daily-sales/whatsapp |
reports.view + reports.daily-sales.export + whatsapp.use |
body: phone_e164, optional date or from + to |
Formats a localized plain-text daily sales summary (up to 93 business days, bounded to 4096 characters while preserving all financial totals) from buildDailySalesExportDataset and sends it with kind: "daily_sales_report". Returns { success: true, deliveryStatus, messageId, body } where deliveryStatus is "sent" when acknowledged by WhatsApp within the brief ACK wait window or "submitted" when handed to the socket and still awaiting server ACK; when WhatsApp is disconnected, returns { fallback: true, reason: "not_connected", body } for manual sharing. Returns 400 on invalid phone, invalid range, disabled WhatsApp, Offline mode, blocklist, content, or send rejection, 409 when regional settings are not configured, and 429 when rate-limited or in cooldown. |
GET |
/daily-sales/export |
reports.daily-sales.export (owner by default) |
query: ?date, ?from, ?to, ?format, ?part |
See GET /api/reports/daily-sales/export. |
GET |
/daily-invoices/export |
reports.daily-sales.export (owner by default) |
query: ?date, ?from, ?to, ?format |
See GET /api/reports/daily-invoices/export. |
GET |
/customer-sales/export |
reports.customer-sales.export (owner by default) |
query: ?start_date, ?end_date, ?format |
One row per item sold with customer details, for a business-date range of at most 366 days. format is xlsx or csv; invalid format, reversed range, or oversized range returns 400. See customer sales export. |
Owner-only daily sales export for one tenant business date (?date=YYYY-MM-DD, defaults to the
current business date) or an inclusive business-date range (?from=YYYY-MM-DD&to=YYYY-MM-DD, up to
93 calendar days). Returns an XLSX workbook (Summary, Daily, Taxes, Items, and Payments
sheets), one part of a five-file CSV set (summary, daily, taxes, items, payments), or a
print-styled HTML document (format=html) used for desktop PDF export and browser print. Accounting
and row grouping live entirely in buildDailySalesExportDataset; the endpoint only validates,
serializes, and sets headers. See daily sales export for the full
reconciliation contract, the summary metric field order, and the worksheet/CSV columns.
| Param | Values | Notes |
|---|---|---|
date |
YYYY-MM-DD |
Single tenant business date; defaults to the current business date when from/to are omitted. |
from |
YYYY-MM-DD |
Inclusive start business date (must be paired with to, cannot be combined with date). |
to |
YYYY-MM-DD |
Inclusive end business date (from <= to, at most 93 calendar days inclusive). |
format |
xlsx, csv, or html |
Required. Any other value returns 400. |
part |
summary, daily, taxes, items, or payments |
Required when format=csv; ignored for xlsx and html. Missing or invalid on CSV returns 400. |
format=xlsxreturns200withContent-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheetandContent-Disposition: attachment; filename="daily-sales-YYYY-MM-DD.xlsx"(ordaily-sales-YYYY-MM-DD_to_YYYY-MM-DD.xlsxfor multi-day ranges).format=csvreturns200withContent-Type: text/csv; charset=utf-8andContent-Disposition: attachment; filename="daily-sales-<slug>-<part>.csv"for the requestedpart(summaryhas headermetric,value;daily,taxes,items, andpaymentsuse the column headers and encoding rules in daily sales export).format=htmlreturns200withContent-Type: text/html; charset=utf-8andContent-Disposition: attachment; filename="daily-sales-YYYY-MM-DD.html"(ordaily-sales-YYYY-MM-DD_to_YYYY-MM-DD.html); see daily sales export.
Errors: 400 for an invalid format, a CSV without part, mixed date and from/to arguments, an incomplete range, an invalid calendar date, to < from, a range exceeding 93 days, or an html export exceeding the 15 MB PDF size limit; 401 unauthenticated; 403 non-owner; 409 for a concurrent-write conflict; 500 otherwise.
Compiled daily invoices export (reports.daily-sales.export, owner by default) for one tenant
business date (?date=YYYY-MM-DD, defaults to the current business date) or an inclusive
business-date range (?from=YYYY-MM-DD&to=YYYY-MM-DD, up to 366 calendar days). Returns a
print-styled A4 portrait HTML document (format=html, default when omitted) formatted with the
store's regional locale and preferences (resolveRegionalSnapshot), used by exportReportAsPdf for
desktop PDF export (invoices-YYYY-MM-DD.pdf or invoices-YYYY-MM-DD_to_YYYY-MM-DD.pdf) and
browser print fallback.
Page 1 renders a bounded fiscal summary cover page (store legal name, tax registration ID with the
active country tax pack's registrationNumberLabel or country profile taxIdLabel, address,
business date/range, timezone, business-day start, currency, total invoices count, gross sales, net
base, tax collected, in-period refunds, net revenue, the VAT/tax summary table by rate, refund scope
reconciliation across in-period, prior-bill, outside-period, and lifetime totals, and summary
reconciliation metrics) reused from buildDailySalesExportDataset (see
daily sales export). When the window has issued invoices, page 2+ renders
all bills paid in the business-day window (payment_status IN ('paid', 'partially_refunded', 'refunded')) ordered by ascending business date and numeric bill_number sequence (paid_at,
id, and bill_number tie-breakers), with page-break-inside: avoid; break-inside: avoid; on each
.invoice-card, [PAID], [PARTIALLY REFUNDED], and [REFUNDED] status badges, non-excluded line
items (unit price, pre-tax subtotal, and after-tax total), itemised charges (including custom fees
from charges_breakdown alongside standard charges, with legacy charge fallbacks), tax components,
payment details, and [In Period] / [Outside Period] refund details. A trailing page-broken appendix renders the
per-day tax breakdown table and any prior-period invoices refunded during the selected period.
- Returns
200withContent-Type: text/html; charset=utf-8andContent-Disposition: attachment; filename="invoices-YYYY-MM-DD.html"(orinvoices-YYYY-MM-DD_to_YYYY-MM-DD.html). - Errors:
400for an unsupportedformat, mixeddateandfrom/toarguments, an incomplete or reversed range, an invalid calendar date, a range exceeding 366 days, more than 5,000 matching invoices (MAX_DAILY_INVOICES_COUNT), or an HTML export exceeding the 15 MB PDF size limit;401unauthenticated;403when lackingreports.daily-sales.export;409for unconfigured regional settings or a concurrent-write conflict;500otherwise.
These seven are declared on app inside registerRoutes in main/routes/index.ts, not on a
router. Every path below is absolute; there is no mount prefix.
Router: declared inline in main/routes/index.ts.
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
POST |
/api/tax/preview |
pos.use, orders.create, or kitchen.use |
body: items (each may carry price_override: { price } to quote an owner-set price), customer_id, packaging_charge, delivery_charge, service_charge, discount_type, discount_value, online_platform; optional new-order context: order_type (required when any context field is supplied), waived_charge_ids, opted_in_charge_ids |
400 when items is missing or empty, the supplied order type is missing or invalid, either supplied charge-ID value is neither null nor an array of strings, or an item names a variant that is unknown, foreign, or inactive (including a product with active variants that names none). Returns the tax rollup for a hypothetical basket without persisting anything. An item's variant_id follows the order rules and is priced from the catalog, so the quote matches the order it becomes. With the optional context, the server resolves configured charges for that order type and returns them as summary.charges_breakdown in the payable total; without it, the legacy manual charge inputs are unchanged. Any one of the three sale permissions admits the caller, and the POS checkout modal calls this on every cart change. |
GET |
/api/tax/categories |
ROLE_ACCESS.ownerManager |
none | { pack_id, country, categories, default_category_id, configuration_ready, unclassified_category_id }. categories is empty until the pack's configuration is complete. |
GET |
/api/mobile/pairing-code |
ROLE_ACCESS.owner |
none | { pairing_code, expires_at, qr_data_url }. Returns the cached code when one is live, otherwise generates one. 409 when the store is not yet claimed in FloAdmin; 502 for any other cloud failure. |
POST |
/api/mobile/rotate-code |
ROLE_ACCESS.owner |
none | Same shape as the read, and every already paired RevFlo device is disconnected. |
GET |
/api/mobile/devices |
ROLE_ACCESS.owner |
none | { devices: [ ... ] }. 502 when FloAdmin is unreachable. |
GET |
/api/customers-search |
customers.view (ROLE_ACCESS.sales by default; grants are configurable) |
query: q |
Flat array of at most 20 active customers, each with a wallet_balance. q shorter than 2 characters returns []. |
GET |
/api/crm/lookup |
ROLE_ACCESS.sales |
query: phone, country_code |
{ found, customer }. 400 when phone is missing. The number is normalized to E.164 against the tenant country before lookup. |
The KDS server reads the same database as the main API. Its whole surface:
| Method | Path | Authorization | Parameters | Response |
|---|---|---|---|---|
GET |
/api/health |
none | none | { status, service, version, timestamp } |
GET |
/api/kds/info |
none | none | { language, country, kds_default_view } for the pre-login display. 404 when KDS is disabled, so the surface is not advertised to the LAN. |
POST |
/api/auth/login |
none, loginAuthRateLimit + shared failed-login limiter |
body: email, password |
{ access_token, user: { id, name, email, role, category_ids, station_ids, station_assignments_configured } }. 24-hour token. 401 on bad credentials with attempts_remaining; 429 after 5 failed attempts across local login listeners; 403 for a role outside kitchen. |
POST |
/api/auth/logout |
bearer token, not role-gated | none | { message }. Revokes the token. |
GET |
/api/auth/me |
ROLE_ACCESS.kitchen |
none | { user: { id, name, email, role } } |
GET |
/api/kds/orders |
ROLE_ACCESS.kitchen |
none | { orders, counts }, the same projection and the same category and station narrowing as the WebSocket initial_data frame. 403 when KDS is disabled or the caller has no active station. |
PATCH |
/api/kds/items/:id/status |
ROLE_ACCESS.kitchen |
path: id; body: status, expected_status |
{ success: true }. Reconciles active parent orders.status in the same transaction (see Order lifecycle). 400 for a terminal or voided item, 403 for a station or category the caller does not cover, 409 when the item belongs to an unreleased scheduled order or the row changed before the write. |
GET |
/api/categories |
ROLE_ACCESS.kitchen |
none | { categories: [ ... ] } narrowed to the caller's station and category scope. |
Plus three static handlers: express.static over the frontend export, GET / redirecting to
/kds-standalone, and a GET /*splat fallback that serves the standalone KDS page.
PATCH /api/kds/items/:id/status re-verifies the caller's active status, token revocation, and token
staleness inside its IMMEDIATE transaction, so a session revoked between the middleware and the
write cannot land a status change.
The KDS server's requireAuth reads the user row on every request and does not cache it, unlike the
main API's 30-second cache. See
three separate token-verification middlewares.
The Server App implements no business logic. It authenticates the caller, checks the role against
ROLE_ACCESS.serverApp (server, manager, owner), and forwards an explicit allowlist to
http://127.0.0.1:<main port>/api/.... System overview
owns that boundary model; this section is the allowlist.
Twenty-one routes in total, thirteen of which forward:
| Method | Path | Forwards to |
|---|---|---|
GET |
/api/categories |
GET /api/categories |
GET |
/api/products |
GET /api/products |
GET |
/api/products/:id/image |
GET /api/products/:id/image |
GET |
/api/tables |
GET /api/tables |
POST |
/api/tables/:id/move-order |
POST /api/tables/:id/move-order |
GET |
/api/orders |
GET /api/orders |
POST |
/api/orders |
POST /api/orders |
POST |
/api/orders/:id/items |
POST /api/orders/:id/items |
GET |
/api/customers-search |
GET /api/customers-search |
GET |
/api/crm/lookup |
GET /api/crm/lookup |
POST |
/api/customers |
POST /api/customers |
POST |
/api/printers/print-kot |
POST /api/printers/print-kot |
POST |
/api/printers/print-bill |
POST /api/printers/print-bill (403 BILL_PRINTING_DISABLED when server_app_bill_printing_enabled is off) |
Adding a route to 3001 does not expose it on 3003. A path becomes reachable from the Server App
only when a forwarding route is added to main/server-app.ts.
The seven routes that are not forwards:
| Method | Path | Authorization | Response |
|---|---|---|---|
GET |
/api/health |
none | { status, service, version, timestamp } |
GET |
/api/server-app/info |
none | { language, country, currency, currency_symbol, currency_position, currency_fraction_digits, kds_enabled, category_sort, item_sort }. 404 when the feature is disabled; 409 with error: "regional_not_configured" when the regional snapshot cannot be resolved. |
POST |
/api/auth/login |
none, loginAuthRateLimit + shared failed-login limiter |
Body email, password, remember_me. { access_token, user: { id, name, email, role } }; 24 hours, or 10 days with remember_me. 401 on bad credentials with attempts_remaining; 429 after 5 failed attempts across local login listeners; 403 for a role outside ROLE_ACCESS.serverApp. |
GET |
/api/auth/me |
ROLE_ACCESS.serverApp |
{ user: { id, name, email, role } } |
POST |
/api/auth/logout |
ROLE_ACCESS.serverApp |
{ success: true }, revoking the token |
GET |
/ |
none | Redirect to /server-standalone |
GET |
/*splat |
none | Static bundle, falling back to the /server-standalone page |
When the frontend export is missing, the GET / redirect is replaced by a placeholder page instead,
so GET / is two mutually exclusive registrations rather than two routes.
requireServerAppAuth is applied to every route except /api/health, /api/server-app/info, and
/api/products/:id/image, and it runs the feature check before the token check. Behaviour by
condition:
- Feature disabled:
404with{ error: "Not found" }, so the surface is not advertised. - Main API unreachable:
502with{ error: "Could not reach the local POS API" }from the forwarding layer. - Request aborted by shutdown:
503with an empty body.
The forwarder relays the upstream status, content type, and body verbatim, and passes the method,
query string, Authorization header, Idempotency-Key, and JSON body upstream. Three limiters on
3003 run before authentication: 150 requests per minute across /api, 150 per minute on
POST /api/customers, and 30 per minute shared by the two print forwarders. Each returns 429 when
exceeded.
One connection contract, implemented once in
main/services/kds.ts and served on both :3001 and :3002.
Open ws://<host>:<port>/kds on 3001 or 3002. The upgrade handler answers before the upgrade
completes:
| Condition | Response |
|---|---|
Path is not /kds |
404, socket destroyed |
| Database maintenance in progress | 503, socket destroyed |
KDS disabled (kds_enabled off) |
404, socket destroyed |
| Otherwise | upgraded, and a connected frame is sent immediately |
- On connect the server sends
connected. - The client sends
authwith a bearer JWT. A display must authenticate withinKDS_AUTH_TIMEOUT_MS, 5 seconds; otherwise the server sendsauth_errorand closes with code1008. - On success the server sends
auth_success, then sendsinitial_dataas a separate frame.
auth_success carries a user object and nothing else:
{
"type": "auth_success",
"user": {
"id": "chef-1",
"name": "Chef One",
"role": "chef",
"categoryIds": ["cat-1", "cat-2"],
"stationIds": ["station-1"]
}
}It does not carry orders or counts. A client that waits for the order list on this frame waits
forever.
| Type | Carries | When |
|---|---|---|
connected |
message, timestamp |
Immediately on connect |
auth_success |
user (see above) |
After a successful auth |
initial_data |
orders, counts |
After auth_success, and on every subsequent broadcast |
status_updated |
order_item_id, status |
After the requesting client's own status_update succeeds |
pong |
timestamp |
In reply to a ping |
error |
message |
A rejected client message or a rejected status update |
auth_error |
message |
Before the server closes an unauthenticated or revoked session |
The message switch in handleMessage accepts exactly three types. Anything else gets
{ "type": "error", "message": "Unknown message type" }.
| Type | Fields | Notes |
|---|---|---|
auth |
token |
Only accepted before authentication. Any other type before auth closes the connection. |
status_update |
order_item_id, status, optional expected_status |
status must be one of pending, preparing, ready, served; reconciles active parent orders.status in the same transaction (see Order lifecycle). |
ping |
none | Answered with pong. |
expected_status turns the write into a compare-and-set. Without it the update excludes the
terminal statuses in its WHERE clause. With it, a row whose status has changed returns
Item status changed; refresh and try again.
There is no incremental order event. notifyKdsUpdate and broadcastOrderUpdate both call
sendActiveOrders, which recomputes the caller's visible orders and counts and sends the result as a
complete initial_data. A client must replace its board wholesale on every initial_data frame. The
status_updated frame is an acknowledgement for the requesting client only; other displays learn
about the change through their own initial_data.
Broadcasts are coalesced: a burst of notifyKdsUpdate calls within one microtask produces one
snapshot per client.
error message |
Cause |
|---|---|
Unknown message type |
A client frame the switch does not accept |
order_item_id and status required |
A status_update missing either field |
Invalid status. Use: pending, preparing, ready, served |
A status_update outside the allowed set |
Invalid expected status. Use: ... |
A malformed expected_status |
Item not found |
Unknown order_item_id |
This item has been voided and can no longer be updated |
The item was voided |
This bill adjustment cannot be updated from KDS |
The item is a void_adjustment row |
This terminal item cannot be updated from KDS |
completed, cancelled, or refunded |
Not authorized to update this station |
Outside the caller's station scope |
Not authorized to update this item |
Outside the caller's category scope |
Item status changed; refresh and try again |
The compare-and-set lost |
Scheduled order has not been released to the kitchen yet |
The item belongs to an unreleased scheduled order |
Could not update item status |
The write threw |
auth_error precedes a close on: Authentication required (no auth in time, or any non-auth
frame before authenticating), Invalid or revoked token, User not found, Only kitchen staff can access KDS, No active kitchen station is assigned to this user, and Could not load station permissions. The 30-second heartbeat also closes a session with Session expired or revoked when
the token or role stops being valid, and with KDS is disabled or Database maintenance in progress
when the store setting changes under a live connection.
- 100 connected clients, and at most 25 of them unauthenticated. Over either cap the server closes
with
1013. - A client whose
bufferedAmountexceeds 1 MB at send time is closed with1013as too slow. - The server sends a WebSocket ping every 30 seconds and terminates a client that misses one.
- A client is re-sent a fresh
initial_datawithout any change of its own when its category or station assignment changes, or when the voided-item expiry marker moves.
Two independent narrowing axes decide what a chef sees. Both are resolved at connect time and
re-resolved on every broadcast, and both are re-checked inside the write transaction for a
status_update.
Categories. categoryIds is the empty list for owner and manager, which means unrestricted,
and the parsed users.category_ids for everyone else. When the list is non-empty, an item is
visible only if its product's category is in the list.
Stations. stationIds comes from getUserKdsStationIds. When a user has station assignments
configured but none active, authentication is refused with No active kitchen station is assigned to this user rather than silently showing an empty board. getKdsStationRoutingScope resolves each
assigned station into a set of categories, plus a separate set for orders on tables with no
kitchen_station_id, and reports whether any station is unrestricted.
An order is in scope when its table's kitchen_station_id is one of the caller's stations, or when
the table has no station and the order contains an item in a tableless routing category, or when the
caller has an unrestricted station. An item is then in scope when isKdsStationItemAllowed passes
for that station and category set, and the category filter above also passes. The counts map is
computed with the same scope, so it always matches the orders it accompanies.
Three statuses are hidden from every KDS surface: void_adjustment rows (the mirrored line a void
writes, which is a bill adjustment rather than a kitchen item), and completed, cancelled, and
refunded items. A voided item stays visible for KDS_VOIDED_ITEM_VISIBILITY_MS after its
voided_at timestamp and then ages off the board; the 30-second heartbeat notices the change and
pushes a fresh snapshot.
projectKdsOrder and projectKdsItem receive a restrictedPayload flag, true for chef and for
anyone with a non-empty category or station scope, false for an unrestricted owner or manager.
Unrestricted sessions receive the fuller row.
The same two axes are applied by GET /api/kds/orders, GET /api/kitchen/orders, and
PATCH /api/kds/items/:id/status on :3002. See
KDS station and category narrowing
for the authorization framing.
Refund eligibility, approval tiers, the Staff Approval PIN rules, and how refund amounts are stored belong to product invariants. This page documents only the two routes.
| Subject | Page |
|---|---|
| Which processes run, on which ports, and the port retry ladder | System overview |
| Token issuance, verification, revocation, staleness, Master PIN, audit attribution | Authentication and authorization |
| What each role sees in the interface | Roles and permissions |
| Refund approval rules and refund amount storage | Product invariants |
| Daily sales export accounting and reconciliation contract | Daily sales export |
| Merchant print template payload and offline transfer format | Merchant print templates |
| Business dates, day boundaries, and timestamp storage | Business time |
| Printer drivers, ESC/POS, and print routing | Printing architecture |
| Device Master PIN second factor | main/middleware/master-pin.ts |
This page lists 311 HTTP route registrations across 310 distinct rows, and one WebSocket contract:
282on the main API: 274 router registrations, 7 inline registrations inmain/routes/index.ts, andGET /api/healthinmain/server.ts.8REST routes on the KDS server, plus 3 static handlers.21route registrations on the Server App, of which 13 forward to the main API. Two of those 21 are mutually exclusiveGET /handlers under one path, which is why 21 registrations render as 20 rows.
Every one of the 310 rows is listed with its method, path, authorization gate, and the parameters
its handler reads. The Response column carries a note for 174 of them: 146 main-API endpoints,
all 8 KDS-server routes, and the 20 Server App routes. The remaining 146 main-API endpoints are
listed with method, path, gate, and parameters only, and this page asserts nothing about what they
return. Those rows are complete as a route inventory and silent as a contract; the handler in
main/routes/ is authoritative for them. A - in the Response column means that, not that the
endpoint does nothing.
The route inventory is maintained by hand against the route files, not generated. The role groups, parameters, and rate limiters in the tables were read out of the route registrations themselves, but a route added after this page was last checked will not appear until someone adds it here.