Developer / APIDevice API (Device Mesh)

Device API (Device Mesh)

The device-mesh module is the server-side registry, pairing and command queue for a tenant’s local hardware: receipt/kitchen printers, cash drawers, fiscal registers (yazarkasa), POS terminals, KDS/bar screens, waiter/customer tablets, caller-ID, barcode scanners and the local bridge agent (local_bridge) that drives several devices at once.

An admin creates a device slot from the panel; the slot shows a 6-character pair code. The device (or the local_bridge agent) enters that code to pair and receives a long-lived device token in return. From then on the server dispatches commands to the device — print a receipt, open the drawer, issue a fiscal receipt, show an order on a screen — through a per-device command queue.

All endpoints live under the /api global prefix at /api/v1/devices/.... See API Fundamentals for the base URL, error envelope and rate-limit tiers shared across the whole REST surface.

Auth realms

device-mesh hosts two distinct auth surfaces in one controller. Each endpoint expects exactly one of them; the wrong realm returns 401.

RealmHeaderUsed byUsed for
Staff JWTAuthorization: Bearer <jwt>Tenant admins (ADMIN / MANAGER)Create slots, send commands, list/retire devices
Device tokenAuthorization: Device <token>The device / local_bridge itselfPair, heartbeat, claim next command, ack
⚠️

The device realm deliberately uses Authorization: Device <token>not Bearer. This is so HTTP intermediaries that strip or rewrite Bearer for user sessions do not clobber device auth.

The admin routes are branch-scoped and require an X-Branch-Id header (below). The device routes (pair, heartbeat, next-command, ack) are authenticated by the token itself, which already carries the tenant/branch context — they do not take X-Branch-Id.

Branch scope — X-Branch-Id

Slot creation, command dispatch and the device list are branch-scoped: the server resolves the active branch from the X-Branch-Id header.

X-Branch-Id: <branch-uuid>

A MANAGER restricted to one branch can only drive devices in that branch — a device outside the caller’s branch scope returns 404. An ADMIN operates tenant-wide.

Device kinds

The kind field is one value from a closed set:

kindDescription
receipt_printerReceipt printer
kitchen_printerKitchen printer
yazarkasaFiscal register (mali / fiscal device)
pos_terminalCard payment terminal
kds_screen / bar_screenKitchen / bar display
tablet_waiter / tablet_customerWaiter / customer tablet
caller_idCaller-ID device
scannerBarcode scanner
local_bridgeLocal bridge agent that drives multiple devices

The capabilities field is an array of free-form tags (max 32 items, each ≤ 64 chars) — e.g. a bridge that carries fiscal + printer + cash-drawer capabilities.

Endpoints

MethodPathRealmNotes
POST/api/v1/devicesStaff JWTCreate a device slot; returns the pair code
GET/api/v1/devicesStaff JWTList devices (filter by branchId, kind, status)
DELETE/api/v1/devices/:idStaff JWT (ADMIN)Retire a device; revokes its token
POST/api/v1/devices/:id/commandsStaff JWTEnqueue a command for a device
POST/api/v1/devices/pairDevice (pair code)Claim a slot with a pair code; returns the raw token once
POST/api/v1/devices/heartbeatDevice tokenKeep-alive + telemetry
GET/api/v1/devices/next-commandDevice tokenAtomically claim the next queued command
POST/api/v1/devices/commands/:id/ackDevice tokenReport a command outcome

Pairing flow

An admin creates a device slot

The slot is created within the current branch scope. The response includes the 6-character pairCode and its expiry.

curl -X POST https://hummytummy.com/api/v1/devices \
  -H "Authorization: Bearer $JWT" \
  -H "X-Branch-Id: $BRANCH_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "receipt_printer",
    "capabilities": ["escpos", "80mm"],
    "model": "Epson TM-T20III",
    "ownership": "byo"
  }'
{
  "id": "01J...",
  "tenantId": "01J...",
  "branchId": "01J...",
  "kind": "receipt_printer",
  "status": "unprovisioned",
  "pairCode": "A4F9K2",
  "pairCodeExpiresAt": "2026-06-22T10:10:00.000Z"
}

