Skip to main content

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.

EntityAnswersStatus values
kitchen_ticketDid the KDS screen acknowledge this item?pending → bumped · recall reverses to pending · voided
print_jobDid the thermal printer successfully print the slip?pending → sent → printed → failed
order_item.statusWhere 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:

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

  1. Waiter taps "Send to Kitchen" in the POS.

  2. The order service calls KitchenStationService.resolveStationForProduct() for each item, which:

    • Looks up the active product_station_assignment for 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_ID parameter.
  3. Emits OnOrderSentToKitchenEvent ("order.sent_to_kitchen") via EventEmitter2 with:

    {
    orderId: string;
    businessId: string;
    locationId: string;
    items: KitchenTicketJobItem[]; // one per order item + station pair
    }

Phase 2 — Event handler enqueues delivery jobs

  1. OnOrderSentToKitchenHandler receives 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

  1. DeliverKitchenTicketProcessor.handleDeliver() picks up the job.

  2. Creates kitchen_ticket (idempotent via ON CONFLICT DO NOTHING):

    status = "pending", firedAt = NOW()
    ticketData = { orderNumber, orderType, tableAlias, seatNo, itemName,
    quantity, modifiers[], notes, courseNumber,
    isModification, modifiedAt }

    ticketData is pre-denormalized — no further joins needed at display time.

  3. Looks up station's outputType.

  4. If outputType is "kds" or "both": Calls IKdsNotificationService.pushTicket()WebsocketKdsNotificationAdapter emits ticket:new to the station room rst:{businessId}:{locationId}:{stationId} on the /kds Socket.IO namespace. All connected KDS devices in that room receive the event immediately.

  5. If outputType is "printer" or "both": PrinterAdapterFactory.createForStation(station) returns the correct adapter:

    • NetworkThermalPrinterAdapter — HTTP POST with ESC/POS payload to printerUrl.
    • StubPrinterAdapter — no-op (testing).

    On success → station printerStatus → "online". On failure → station printerStatus → "offline" → emits printer:offline to location room → BullMQ retries (exponential backoff, up to 3 attempts) → after all retries fail, captured in Sentry.

Phase 4 — Kitchen staff interaction

  1. KDS device receives ticket:new, displays card with elapsed timer.
  2. Staff cooks, taps Bump (or long-press 600 ms) → card groups all tickets in the same order.
  3. Bump confirmed → POST /tickets/:id/bump (device token auth) → kitchen_ticket.status = "bumped"ticket:bumped WebSocket event → ticket removed from all screens in that station room.
  4. Staff can undo: Recall button (kiosk) or admin Recall → POST /tickets/:id/recallkitchen_ticket.status = "pending"ticket:recalled event → 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:

EventPayloadMeaning
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:

EventPayloadEffect
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:

EventPayloadUsed 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 NX prevents 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:

FieldOptionsNotes
Output typeKDS screen only · Thermal printer only · KDS + PrinterControls what fires when items reach the station
Paper width58 mm · 80 mmMatch your printer's paper roll
Header linesFree text, one line per rowPrinted centered at the top of every slip
Cut after each ticketCheckboxSends a paper-cut ESC/POS command after each slip
Copies1–5Number 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

