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.
| Realm | Header | Used by | Used for |
|---|---|---|---|
| Staff JWT | Authorization: Bearer <jwt> | Tenant admins (ADMIN / MANAGER) | Create slots, send commands, list/retire devices |
| Device token | Authorization: Device <token> | The device / local_bridge itself | Pair, 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:
kind | Description |
|---|---|
receipt_printer | Receipt printer |
kitchen_printer | Kitchen printer |
yazarkasa | Fiscal register (mali / fiscal device) |
pos_terminal | Card payment terminal |
kds_screen / bar_screen | Kitchen / bar display |
tablet_waiter / tablet_customer | Waiter / customer tablet |
caller_id | Caller-ID device |
scanner | Barcode scanner |
local_bridge | Local 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
| Method | Path | Realm | Notes |
|---|---|---|---|
POST | /api/v1/devices | Staff JWT | Create a device slot; returns the pair code |
GET | /api/v1/devices | Staff JWT | List devices (filter by branchId, kind, status) |
DELETE | /api/v1/devices/:id | Staff JWT (ADMIN) | Retire a device; revokes its token |
POST | /api/v1/devices/:id/commands | Staff JWT | Enqueue a command for a device |
POST | /api/v1/devices/pair | Device (pair code) | Claim a slot with a pair code; returns the raw token once |
POST | /api/v1/devices/heartbeat | Device token | Keep-alive + telemetry |
GET | /api/v1/devices/next-command | Device token | Atomically claim the next queued command |
POST | /api/v1/devices/commands/:id/ack | Device token | Report 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
/pairendpoint 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)
kind | Hardware action |
|---|---|
print_receipt | Print a customer receipt |
open_drawer | Open the cash drawer |
fiscal_receipt | Issue a fiscal receipt on the register |
fiscal_cancel | Cancel a fiscal receipt (fiscal void) |
charge_card | Start a charge on the card terminal |
show_order / clear_order | Show / clear an order on screen |
reboot / firmware_update | Reboot the device / update firmware |
capability_probe / noop | Probe 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"
}'kindmust be one of the closed set above; free-form aliases (such ascharge.card) are rejected with400.payloadmust be an object (JSONB);priorityis 0–1000 (0 default, higher = first).- If
idempotencyKeyis 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:
kind | Action |
|---|---|
show_order | Show an order on the screen |
clear_order | Clear the displayed order |
reboot | Reboot the screen device |
capability_probe | Probe capabilities |
noop | No-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:
| Endpoint | Limit |
|---|---|
POST /api/v1/devices/pair | 5 / min / IP |
POST /api/v1/devices/heartbeat | 60 / min |
GET /api/v1/devices/next-command | 120 / min |
POST /api/v1/devices/commands/:id/ack | 120 / 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
MANAGERrestricted to one branch cannot sendcharge_card/open_drawer/fiscal_receiptto another branch’s terminal (an out-of-scope device returns404). AnADMINoperates tenant-wide. - Retiring a device (
DELETE /api/v1/devices/:id) is restricted to theADMINrole; 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.