branchId is required. Because the route is branch-scoped, the server resolves it from the X-Branch-Id header; you may also pass branchId in the body to set it explicitly. The ownership field is one of sold / rented / byo (default byo).

The device pairs with the pair code

The device (or the local_bridge agent) reads the on-screen code and pairs. The response contains the raw token, returned exactly once.

curl -X POST https://hummytummy.com/api/v1/devices/pair \
  -H "Content-Type: application/json" \
  -d '{
    "pairCode": "A4F9K2",
    "model": "Epson TM-T20III",
    "capabilities": ["escpos", "80mm"]
  }'
{
  "deviceId": "01J...",
  "tenantId": "01J...",
  "branchId": "01J...",
  "kind": "receipt_printer",
  "token": "018f....base64url",
  "tokenExpiresAt": "2026-06-23T10:05:00.000Z",
  "capabilities": ["escpos", "80mm"]
}

The device stores the token and sends heartbeats

From now on the token is sent on every request in the Authorization: Device <token> header. The device sends regular heartbeats to stay online.

curl -X POST https://hummytummy.com/api/v1/devices/heartbeat \
  -H "Authorization: Device $DEVICE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "batteryPct": 100, "agentVersion": "1.0.0", "queueDepth": 0 }'

Pairing rules, TTLs and brute-force protection

⚠️
  • Pair code TTL defaults to 10 minutes (DEVICE_PAIR_CODE_TTL_MS). An expired code is atomically cleared on first use and returns “Pair code expired — request a new one”.
  • Device token TTL defaults to 24 hours (DEVICE_TOKEN_TTL_MS). Tokens are stored as a sha256 hash on the server; the raw token is never persisted and is returned only once, at pair time.
  • The /pair endpoint is rate-limited to 5 requests/min/IP — making brute force of the 6-character code ([A-Z0-9], ~2.2 billion combinations) meaningless within the TTL window.
  • The pair code is single-use: if two devices enter the same code milliseconds apart, the atomic claim (updateMany) only succeeds for the first; the second gets “Pair code already claimed by another device or expired”.

Device statuses

unprovisioned → (slot created, awaiting code)
paired        → (first pair, no heartbeat yet)
online        → (within the heartbeat window)
offline       → (no heartbeat for ~45s)
retired       → (admin retired it; token revoked)

The heartbeat window is 45s; a background sweeper (cron) flips online devices that exceed it to offline.

Command queue

An admin drives hardware by sending commands to a device. Commands are held in a per-device FIFO + priority queue; the device pulls the next command via next-command, executes it, and reports the outcome via ack.

Command kinds (kind)

kindHardware action
print_receiptPrint a customer receipt
open_drawerOpen the cash drawer
fiscal_receiptIssue a fiscal receipt on the register
fiscal_cancelCancel a fiscal receipt (fiscal void)
charge_cardStart a charge on the card terminal
show_order / clear_orderShow / clear an order on screen
reboot / firmware_updateReboot the device / update firmware
capability_probe / noopProbe capabilities / no-op

Enqueue a command (admin)

curl -X POST https://hummytummy.com/api/v1/devices/$DEVICE_ID/commands \
  -H "Authorization: Bearer $JWT" \
  -H "X-Branch-Id: $BRANCH_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "print_receipt",
    "payload": { "orderId": "01J...", "copies": 1 },
    "priority": 0,
    "idempotencyKey": "order-01J-receipt"
  }'
  • kind must be one of the closed set above; free-form aliases (such as charge.card) are rejected with 400.
  • payload must be an object (JSONB); priority is 0–1000 (0 default, higher = first).
  • If idempotencyKey is supplied, the command is deduplicated on (deviceId, idempotencyKey); resending returns the same command (no duplicate is created). Always send a stable key when retrying.

Device side: claiming and acking

