KDS & Printing System — Developer Reference
This document describes the full Kitchen Display System (KDS) and thermal-printer pipeline: data models, event flows, API contracts, WebSocket protocols, UI modes, and known implementation gaps.
Last verified against: branch
036-restaurant-kds-screens
The Three Tracking Entities
Three independent entities track different aspects of an order item's journey from waiter to guest. They all reference the same order_item.id but evolve separately.
| Entity | Answers | Status values |
|---|---|---|
kitchen_ticket | Did the KDS screen acknowledge this item? | pending → bumped · recall reverses to pending · voided |
print_job | Did the thermal printer successfully print the slip? | pending → sent → printed → failed |
order_item.status | Where is the item in the service lifecycle? | pending → preparing → ready → served · cancelled |
kitchen_ticket is the canonical record. It is always created when an item is sent to a station — regardless of whether a printer is configured. print_job records are only created when the station has a printer and printing is explicitly requested.
Station Output Configuration
Each kitchen_station has an outputType field that controls what happens when items arrive:
outputType | What happens |
|---|---|
"kds" | Ticket pushed to KDS screen via WebSocket. No print job created by the delivery processor. |
"printer" | Printer adapter sends ESC/POS slip. Ticket still pushed to KDS WebSocket. |
"both" | Ticket pushed to KDS + printer fires slip. |
null (default) | Treated as "kds". |
The station also stores printerType (e.g. "network_http"), printerUrl, and printerConfig (JSON: paper width, copy count, encoding, cut-after-each, open-cash-drawer, header lines).
Complete Ticket Firing Flow
Phase 1 — Order sent to kitchen (Order Service)
-
Waiter taps "Send to Kitchen" in the POS.
-
The order service calls
KitchenStationService.resolveStationForProduct()for each item, which:- Looks up the active
product_station_assignmentfor that product/category. - Supports modifier-based routing: if
modifierRouting[modifierOptionId]is configured, routes to a different station. - Falls back to the configured
DEFAULT_KITCHEN_STATION_IDparameter.
- Looks up the active
-
Emits
OnOrderSentToKitchenEvent("order.sent_to_kitchen") viaEventEmitter2with:{
orderId: string;
businessId: string;
locationId: string;
items: KitchenTicketJobItem[]; // one per order item + station pair
}
Phase 2 — Event handler enqueues delivery jobs
-
OnOrderSentToKitchenHandlerreceives the event and enqueues one BullMQ job per item:{
name: "deliver",
data: { businessId, locationId, orderItemId, stationId, ticketData },
opts: {
jobId: `${orderItemId}:${stationId}`, // idempotency key
attempts: 3,
backoff: { type: "exponential", delay: 2000 },
}
}Queue name:
deliver-kitchen-ticket.
Phase 3 — BullMQ processor creates ticket and delivers
-
DeliverKitchenTicketProcessor.handleDeliver()picks up the job. -
Creates
kitchen_ticket(idempotent viaON CONFLICT DO NOTHING):status = "pending", firedAt = NOW()
ticketData = { orderNumber, orderType, tableAlias, seatNo, itemName,
quantity, modifiers[], notes, courseNumber,
isModification, modifiedAt }ticketDatais pre-denormalized — no further joins needed at display time. -
Looks up station's
outputType. -
If
outputTypeis"kds"or"both": CallsIKdsNotificationService.pushTicket()→WebsocketKdsNotificationAdapteremitsticket:newto the station roomrst:{businessId}:{locationId}:{stationId}on the/kdsSocket.IO namespace. All connected KDS devices in that room receive the event immediately. -
If
outputTypeis"printer"or"both":PrinterAdapterFactory.createForStation(station)returns the correct adapter:NetworkThermalPrinterAdapter— HTTP POST with ESC/POS payload toprinterUrl.StubPrinterAdapter— no-op (testing).
On success → station
printerStatus → "online". On failure → stationprinterStatus → "offline"→ emitsprinter:offlineto location room → BullMQ retries (exponential backoff, up to 3 attempts) → after all retries fail, captured in Sentry.
Phase 4 — Kitchen staff interaction
- KDS device receives
ticket:new, displays card with elapsed timer. - Staff cooks, taps Bump (or long-press 600 ms) → card groups all tickets in the same order.
- Bump confirmed →
POST /tickets/:id/bump(device token auth) →kitchen_ticket.status = "bumped"→ticket:bumpedWebSocket event → ticket removed from all screens in that station room. - Staff can undo: Recall button (kiosk) or admin Recall →
POST /tickets/:id/recall→kitchen_ticket.status = "pending"→ticket:recalledevent → ticket reappears.
WebSocket Namespaces
/kds — KDS device namespace
Auth: handshake.auth.deviceToken (raw 64-char hex from device registration).
The gateway SHA-256 hashes it, looks up kds_device.deviceTokenHash, uses timing-safe comparison. On success, sets socket.stationId, joins station and location rooms, delivers pending tickets.
Rooms:
- Station room:
rst:{businessId}:{locationId}:{stationId} - Location room:
rst:{businessId}:{locationId}:all
Events received by client:
| Event | Payload | Meaning |
|---|---|---|
ticket:new | { ticketId, orderItemId, stationId, status, firedAt, ticketData } | New item fired to this station |
ticket:bumped | { ticketId, stationId, status:"bumped", bumpedAt, bumpedBy } | Item acknowledged by kitchen |
ticket:recalled | { ticketId, stationId, status:"pending", recalledAt } | Bump undone |
ticket:voided | { ticketId, stationId, status:"voided", voidedAt } | Item cancelled |
printer:offline | { stationId, stationName, locationId, detectedAt } | Printer went offline |
printer:online | { stationId, stationName, locationId, recoveredAt } | Printer recovered |
auth_error | { message } | Token invalid/expired → client clears localStorage |
Events sent by client:
| Event | Payload | Effect |
|---|---|---|
subscribe | { businessId, locationId } | Join location room |
On reconnect, the gateway re-delivers all pending tickets for the station via ticket:new.
/restaurant — Admin/staff namespace
Auth: handshake.auth.token (Firebase ID token).
Used by the admin KDS view, orders page, dining room view, and other restaurant staff screens.
Relevant events for KDS:
| Event | Payload | Used by admin KDS |
|---|---|---|
print_job.new | { printJob, stationId } | Triggers loadTickets() — refreshes the admin ticket list |
print_job.updated | { printJob, stationId } | Available (not currently used in KDS admin) |
printer:offline | { stationId } | Available for offline banner |
The admin KDS view subscribes to print_job.new as a proxy trigger to reload kitchen_ticket records. This is intentional: print_job.new fires whenever items are sent to kitchen, making it a reliable real-time signal even for stations without printers.
Device Pairing Flow
Generating a pairing code (admin)
POST /restaurant/kitchen/stations/:stationId/pairing-code
?locationId=...&businessId=...
Authorization: Bearer <firebase-token>
→ { code: "847293", expiresAt: "2026-...", stationId, stationName }
- 6-digit code stored in Redis:
kds:pair:station:{stationId}→{code}:{locationId}:{businessId}(10-minute TTL). - Redis
SET NXprevents code storms — returns existing code if already active.
Registering a device (KDS screen)
POST /restaurant/kitchen/devices
Content-Type: application/json
{ "pairingCode": "847293", "deviceName": "Grill KDS" }
→ {
"deviceId": "uuid",
"deviceToken": "64-char-hex", // ← only returned ONCE, store immediately
"stationId": "uuid",
"stationName": "Grill",
"registeredAt": "..."
}
- Scans Redis keys matching
kds:pair:station:*to find the code. - Atomically consumes (deletes) the key.
- Generates raw token:
crypto.randomBytes(32).toString("hex"). - Stores SHA-256 hash as
kds_device.deviceTokenHash. - Device token is never stored in the database — only the hash.
Client-side storage
localStorage["flowpos_kds_device_token"] = deviceToken; // auth for WebSocket + bump/recall
localStorage["flowpos_kds_station_id"] = stationId;
localStorage["flowpos_kds_device_id"] = deviceId; // needed for deregistration
Deregistering (Unpair)
DELETE /restaurant/kitchen/devices/:deviceId
Authorization: Bearer <firebase-token>
Calls kdsDeviceRepository.deactivate(deviceId) then kdsNotificationService.disconnectDevice(deviceId) which force-closes all active WebSocket connections for that device. Client clears localStorage.
Configuring a Station for Printing
Open Kitchen Stations (/forms/restaurantKitchenStations) and locate the station card. Each card has an Output Config button that expands an inline form:
| Field | Options | Notes |
|---|---|---|
| Output type | KDS screen only · Thermal printer only · KDS + Printer | Controls what fires when items reach the station |
| Paper width | 58 mm · 80 mm | Match your printer's paper roll |
| Header lines | Free text, one line per row | Printed centered at the top of every slip |
| Cut after each ticket | Checkbox | Sends a paper-cut ESC/POS command after each slip |
| Copies | 1–5 | Number of duplicate slips per ticket |
After saving, a Test Print button appears on the card (only when outputType is printer or both and a printerUrl is configured). Tapping it sends a dated test slip so you can confirm TCP/IP connectivity before service.
Printer URL format
The thermal printer adapter connects over raw TCP (port 9100 is the thermal-printer standard):
tcp://192.168.1.100:9100
The printerUrl field on the station creation form accepts a URL string. For network ESC/POS printers the scheme is tcp://. For the JSON webhook path (when using a cloud print queue) use http:// or https://.
Two print paths
| Path | When used | Protocol | Auth |
|---|---|---|---|
ESC/POS via DeliverKitchenTicketProcessor | outputType = "printer" or "both" at ticket fire time | Raw TCP + ESC/POS binary | None (LAN) |
JSON webhook via PrintJobService | When print_job records are created explicitly and printerType = "network_http" | HTTP POST with JSON body | None (configured URL) |
The ESC/POS path is the primary path triggered by the kitchen ticket delivery flow. It formats and sends the ticket immediately when the order is fired.
The JSON webhook path is for external print queue integrations — the receiver formats and prints the slip in its own way.
Setup Modes
KDS Only (no printer)
kitchen_ticketis created → pushed via WebSocket to KDS screen.- No
print_jobis created by the delivery processor. - Staff bump the ticket when done →
kitchen_ticket.status = "bumped".
Printer Only (no KDS screen)
kitchen_ticketis still created (canonical record, always).- Printer adapter fires ESC/POS slip to
printerUrl. - No device token / no KDS screen needed.
print_jobrecords are created viaPrintJobService.createPrintJobs()(called from order service or other integration points, not automatically byDeliverKitchenTicketProcessor).- Kitchen staff acknowledge via paper slip; no bump action.
KDS + Printer (both)
kitchen_ticketcreated → WebSocket push to KDS screen.- Printer adapter fires slip.
- Staff bump on screen →
kitchen_ticket.status = "bumped". print_job.statusis managed independently byPrintJobService/ printer infrastructure.
Expo — station completion API (GET /orders/:id/station-completion)
Per-station progress for the Expo screen is derived from print_job rows (one per routed line). A line counts as complete when print_job.status is printed or failed, or the matching kitchen_ticket (same order_item_id + kitchen_station_id) is bumped or voided. That aligns Expo with KDS acknowledgement on KDS-only stations, where print_job may stay pending because no thermal path runs.
UI Views
Kiosk Screen (/forms/restaurantKds?kiosk=true&stationId=...)
- Full-screen layout (
h-dvh, no admin chrome). - Driven by
useKdsSockethook (WebSocket/kdsnamespace, device token auth). - Displays
KdsTicketGrid— allpendingtickets for the paired station. - Tickets are grouped by order number into
KdsOrderGroupcards. - Each card shows: order number, table alias, item name + quantity, modifiers (add/remove/default styled), notes, elapsed timer with urgency color.
Urgency thresholds (kiosk):
| Level | Threshold | Visual |
|---|---|---|
normal | < 5 min (300 s) | Green border |
warning | 5–10 min (300–599 s) | Amber border + amber background |
critical | ≥ 10 min (≥ 600 s) | Red border + red background + pulse animation |
Bump interaction: single tap opens confirmation dialog; 600 ms long-press confirms immediately. Bumping one card bumps all tickets in the order group in a single action.
Recall button: floating fixed-bottom-right button; restores the last bumped ticket (held in lastBumpedTicketRef).
Sound: new ticket fires a short WAV beep (volume 0.3, respects the toggle).
Admin View (/forms/restaurantKds)
- Standard padded layout with navigation buttons (Dining, Orders, Kitchen Stations, Unpair).
- Driven by polling
GET /restaurant/kitchen/tickets?stationId=...every 60 s (Firebase auth). - Real-time trigger:
useRestaurantSocketlistens toprint_job.new→ callsloadTickets().
Tabs:
| Tab | Data source | Contents |
|---|---|---|
| Active | kitchen_ticket where status = "pending" | Cards grouped by order number, Bump button per ticket |
| Served | kitchen_ticket where status = "bumped" | Cards with Recall button |
| Reports | getKitchenAnalytics() + getAggregatedItemsByStation() | Avg/max prep time, all-day item totals |
Keyboard shortcuts: 1 Active · 2 Served · 3 Reports.
Bump (admin): POST /restaurant/kitchen/admin/tickets/:id/bump — Firebase auth, no device token needed.
Recall (admin): POST /restaurant/kitchen/admin/tickets/:id/recall — Firebase auth.
API Reference
All paths are prefixed by the module mount prefix (typically /restaurant/kitchen).
Kitchen Tickets
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /tickets | Firebase | List tickets. Required: locationId. Optional: stationId, status (pending/bumped/voided). |
POST | /tickets/:id/bump | Device token (KdsDeviceGuard) | Bump from KDS kiosk. Body: { employeeId?: string }. |
POST | /tickets/:id/recall | Device token (KdsDeviceGuard) | Recall from KDS kiosk. |
POST | /admin/tickets/:id/bump | Firebase | Bump from admin view. Uses req.firebaseUser.uid as actor. |
POST | /admin/tickets/:id/recall | Firebase | Recall from admin view. |
Kitchen Stations
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /kitchen-stations | Firebase | Create station. |
GET | /kitchen-stations | Firebase | List stations (paginates; supports locationId, businessId, isActive). |
PATCH | /kitchen-stations/:id | Firebase | Update station. |
DELETE | /kitchen-stations/:id | Firebase | Delete station (blocked if assigned as multicast target). |
POST | /kitchen-stations/:id/heartbeat | Firebase | Update lastSeenAt. |
GET | /kitchen-stations/health | Firebase | Online/offline status per station (threshold configurable). |
GET | /kitchen-stations/:id/aggregated-items | Firebase | All-day item totals for station. |
GET | /kitchen-stations/load | Firebase | Per-station job counts and load level (low/medium/high). |
PATCH | /stations/:id/output-config | Firebase | Update outputType, printerConfig, fallbackStationId. UI: Output Config panel in Kitchen Stations page. |
POST | /stations/:id/test-print | Firebase | Send test ESC/POS slip to station printer. UI: Test Print button on station card. |
POST | /stations/:id/pairing-code | Firebase | Generate 6-digit pairing code (10-min TTL). |
KDS Devices
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /devices | Public (no auth) | Register device with pairing code. Returns raw token once only. |
GET | /devices | Firebase | List devices. Required: locationId. |
DELETE | /devices/:id | Firebase | Deregister device. Force-disconnects WebSocket. |
Print Jobs
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /print-jobs | Firebase | List jobs. Optional: status, stationId, expand=details. |
PATCH | /print-jobs/:id | Firebase | Update job status. Body: { status, lastError? }. |
GET | /analytics | Firebase | Prep-time analytics. Optional: stationId, productId, dateFrom, dateTo. |
GET | /stations/printer-health | Firebase | Printer online/offline status for all stations at location. |
Product-Station Assignments
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /product-station-assignments | Firebase | Create assignment (requires productId or categoryId). |
GET | /product-station-assignments | Firebase | List assignments (supports locationId, stationId, productId, categoryId, isActive). |
PATCH | /product-station-assignments/:id | Firebase | Update assignment. |
DELETE | /product-station-assignments/:id | Firebase | Delete assignment. |
Data Shapes
kitchen_ticket.ticketData (JSONB — written once at fire time)
{
"orderNumber": 83,
"orderType": "dine_in",
"tableAlias": "T4",
"seatNo": null,
"itemName": "COKE",
"quantity": 2,
"modifiers": ["Large (+Q2.50)", "No Ice"],
"notes": "Allergy: no dairy",
"courseNumber": 1,
"isModification": false,
"modifiedAt": null
}
modifiers is stored as plain strings (string[]). The kiosk useKdsSocket hook maps them to KdsTicketModifier[] (with groupName, optionName, type) for richer UI rendering. The admin REST view receives them as plain strings.
KitchenTicket — REST API response (admin view)
{
id: string;
orderItemId: string;
status: "pending" | "bumped" | "voided";
firedAt: string; // ISO timestamp — used for aging timer
ticketData: {
orderNumber: string;
orderType: string;
tableAlias: string | null;
seatNo: number | null;
itemName: string;
quantity: number;
modifiers: string[]; // plain strings
notes: string | null;
courseNumber: number | null;
isModification: boolean;
modifiedAt: string | null;
};
}
KitchenTicket — WebSocket shape (kiosk, via useKdsSocket)
{
id: string;
orderItemId: string;
orderNumber: number;
orderType: "dine_in" | "quick_service";
tableAlias: string | null;
seatNo: number | null;
itemName: string;
quantity: number;
modifiers: KdsTicketModifier[]; // enriched — groupName, optionName, type
notes: string | null;
courseNumber: number | null;
isModification: boolean;
modifiedAt: string | null;
firedAt: string;
status: "pending" | "ready" | "bumped" | "voided";
}
Status Transitions
kitchen_ticket
pending ──bump──▶ bumped
bumped ──recall──▶ pending
pending or bumped ──void──▶ voided (external cancel)
print_job
pending ──send──▶ sent ──print──▶ printed
sent ──fail──▶ failed (after maxAttempts exceeded)
pending ──fail──▶ failed
Stuck sent jobs (> 2 minutes old) are automatically reset to pending by a cron job (*/5 * * * *).
order_item.status
pending → preparing → ready → served
→ cancelled
Managed separately by kitchen and floor staff via the Orders page.
What Happens When Bump is Called
| Step | Action | Implemented? |
|---|---|---|
| 1 | kitchen_ticket.status → "bumped", bumpedAt = NOW(), bumpedBy = employeeId | ✅ |
| 2 | Emit ticket:bumped WebSocket event to all devices in station room | ✅ |
| 3 | order_item.status → "ready" (kitchen done, awaiting pickup) | ⚠️ Not yet implemented — must be done manually on the Orders page |
| 4 | print_job.status — do not change (managed by printer infrastructure) | ✅ |
Gap: Bumping a kitchen ticket does not automatically advance
order_item.statustoready. Kitchen staff must currently switch to the Orders page to mark items as ready. This is a planned improvement.
Common Mistakes
| Mistake | Why it's wrong |
|---|---|
Building the admin KDS view on top of print_job | Restaurants without printers would see nothing; kitchen_ticket is always created |
Bumping a ticket → also marking print_job as printed | print_job tracks printer hardware, not kitchen acknowledgement |
Equating kitchen_ticket.bumped with order_item.served | Bumped = kitchen done; served = delivered to guest — waiter must still deliver |
Filtering admin KDS view by order_item.status | Mixes kitchen prep state with service lifecycle — use kitchen_ticket.status instead |
Using print_job analytics (getAnalytics) for KDS-only stations | Analytics endpoint returns print_job data; use getAggregatedItemsByStation for KDS-only |
Storing the raw deviceToken in the database | Only the SHA-256 hash is stored; the raw token is returned once and must be saved client-side |
Not using ON CONFLICT DO NOTHING when creating tickets | BullMQ retries the job on failure — duplicate tickets would appear on the KDS screen |
Infrastructure
BullMQ Queues
| Queue | Processor | Attempts | Backoff |
|---|---|---|---|
deliver-kitchen-ticket | DeliverKitchenTicketProcessor | 3 | Exponential, 2 s base |
printer-health-check | PrinterHealthProcessor | 1 | None |
Job IDs use ${orderItemId}:${stationId} for idempotency — re-sending to kitchen is safe.
Redis Keys
| Key pattern | Value | TTL |
|---|---|---|
kds:pair:station:{stationId} | {code}:{locationId}:{businessId} | 600 s (10 min) |
Station Health
A station is considered online if its lastSeenAt is within the threshold (default: 120 s, configurable via PrintJobSettingsService). The kiosk calls POST /kitchen-stations/:id/heartbeat every 30 seconds to keep the station alive.