PathWhen usedProtocolAuth
ESC/POS via DeliverKitchenTicketProcessoroutputType = "printer" or "both" at ticket fire timeRaw TCP + ESC/POS binaryNone (LAN)
JSON webhook via PrintJobServiceWhen print_job records are created explicitly and printerType = "network_http"HTTP POST with JSON bodyNone (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_ticket is created → pushed via WebSocket to KDS screen.
  • No print_job is created by the delivery processor.
  • Staff bump the ticket when done → kitchen_ticket.status = "bumped".

Printer Only (no KDS screen)

  • kitchen_ticket is still created (canonical record, always).
  • Printer adapter fires ESC/POS slip to printerUrl.
  • No device token / no KDS screen needed.
  • print_job records are created via PrintJobService.createPrintJobs() (called from order service or other integration points, not automatically by DeliverKitchenTicketProcessor).
  • Kitchen staff acknowledge via paper slip; no bump action.

KDS + Printer (both)

  • kitchen_ticket created → WebSocket push to KDS screen.
  • Printer adapter fires slip.
  • Staff bump on screen → kitchen_ticket.status = "bumped".
  • print_job.status is managed independently by PrintJobService / 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 useKdsSocket hook (WebSocket /kds namespace, device token auth).
  • Displays KdsTicketGrid — all pending tickets for the paired station.
  • Tickets are grouped by order number into KdsOrderGroup cards.
  • Each card shows: order number, table alias, item name + quantity, modifiers (add/remove/default styled), notes, elapsed timer with urgency color.

Urgency thresholds (kiosk):

LevelThresholdVisual
normal< 5 min (300 s)Green border
warning5–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: useRestaurantSocket listens to print_job.new → calls loadTickets().

Tabs:

TabData sourceContents
Activekitchen_ticket where status = "pending"Cards grouped by order number, Bump button per ticket
Servedkitchen_ticket where status = "bumped"Cards with Recall button
ReportsgetKitchenAnalytics() + 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

MethodPathAuthDescription
GET/ticketsFirebaseList tickets. Required: locationId. Optional: stationId, status (pending/bumped/voided).
POST/tickets/:id/bumpDevice token (KdsDeviceGuard)Bump from KDS kiosk. Body: { employeeId?: string }.
POST/tickets/:id/recallDevice token (KdsDeviceGuard)Recall from KDS kiosk.
POST/admin/tickets/:id/bumpFirebaseBump from admin view. Uses req.firebaseUser.uid as actor.
POST/admin/tickets/:id/recallFirebaseRecall from admin view.

Kitchen Stations

MethodPathAuthDescription
POST/kitchen-stationsFirebaseCreate station.
GET/kitchen-stationsFirebaseList stations (paginates; supports locationId, businessId, isActive).
PATCH/kitchen-stations/:idFirebaseUpdate station.
DELETE/kitchen-stations/:idFirebaseDelete station (blocked if assigned as multicast target).
POST/kitchen-stations/:id/heartbeatFirebaseUpdate lastSeenAt.
GET/kitchen-stations/healthFirebaseOnline/offline status per station (threshold configurable).
GET/kitchen-stations/:id/aggregated-itemsFirebaseAll-day item totals for station.
GET/kitchen-stations/loadFirebasePer-station job counts and load level (low/medium/high).
PATCH/stations/:id/output-configFirebaseUpdate outputType, printerConfig, fallbackStationId. UI: Output Config panel in Kitchen Stations page.
POST/stations/:id/test-printFirebaseSend test ESC/POS slip to station printer. UI: Test Print button on station card.
POST/stations/:id/pairing-codeFirebaseGenerate 6-digit pairing code (10-min TTL).

KDS Devices

MethodPathAuthDescription
POST/devicesPublic (no auth)Register device with pairing code. Returns raw token once only.
GET/devicesFirebaseList devices. Required: locationId.
DELETE/devices/:idFirebaseDeregister device. Force-disconnects WebSocket.
MethodPathAuthDescription
GET/print-jobsFirebaseList jobs. Optional: status, stationId, expand=details.
PATCH/print-jobs/:idFirebaseUpdate job status. Body: { status, lastError? }.
GET/analyticsFirebasePrep-time analytics. Optional: stationId, productId, dateFrom, dateTo.
GET/stations/printer-healthFirebasePrinter online/offline status for all stations at location.

Product-Station Assignments

MethodPathAuthDescription
POST/product-station-assignmentsFirebaseCreate assignment (requires productId or categoryId).
GET/product-station-assignmentsFirebaseList assignments (supports locationId, stationId, productId, categoryId, isActive).
PATCH/product-station-assignments/:idFirebaseUpdate assignment.
DELETE/product-station-assignments/:idFirebaseDelete 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)
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

StepActionImplemented?
1kitchen_ticket.status → "bumped", bumpedAt = NOW(), bumpedBy = employeeId
2Emit ticket:bumped WebSocket event to all devices in station room
3order_item.status → "ready" (kitchen done, awaiting pickup)⚠️ Not yet implemented — must be done manually on the Orders page
4print_job.statusdo not change (managed by printer infrastructure)

Gap: Bumping a kitchen ticket does not automatically advance order_item.status to ready. Kitchen staff must currently switch to the Orders page to mark items as ready. This is a planned improvement.


Common Mistakes

MistakeWhy it's wrong
Building the admin KDS view on top of print_jobRestaurants without printers would see nothing; kitchen_ticket is always created
Bumping a ticket → also marking print_job as printedprint_job tracks printer hardware, not kitchen acknowledgement
Equating kitchen_ticket.bumped with order_item.servedBumped = kitchen done; served = delivered to guest — waiter must still deliver
Filtering admin KDS view by order_item.statusMixes kitchen prep state with service lifecycle — use kitchen_ticket.status instead
Using print_job analytics (getAnalytics) for KDS-only stationsAnalytics endpoint returns print_job data; use getAggregatedItemsByStation for KDS-only
Storing the raw deviceToken in the databaseOnly 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 ticketsBullMQ retries the job on failure — duplicate tickets would appear on the KDS screen

Infrastructure

BullMQ Queues

QueueProcessorAttemptsBackoff
deliver-kitchen-ticketDeliverKitchenTicketProcessor3Exponential, 2 s base
printer-health-checkPrinterHealthProcessor1None

Job IDs use ${orderItemId}:${stationId} for idempotency — re-sending to kitchen is safe.

Redis Keys

Key patternValueTTL
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.