# Atomically claim the next command (returns null if none)
curl https://hummytummy.com/api/v1/devices/next-command \
  -H "Authorization: Device $DEVICE_TOKEN"
 
# Report the outcome
curl -X POST https://hummytummy.com/api/v1/devices/commands/$COMMAND_ID/ack \
  -H "Authorization: Device $DEVICE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "status": "done", "result": { "bytesPrinted": 412 } }'

next-command is atomic via FOR UPDATE SKIP LOCKED — even if a single device opens two connections, a command is never delivered twice. The ack status is done or failed; the error field (≤ 1000 chars) carries the failure reason.

Retry semantics — idempotency and side-effects

⚠️

Money-moving / artefact-producing commands are never auto-retried. charge_card, fiscal_receipt, fiscal_cancel, open_drawer and print_receipt are side-effecting: if a command’s ack is lost (the terminal charged → the app crashed → the result never reached the server) and it were requeued, the customer would be double-charged or the receipt double-printed. Such commands therefore terminate directly in failed on failure; an operator reconciles with an explicit compensating command (e.g. fiscal_cancel). Safe/idempotent commands (show_order, clear_order, capability_probe, reboot, noop) follow the normal retry path (up to 5 attempts).

Screen commands (KDS / bar displays)

A KDS or bar screen registered with kind: "kds_screen" (or bar_screen) can be driven with the safe screen subset of the command set:

kindAction
show_orderShow an order on the screen
clear_orderClear the displayed order
rebootReboot the screen device
capability_probeProbe capabilities
noopNo-op (connectivity test)
curl -X POST https://hummytummy.com/api/v1/devices/$SCREEN_ID/commands \
  -H "Authorization: Bearer $JWT" \
  -H "X-Branch-Id: $BRANCH_ID" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "show_order", "payload": { "orderId": "01J..." } }'

These are all idempotent / safe — unlike the side-effecting hardware commands, showing or clearing a screen twice causes no harm, so on failure they follow the normal retry path (up to 5 attempts).

Listing devices

curl "https://hummytummy.com/api/v1/devices?branchId=$BRANCH_ID&kind=receipt_printer&status=online" \
  -H "Authorization: Bearer $JWT" \
  -H "X-Branch-Id: $BRANCH_ID"

Filterable by branchId, kind and status. The list response returns identity + status information; sensitive fields such as pairCode and tokenHash are deliberately excluded from the list.

Rate limits

In addition to the global tiers in API Fundamentals, the device endpoints add their own tighter limits:

EndpointLimit
POST /api/v1/devices/pair5 / min / IP
POST /api/v1/devices/heartbeat60 / min
GET /api/v1/devices/next-command120 / min
POST /api/v1/devices/commands/:id/ack120 / min

The per-token limits on heartbeat, next-command and ack stop a compromised device token from hammering the database.

Security notes

  • Sending commands and inspecting the queue are branch-scoped: a MANAGER restricted to one branch cannot send charge_card / open_drawer / fiscal_receipt to another branch’s terminal (an out-of-scope device returns 404). An ADMIN operates tenant-wide.
  • Retiring a device (DELETE /api/v1/devices/:id) is restricted to the ADMIN role; it revokes the token.
  • The raw device token is shown once at pair time and stored only as a sha256 hash; if it is lost, retire the slot and pair again.

The desktop app’s local Bluetooth/ESC/POS support (Tauri commands like scan_devices, connect_device, print_receipt) is client-side BLE printing that runs inside the client. The device-mesh described here is the server-side registry, pairing and command queue. The two complement each other: a local_bridge can translate a print_receipt command it received from the mesh into a local ESC/POS print job.

Operator screens

Restaurant staff manage devices from the in-app panel, not the API directly:

  • Devices screen — create slots, read pairing codes/QR, monitor status, send a capability probe, retire.
  • Hardware pairing — the operator-side pairing walkthrough for printers, cash drawers and fiscal registers.
  • KDS / kiosk mode — running a kitchen display as a registered screen device.