Skip to content

Latest commit

 

History

History
1249 lines (1010 loc) · 414 KB

api.md

File metadata and controls

1249 lines (1010 loc) · 414 KB

API reference

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.

How to read this page

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:3001 or http://<local-ip>:3001
  • KDS: http://flo.local:3002
  • Server App: http://flo.local:3003

Authentication

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.

Configurable authorization management

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.

Rate limiting

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.

Main API (:3001)

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.

Health

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.

Authentication

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.

Categories

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.

Addon groups

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 -

Products

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.

Recipes

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 -

Supplies

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.

Inventory 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 -

Tables

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.

Kitchen stations

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 -

Counters

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.

Orders

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 completed transitions require every payable bill for the order to be settled (paid with zero unpaid balance) and billed totals (accounting for round_off) to cover order.total. An unbilled positive-total order is not considered paid. When require_kitchen_delivered_before_settlement is enabled (kds_enabled not false and billing_type not prepaid), any item still in pending, preparing, or ready also blocks ordinary completion.
  • When unsettled payment or the applicable kitchen-delivery requirement blocks completed, an explicit override_pin from an active owner or manager holding orders.status.update (plus payments.take when overriding unsettled payment) is required (400 when missing, 403 when invalid or unauthorized, 429 after 5 failed attempts). Being logged in as an owner or manager does not bypass override_pin. Overriding completion never marks bills paid, erases balances, or creates payment records.
  • cancelled transitions require an explicit override_pin from an active owner or manager holding orders.status.update and orders.item.cancel whenever the order has advanced past pending, any item is in preparing, ready, served, or completed, or any bill holds recorded collected funds. Cancelling a scheduled order that holds net collected funds returns 409 (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 in pending status that actually deducted inventory (unreleased scheduled orders deduct no stock on creation, so none is restored).
  • When approver_id (or manager_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 (including payments.take when completion is blocked by unsettled payment). Every overridden status_changed audit log entry records actor_user_id, approved_by, overridden_safeguards, bill_ids, outstanding_amount, outstanding_amounts, and collected_amount (plus undelivered_items when 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:

  • 403 without orders.item.void (shipped to ROLE_ACCESS.sales) when the item is already in preparing or ready, and 403 without it again for an item that is already voided, void_adjustment or refunded. Every other status, including pending, served and an item already cancelled, needs orders.item.cancel (shipped to ROLE_ACCESS.ownerManager). Voiding an in-progress item additionally requires override_pin. When an approver is explicitly supplied (approver_id, manager_id, or user_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.
  • 409 on a paid scheduled order (SCHEDULED_ORDER_PAID) or when any bill for the order is paid, partially paid, or carries payment details.
  • 400 when the order is completed or cancelled.
  • Voiding an item in preparation writes a mirrored negative order_items row with status void_adjustment and marks the original voided, 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 to cancelled and restores the recorded inventory deduction and recipe components.
  • Cancelling the last active item cancels the order and frees its table.
  • override_pin attempts 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).

Order item status

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.

Held orders

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.

Bills

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.

Refunds

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.

Expenses

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.

Payment methods

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.

Cash sessions

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).

Cash closures

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).

Customers

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 -

Staff and users

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 -

Printers

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.

Merchant print templates

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 -

Menu CSV

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.

Settings

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.

Tax packs

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 -

KDS

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).

Kitchen

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.

KDS info

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.

Customer display feed

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.

POS info

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.

Server App info

Router: main/routes/server-app-info.ts. Full path: /api/server-app-info.

Method Path Authorization Parameters Response
GET / any authenticated role none -

More apps

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 -

WhatsApp

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.

Support tickets

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.

Diagnostics

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.

Database

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.

Database tools

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.

Reports

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.

GET /api/reports/daily-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=xlsx returns 200 with Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet and Content-Disposition: attachment; filename="daily-sales-YYYY-MM-DD.xlsx" (or daily-sales-YYYY-MM-DD_to_YYYY-MM-DD.xlsx for multi-day ranges).
  • format=csv returns 200 with Content-Type: text/csv; charset=utf-8 and Content-Disposition: attachment; filename="daily-sales-<slug>-<part>.csv" for the requested part (summary has header metric,value; daily, taxes, items, and payments use the column headers and encoding rules in daily sales export).
  • format=html returns 200 with Content-Type: text/html; charset=utf-8 and Content-Disposition: attachment; filename="daily-sales-YYYY-MM-DD.html" (or daily-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.

GET /api/reports/daily-invoices/export

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 200 with Content-Type: text/html; charset=utf-8 and Content-Disposition: attachment; filename="invoices-YYYY-MM-DD.html" (or invoices-YYYY-MM-DD_to_YYYY-MM-DD.html).
  • Errors: 400 for an unsupported format, mixed date and from/to arguments, 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; 401 unauthenticated; 403 when lacking reports.daily-sales.export; 409 for unconfigured regional settings or a concurrent-write conflict; 500 otherwise.

Inline endpoints (registered outside a router)

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.

KDS server (:3002)

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.

Server App (:3003)

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: 404 with { error: "Not found" }, so the surface is not advertised.
  • Main API unreachable: 502 with { error: "Could not reach the local POS API" } from the forwarding layer.
  • Request aborted by shutdown: 503 with 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.

KDS WebSocket (/kds)

One connection contract, implemented once in main/services/kds.ts and served on both :3001 and :3002.

Connecting

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

Handshake

  1. On connect the server sends connected.
  2. The client sends auth with a bearer JWT. A display must authenticate within KDS_AUTH_TIMEOUT_MS, 5 seconds; otherwise the server sends auth_error and closes with code 1008.
  3. On success the server sends auth_success, then sends initial_data as 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.

Frames the server sends

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

Frames the server accepts

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.

Every update is a full snapshot

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.

Errors a display must handle

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.

Connection limits

  • 100 connected clients, and at most 25 of them unauthenticated. Over either cap the server closes with 1013.
  • A client whose bufferedAmount exceeds 1 MB at send time is closed with 1013 as 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_data without any change of its own when its category or station assignment changes, or when the voided-item expiry marker moves.

KDS category and station scoping

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.

Refunds

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.

Cross-references

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

Coverage

This page lists 311 HTTP route registrations across 310 distinct rows, and one WebSocket contract:

  • 282 on the main API: 274 router registrations, 7 inline registrations in main/routes/index.ts, and GET /api/health in main/server.ts.
  • 8 REST routes on the KDS server, plus 3 static handlers.
  • 21 route registrations on the Server App, of which 13 forward to the main API. Two of those 21 are mutually exclusive GET / 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.