# Orders

Customer orders and order management

## GET /api/v1/kitchen-dispatch-items

List kitchen dispatch items (KDS 2)

Returns a flat list of kitchen dispatch items with their parent kitchen-dispatch and tab context embedded, for the item-first KDS 2 display. The `state` filter targets the per-item state (kitchen_dispatch_items.item_state). Pagination is over root items; variant children always accompany their parent. All prices are in cents.

Query parameters:
- `page` (integer, optional)
- `limit` (integer, optional)
- `state` (string | string[], optional) — Item state(s) to include (single value or array)
- `printerGroupIds` (string | string[], optional) — Keep only items whose root belongs to one of these prep (printer) groups
- `tabType` (string | string[], optional) — Filter by tab type (e.g. table, takeaway)
- `source` (string | string[], optional) — Order channel/source: POS, ONLINE, QR, KIOSK
- `startDate` (string, optional) — ISO start of parent dispatch created_at range
- `endDate` (string, optional) — ISO end of parent dispatch created_at range
- `excludeAbortedTabs` (boolean, optional) — Hide items whose parent tab is aborted
- `sort` ("created_at" | "kitchen_sort_order", optional)
- `order` ("asc" | "desc", optional)

Response 200 — Flat list of kitchen dispatch items

```ts
{
  status?: string
  data?: {
    items?: Array<{
        amounts?: { … }
        vatRateBasisPoints?: integer | null
      }>
    pagination?: {
      page?: integer
      limit?: integer
      total?: integer
      totalPages?: integer
      hasMore?: boolean
    }
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## DELETE /api/v1/kitchen-dispatch-items/{id}

Delete kitchen dispatch item

Remove a single item from an order. Disallowed when the item is already paid or has a settlement in progress.

Path parameters:
- `id` (string, required) — Kitchen dispatch item UUID

Response 200 — Successfully deleted item

```ts
{
  status?: "success"
  data?: {
    id: string // uuid
    kitchenDispatchId: string // uuid
    tabId: string // uuid
    kitchenDispatchEmptied: boolean
    deleted: boolean
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/kitchen-dispatch-items/{id}/split

Get split order items

Get all items that were split from an order item for bill splitting

Path parameters:
- `id` (string, required) — Kitchen dispatch item UUID (the split_of_kitchen_dispatch_item reference)

Response 200 — Successfully retrieved split items

```ts
{
  status?: "success"
  data?: {
    items: Array<{
        uuid: string // uuid
        basePrice: number
        modificationPrice: number
        price: number
        title: string
        currencyCode: string
        modifications: unknown[]
      }>
    originalItemId: string // uuid
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/kitchen-dispatch-items/{id}/split

Split order item

Split an order item into multiple parts for bill splitting with equal pricing

Path parameters:
- `id` (string, required) — Kitchen dispatch item UUID

Request body:

```ts
{
  numberOfParts: number
  tabId: string // uuid
}
```

Response 200 — Successfully split the item

```ts
{
  status?: "success"
  data?: {
    splitItemIds: string[]
    originalItemId: string // uuid
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## PATCH /api/v1/kitchen-dispatch-items/{id}/takeaway

Toggle takeaway status

Toggle takeaway status on a kitchen dispatch item

Path parameters:
- `id` (string, required) — Kitchen dispatch item UUID

Request body:

```ts
{
  is_takeaway: boolean
}
```

Response 200 — Successfully toggled takeaway status

```ts
{
  status?: string
  data?: {
    uuid?: string
    isTakeaway?: boolean
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/kitchen-dispatch-items/{id}/void

Void a kitchen dispatch item

Set item_state to "aborted" on a kitchen dispatch item. Requires orders.void permission. Rejects an item that has been paid for, and any void that would leave the tab paid more than it can still charge. When reasonCode is "other", reasonNote is required.

Path parameters:
- `id` (string, required) — Kitchen dispatch item UUID

Request body:

```ts
{
  reasonCode: "staff_mistake" | "customer_change" | "wrong_make" | "quality_issue" | "out_of_stock" | "allergy" | "other"
  reasonNote?: string | null
  quantityCents?: integer | null
  suppressKorrbong?: boolean
}
```

Response 200 — Successfully voided the item

```ts
{
  status?: "success"
  data?: {
    kitchenDispatchItemId: string // uuid
    previousItemState: string | null
    newItemState: "aborted"
    reasonCode: "staff_mistake" | "customer_change" | "wrong_make" | "quality_issue" | "out_of_stock" | "allergy" | "other"
    reasonNote: string | null
    dispatchAborted: boolean
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/kitchen-dispatch-items/advance

Advance kitchen dispatch items by ID (KDS 2)

Requires `orders.update`. Forward-only advance of explicit kitchen_dispatch_item IDs to a target item state. Item IDs may span multiple kitchen dispatches; each dispatch is locked and advanced independently, its latest_state re-derived from the new minimum item state, and the same SMS milestone + order.updated webhook side effects fire as the per-order advance endpoint. Held items (not_sent, staged_awaiting_payment, waiting_for_merge) and aborted items are never swept forward.

Request body:

```ts
{
  itemIds: string[]
  state: "pending" | "accepted" | "in_preparation" | "ready_to_deliver" | "delivered" | "rejected"
}
```

Response 200 — Advance applied

```ts
{
  status?: string
  data?: {
    itemsAdvanced?: integer
    dispatches?: Record<string, unknown>[]
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/kitchen-dispatch-items/void-batch

Void one or more kitchen dispatch items

Set item_state to "aborted" on multiple kitchen dispatch items in one request. Items may span multiple parent dispatches; one `order.reversed` event is emitted per affected KD. Requires orders.void permission. Rejects when ANY affected tab has purchases. When reasonCode is "other", reasonNote is required.

Request body:

```ts
{
  reasonCode: "staff_mistake" | "customer_change" | "wrong_make" | "quality_issue" | "out_of_stock" | "allergy" | "other"
  reasonNote?: string | null
  items: Array<{
      id: string // uuid
      quantityCents?: integer | null
    }>
  suppressKorrbong?: boolean
}
```

Response 200 — Successfully voided all items in the batch

```ts
{
  status?: "success"
  data?: {
    reasonCode: "staff_mistake" | "customer_change" | "wrong_make" | "quality_issue" | "out_of_stock" | "allergy" | "other"
    reasonNote: string | null
    results: Array<{
        kitchenDispatchItemId: string // uuid
        kitchenDispatchId: string // uuid
        previousItemState: string | null
        newItemState: "aborted"
        dispatchAborted: boolean
      }>
    affectedDispatchIds: string[]
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/orders

List orders

Retrieve a paginated list of orders with filtering capabilities.

**Hybrid Dual-Fetch Architecture:**
- PostgreSQL for recent orders (<30 minutes old) - ensures freshness
- BigQuery for historical orders (>30 minutes old) - scalability
- Automatic merge when date range spans both sources

**KDS/Real-Time Mode:**
- Use `forcePostgres=true` to always query PostgreSQL only
- This ensures fresh state data for Kitchen Display Systems
- Recommended for any real-time display needing immediate state updates

**Default Behavior:**
- Returns last 24 hours if no date filters specified
- Sorted by created_at descending (newest first)
- Items not included by default (use includeItems=true)

**Performance Notes:**
- BigQuery queries typically take 1-3 seconds
- For real-time displays, use forcePostgres=true or /api/v1/orders/stream endpoint

**Permissions:** 🔵 `orders.read`

Query parameters:
- `page` (number, optional) — Page number
- `limit` (number, optional) — Items per page (max 1000)
- `includeItems` (boolean, optional) — Include order items in response. Items are returned FLAT with parentKitchenDispatchItemId for hierarchy building.
- `includeNotes` (boolean, optional) — Include tab notes attached to each order (notes are polymorphic, keyed by tab uuid). Used by the KDS.
- `includeChildren` (boolean, optional) — Include authorized descendants of the selected X-Location-Id.
- `locationIds` (string, optional) — Optional comma-separated subset of the resolved location hierarchy.
- `forcePostgres` (boolean, optional) — Force PostgreSQL only (skip BigQuery). Use for KDS/real-time displays where state must be fresh.
- `sharedKitchen` (boolean, optional) — KDS-only: at a location that owns a prep group with owner_printers_serve_children, also return its descendant venues' orders, with items limited to those routed to the shared groups. No effect elsewhere.
- `keepActiveBeyondLimit` (boolean, optional) — KDS-only: apply `limit` to terminal/done dispatches only and always return every active (in-progress) dispatch in the window, so an open ticket never ages out of the newest-N set at a high-volume venue. Leave off for paginated history/audit views.
- `excludeAbortedTabs` (boolean, optional) — Hide KDs whose parent tab was force-closed (aborted). Use for the active KDS so staff don't see orphan tickets they cannot advance. Leave off for order history and audit views.
- `dailyOrderNumber` (number, optional) — Filter by daily order number
- `state` (string | string[], optional) — Filter by state(s)
- `tabType` (string | string[], optional) — Filter by tab type(s)
- `tableId` (string, optional) — Filter by table/QR code ID
- `tabMemberEmail` (string, optional) — Filter by tab member email (partial match)
- `minPrice` (number, optional) — Minimum total price in cents
- `maxPrice` (number, optional) — Maximum total price in cents
- `source` (string | string[], optional) — Filter by order source/channel (POS, ONLINE, QR, KIOSK)
- `itemId` (number, optional) — Filter orders containing a specific inventory item ID
- `printerGroupIds` (string | string[], optional) — Filter by prep group (printer group) IDs
- `scopeToUser` (boolean, optional) — Narrow to the orders credited to the calling staff member. Attribution is per line item — who rang the item in, falling back to the tab's initiator — the same rule the POS sales report and the purchase history use. There is deliberately no parameter for scoping to another staff member: narrowing is only ever to the caller. Requires a staff (retailer user) token.
- `startDate` (string, optional) — Filter orders created after this date (date or date-time; default: 24h ago)
- `endDate` (string, optional) — Filter orders created before this date (date-only = inclusive end-of-day; default: now)
- `sort` ("created_at" | "daily_ordinal_number" | "updated_at", optional) — Field to sort by
- `order` ("asc" | "desc", optional) — Sort order

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    items: Array<{
        amounts?: { … }
        currentVatBreakdown?: unknown[]
        customFields?: unknown[]
        metadata?: { … } | null
        uuid: string // uuid
        locationId: number
        tabId?: string | null // uuid
        dailyOrderNumber?: number | null
        externalOrderId?: string | null
        externalOrderNumber?: string | null
        state: "not_sent" | "pending" | "accepted" | "in_preparation" | "ready_to_deliver" | "delivered" | "rejected" | "rejected_and_acknowledged" | "staged_awaiting_payment" | "scheduled" | "aborted"
        orderType?: "TAKEAWAY" | "OTC" | "QR" | "TABLE"
        orderChannel?: "ONLINE" | "QR" | "KIOSK" | "POS"
        createdAt: string // date-time
        updatedAt: string // date-time
        completedAt?: string | null // date-time
        tabType?: string | null
        storefrontQrCodeId?: string | null
        tabMemberEmail?: string | null // email
        tabMemberName?: string | null
        tabMemberImageUrl?: string | null
        memberCount?: number | null
        totalPrice?: number | null
        itemCount?: number | null
        totalVatAmountCents?: number | null
        vatBreakdown?: unknown[] | null
        message?: string | null
        source?: string | null
        venueName?: string | null
        tableName?: string | null
        items?: unknown[] | null
        notes?: unknown[] | null
        events?: unknown[]
        desiredPickupTime?: string | null // date-time
        storeCommitmentTimeout?: string | null // date-time
        paymentBlocked?: boolean
        printTickets?: unknown[]
        deliveredAt?: string | null // date-time
      }>
    pagination: {
      page: number
      limit: number
      total: number
      totalPages: number
    }
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/orders/{kitchenDispatchId}/cancel

Cancel order

Cancel a kitchen dispatch (order) by setting its state to aborted. Only early-stage orders can be cancelled.

**Permissions:** 🟠 `inventory.update`

Path parameters:
- `kitchenDispatchId` (string, required) — Kitchen dispatch UUID to cancel

Request body:

```ts
Record<string, unknown>
```

Response 200 — Order cancelled successfully

```ts
{
  status: "success"
  data: {
    kitchenDispatchId: string // uuid
    previousState: string
    newState: "aborted"
    cancelledAt: string // date-time
    cancelledBy: number
    reason: string | null
    tabId: string // uuid
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/orders/{kitchenDispatchId}/sync

Sync (atomic void + insert) lines on a kitchen dispatch

Atomically void a set of existing kitchen_dispatch_items AND insert a set of new ones in ONE transaction, ONE webhook fan-out.

Cafe-mode "edit committed items" primitive. The earlier two-call shape (POST /void-batch + POST /append-to-dispatch in parallel) is replaced — clients describe the diff, the server applies it atomically.

**Maps cleanly to actions:**
- `+` on a committed line → `{ voids: [], inserts: [oneItem] }`
- `−` on a committed line → `{ voids: [kdiId], inserts: [] }`
- Variant edit → `{ voids: [originalKdiId], inserts: [newSpec] }`

**Guards:**
- Dispatch must exist, belong to the supplied tab + location, and be in an appendable state (`pending`/`accepted`/`in_preparation`/`ready_to_deliver`).
- Tab must not be closed/abandoned.
- Voids: an item that has been paid for rejects with 422 `ITEMS_ALREADY_PAID`, and a sync that would leave the tab paid past what it can still charge rejects with 422 `TAB_WOULD_BE_OVERPAID` — the inserts are counted against the voids first. Already-aborted items pass through (idempotent, `wasAlreadyAborted: true` in the response).
- Voided items not on this dispatch reject with 404 `KITCHEN_DISPATCH_ITEM_NOT_FOUND`.
- Inserts: items + variants validated identically to `/orders/create`.

**Side effects (one fan-out):**
- `order.reversed` webhook (printer KORRBONG) — skipped when `suppressKorrbong: true` (cafe mode).
- `order.updated` webhook with `changes.syncedLines` (KDS / cross-device refetch).
- `tab.updated` webhook with `changes.kitchenDispatchSynced` (cashier tab refetch).
- `inventory.updated` per ministock change.
- Per-item `kitchen_dispatch_item.voided` platform_event for the audit trail.

**Permissions:** 🟢 `inventory.create`

Path parameters:
- `kitchenDispatchId` (string, required) — UUID of the kitchen dispatch to sync

Request body:

```ts
{
  tabId: string // uuid
  locationId: number
  order_channel: "ONLINE" | "QR" | "KIOSK" | "POS"
  voids: string[]
  inserts: Array<{
      id: number
      quantity_cents: number
      measurement_amount?: unknown
      measurement_amount_micros?: integer
      price_cents?: number | null
      variants: { … }[]
      comments: string[]
      tab_member_id?: string | null // uuid
      coursing_order?: number | null
      upsell_source?: "ai" | "manual"
      upsell_session_id?: string | null // uuid
      upsell_recommendation_id?: string | null // uuid
    }>
  suppressKorrbong?: boolean
  voidReasonCode?: "staff_mistake" | "customer_change" | "wrong_make" | "quality_issue" | "out_of_stock" | "allergy" | "other"
  voidReasonNote?: string | null
}
```

Response 200 — Lines synced successfully

```ts
{
  status?: "success"
  data?: {
    kitchenDispatchId?: string // uuid
    tabId?: string // uuid
    inheritedItemState?: string
    dispatchAborted?: boolean
    voided?: Array<{
        kitchenDispatchItemId?: string // uuid
        previousItemState?: string | null
        newItemState?: "aborted"
        wasAlreadyAborted?: boolean
      }>
    inserted?: Array<{
        kitchen_dispatch_item_id?: string // uuid
        item_id?: number
        quantity?: number
        quantity_cents?: number
        price_cents?: number
        measurement?: { … } | null
        status?: string | null
      }>
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/orders/{orderId}

Get order by ID

Retrieve detailed information about a specific order including all items.

Items are always included in the response for single order queries.

**Permissions:** 🔵 `orders.read`

Path parameters:
- `orderId` (string, required) — Order ID (kitchen_dispatch.uuid)

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    amounts?: {
      grossAmountCents: integer
      vatAmountCents: integer | null
      netAmountCents: integer | null
      basis: "current"
      vatComplete: boolean
    }
    currentVatBreakdown?: Array<{
        grossAmountCents: integer
        vatAmountCents: integer | null
        netAmountCents: integer | null
        basis: "current"
        vatComplete: boolean
        vatRateBasisPoints: integer | null
      }>
    customFields?: Array<{
        fieldId?: string
        key?: string
        label?: string
        group?: string
        type?: string
        displayValue?: string
        value?: unknown // The stored value; its JSON type follows the field type
        version?: integer
        status?: string
      }>
    metadata?: {
      customFields?: Record<string, unknown>
      provider?: "FOODORA" | "UBER_EATS" | "WOLT" | "THIRD_PARTY"
      customerName?: string
      customerPhone?: string
      fulfilment?: "delivery" | "pickup" | "eatin" | "self_delivery"
      deliveryAddress?: {
        street?: string
        city?: string
        postalCode?: string
        formattedAddress?: string
        comment?: string
      }
      pricing?: {
        discountCents?: number
        merchantDiscountCents?: number
        discountUnattributedCents?: number
        grossByLine?: { … }
        fees?: { … }
        platform?: { … }
      }
      acceptance?: {
        twoStep?: boolean
        acceptedAt?: string
      }
    } | null
    uuid: string // uuid
    locationId: number
    tabId?: string | null // uuid
    dailyOrderNumber?: number | null
    externalOrderId?: string | null
    externalOrderNumber?: string | null
    state: "not_sent" | "pending" | "accepted" | "in_preparation" | "ready_to_deliver" | "delivered" | "rejected" | "rejected_and_acknowledged" | "staged_awaiting_payment" | "scheduled" | "aborted"
    orderType?: "TAKEAWAY" | "OTC" | "QR" | "TABLE"
    orderChannel?: "ONLINE" | "QR" | "KIOSK" | "POS"
    createdAt: string // date-time
    updatedAt: string // date-time
    completedAt?: string | null // date-time
    tabType?: string | null
    storefrontQrCodeId?: string | null
    tabMemberEmail?: string | null // email
    tabMemberName?: string | null
    tabMemberImageUrl?: string | null
    memberCount?: number | null
    totalPrice?: number | null
    itemCount?: number | null
    totalVatAmountCents?: number | null
    vatBreakdown?: Array<{
        vatRateBasisPoints: number
        vatAmountCents: number
      }> | null
    message?: string | null
    source?: string | null
    venueName?: string | null
    tableName?: string | null
    items?: Array<{
        amounts?: { … }
        uuid: string // uuid
        itemId?: number | null
        title?: string | null
        titlePrep?: string | null
        imageUrl?: string | null
        quantity: number
        quantityCents: number
        measurement?: { … } | null
        price: number
        basePrice: number
        discount?: number | null
        status?: string | null
        comments?: unknown
        variants?: unknown[] | null
        tabMemberId?: string | null // uuid
        createdAt: string // date-time
        servingCenterId?: number | null
        printerGroupId?: string | null // uuid
        tabMemberEmail?: string | null // email
        tabMemberName?: string | null
        parentKitchenDispatchItemId?: string | null // uuid
        itemState?: string | null
        firedAt?: string | null // date-time
        salesItemType?: string | null
        surplusEntryId?: number | null
        zeroTicket?: boolean
        isTakeaway?: boolean
        vatRateBasisPoints?: number | null
        vatAmountCents?: number | null
      }> | null
    notes?: Array<{
        uuid?: string
        entityType?: string
        entityId?: string
        note?: string
        locationId?: number
        sortOrder?: number
        createdByRetailerUserId?: number | null
        createdByName?: string | null
        updatedByRetailerUserId?: number | null
        updatedByName?: string | null
        createdAt?: string // date-time
        updatedAt?: string // date-time
      }> | null
    events?: Array<{
        event: string
        created_at: string // date-time
      }>
    desiredPickupTime?: string | null // date-time
    storeCommitmentTimeout?: string | null // date-time
    paymentBlocked?: boolean
    printTickets?: Array<{
        destinationKey: string
        latestJobId: string // uuid
        printerGroupId: string // uuid
        printerGroupName?: string | null
        printerId?: string | null // uuid
        printerName?: string | null
        printerOnline?: boolean | null
        state: "printed" | "pending" | "failed"
        attempts: number
        failedAttempts?: number
        firstCreatedAt?: string // date-time
        lastUpdatedAt?: string // date-time
        failureReason?: string | null
        jobs?: unknown[]
      }>
    deliveredAt?: string | null // date-time
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## PATCH /api/v1/orders/{orderId}

Update orders custom fields

Custom fields: send customFields: [{ "key": "your_field_key", "value": "your_value", "expectedVersion": 0 }]. Use either fieldId or a stable key. expectedVersion is optional; supply the last-read version to reject stale edits. Omitted fields are unchanged; null clears an optional value. See [Custom field inputs](/docs/custom-fields#values-on-normal-create-and-update-calls).

Path parameters:
- `orderId` (string, required)

Request body:

```ts
{
  customFields: unknown | unknown[]
}
```

Response 200 — Default Response

```ts
{
  status?: string
  data?: {
    id?: string
    locationId?: integer
    customFields?: Array<{
        fieldId?: string
        key?: string
        label?: string
        group?: string
        type?: string
        displayValue?: string
        value?: unknown // The stored value; its JSON type follows the field type
        version?: integer
        status?: string
      }>
    metadata?: {
      customFields?: Record<string, unknown>
    } | null
  }
}
```

## POST /api/v1/orders/{orderId}/accept

Accept a marketplace order

Accept a third-party (marketplace) order — the promise to the marketplace, separated from the KDS lane move.

**Acceptance mode** (stamped on the dispatch at intake from the integration's `twoStepAcceptance` setting):
- **Two-step**: records `metadata.acceptance.acceptedAt` only. The card stays in the Incoming lane with an accepted badge; staff move it to In progress with the normal state/items advance.
- **Combined** (default): records the acceptance AND transitions the dispatch to `accepted` in the same call — the historical single-click behaviour.

Either way the emitted `order.updated` triggers the accept push to the originating marketplace.

**Idempotency:** an already-accepted order (or one already past acceptance) returns 200 without re-emitting, so a double-tap cannot double-push.

Only valid for marketplace orders (`metadata.provider` present); first-party orders use the state transition endpoint.

**Permissions:** 🟠 `orders.update`

Path parameters:
- `orderId` (string, required) — Order ID (kitchen_dispatch.uuid)

Request body:

```ts
{
  prepTimeExtraMinutes?: unknown // Extra minutes of preparation time the kitchen bought when accepting, as a whole number of minutes. Same semantics and s…
}
```

Response 200 — Default Response

```ts
{
  status?: "success"
  data?: {
    orderId?: string // uuid
    acceptedAt?: string | null
    alreadyAccepted?: boolean
    twoStep?: boolean
    previousState?: string
    state?: string
    tabId?: string // uuid
  }
  meta?: Record<string, unknown>
}
```

## POST /api/v1/orders/{orderId}/advance-items

Advance order item states

Advance item-level states for a kitchen dispatch (order).

**Item-Level State Tracking:**
- Each item independently tracks its own state
- The overall order state is automatically derived as the earliest non-not_sent item state
- Forward-only: items can only advance forward, never backward

**Filtering Options:**
- No filter: advance ALL items in the order
- printerGroupId: advance only items in a specific prep group
- coursingOrder: advance only items in a specific course

**Valid Target States:**
- pending, accepted, in_preparation, ready_to_deliver, delivered, rejected

**Webhook:**
- Emits order.updated with targetItemState and optional printerGroupId

**Permissions:** 🟠 `orders.update`

Path parameters:
- `orderId` (string, required) — Order ID (kitchen_dispatch.uuid)

Request body:

```ts
{
  state: "pending" | "accepted" | "in_preparation" | "ready_to_deliver" | "delivered" | "rejected"
  printerGroupId?: string // uuid
  coursingOrder?: integer
}
```

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    orderId: string // uuid
    targetState: string
    itemsAdvanced: number
    derivedOrderState: string
    previousOrderState: string
    heldItems?: Record<string, unknown>
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## PATCH /api/v1/orders/{orderId}/edit-items

Edit items on a staged order

Edit item quantities on a kitchen dispatch in `staged_awaiting_payment` state.

Replaces the cancel→re-commit pattern previously used for storefront-web pay_upfront tab editing.

**Per item:**
- `quantityCents > 0` — UPDATE quantity, adjust ministock delta
- `quantityCents === 0` — DELETE the item (and its children), restore ministock

**Guards:**
- Dispatch must be in `staged_awaiting_payment` state
- No item on the dispatch may have `paid_amount > 0`
- Ministock checked for quantity increases
- Location access validated

**Side effects:**
- Tab total recalculated
- Emits `order.updated` webhook

**Permissions:** 🟠 `inventory.update`

Path parameters:
- `orderId` (string, required) — Kitchen dispatch UUID to edit

Request body:

```ts
{
  items: Array<{
      kitchenDispatchItemId: string // uuid
      quantityCents: number
    }>
}
```

Response 200 — Items edited successfully

```ts
{
  status: "success"
  data: {
    orderId?: string // uuid
    updated?: number
    removed?: number
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## PATCH /api/v1/orders/{orderId}/item-comments

Replace kitchen notes on order lines

Replace the kitchen notes (`kitchen_dispatch_items.comments`) on one or more lines of a committed dispatch.

The KDS writes here when a cook or waiter adds a note ("GLUTENFRI") to a line that is already committed. The POS writes its pre-commit notes at insert time instead, through `/orders/create` and `/orders/:id/sync`.

**Semantics:**
- Replace, not append — send the line's whole note list. `comments: []` clears it.
- Several items per call: a KDS row can stand for several identical lines, and they are written in one transaction.

**Side effects:**
- Emits `order.updated`, which refreshes every KDS and POS screen on the location.
- Prints nothing: printer-service does not subscribe to `order.updated`.
- One `kitchen_dispatch_item.comment_updated` platform event per line, naming the retailer user.

**Permissions:** 🟠 `orders.update`

Path parameters:
- `orderId` (string, required) — Kitchen dispatch UUID the lines belong to

Request body:

```ts
{
  items: Array<{
      kitchenDispatchItemId: string // uuid
      comments: string[]
    }>
}
```

Response 200 — Notes updated

```ts
{
  status: "success"
  data: {
    orderId?: string // uuid
    tabId?: string | null
    items?: Array<{
        uuid?: string // uuid
        comments?: unknown[]
      }>
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/orders/{orderId}/items/{itemId}/status

Update item status

Update the status of a kitchen dispatch item or modification. Requires staff authentication (JWT). User ID for audit trail is extracted from the auth token.

Path parameters:
- `orderId` (string, required) — Kitchen dispatch (order) UUID
- `itemId` (string, required) — Kitchen dispatch item UUID

Request body:

```ts
{
  status: "COMPLETED" | "UNAVAILABLE" | null
  modificationId?: number
}
```

Response 200 — Status updated successfully

```ts
{
  status?: "success"
  data?: {
    success?: boolean
    itemId?: string // uuid
    status?: "COMPLETED" | "UNAVAILABLE" | null
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/orders/{orderId}/items/{itemId}/status/logs

Get item status logs

Get the status change history for a kitchen dispatch item

Path parameters:
- `orderId` (string, required) — Kitchen dispatch (order) UUID
- `itemId` (string, required) — Kitchen dispatch item UUID

Response 200 — Status logs retrieved successfully

```ts
{
  status?: "success"
  data?: {
    logs?: Array<{
        id?: number
        itemId?: string
        status?: …
        isModification?: boolean
        retailerUserId?: string | null
        createdAt?: string // date-time
        updatedAt?: string | null // date-time
      }>
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/orders/{orderId}/items/status

Batch update item statuses

Batch update the status of multiple kitchen dispatch items and/or modifications. Requires staff authentication (JWT). User ID for audit trail is extracted from the auth token.

Path parameters:
- `orderId` (string, required) — Kitchen dispatch (order) UUID

Request body:

```ts
{
  updates: Array<{
      id: string
      status: "COMPLETED" | "UNAVAILABLE" | null
      isModification?: boolean
    }>
}
```

Response 200 — Batch update completed

```ts
{
  status?: "success"
  data?: {
    success?: boolean
    updatedCount?: number
    failedItems?: Array<{
        id?: string
        isModification?: boolean
        error?: string
      }>
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/orders/{orderId}/serving-center-state

Update serving center state

Update the state of a specific serving center for an order.

**Serving Center State Management:**
- Each serving center independently tracks its state for each order
- The overall order state is derived as the earliest (most backward) state across all serving centers
- When all serving centers advance, the overall order state advances too

**Valid Serving Center States (forward-only):**
- pending → accepted → in_preparation → ready_to_deliver → delivered
- rejected (terminal)

**Constraints:**
- Cannot set serving center state earlier than the overall order state
- Forward-only transitions (can't go backward)

**Webhook:**
- Emits order.updated with servingCenterId and servingCenterState
- Used by common-functions to send serving center SMS notifications

**Replaces:** GraphQL mutation `updateServingCenterKitchenDispatchState` from Karma-Merchant-API

**Permissions:** 🟠 `inventory.update`

Path parameters:
- `orderId` (string, required) — Order ID (kitchen_dispatch.uuid)

Request body:

```ts
{
  servingCenterId: number
  state: "pending" | "accepted" | "in_preparation" | "ready_to_deliver" | "delivered" | "rejected"
}
```

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    orderId: string // uuid
    servingCenterId: number
    previousServingCenterState?: string | null
    newServingCenterState: string
    orderState: string
    orderStateChanged: boolean
    tabId?: string // uuid
    updatedAt?: string // date-time
    updatedBy?: number
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/orders/{orderId}/state

Update order state

Update the state of an order (kitchen dispatch).

**Event Sourcing:**
- Inserts an event into kitchen_dispatch_events table
- PostgreSQL trigger automatically updates kitchen_dispatches.latest_state
- Emits order.updated webhook for real-time UI updates

**Valid State Transitions:**
- `not_sent` → pending, delivered, aborted
- `pending` → accepted, rejected, aborted
- `scheduled` → accepted, pending, aborted
- `accepted` → in_preparation, ready_to_deliver, aborted
- `in_preparation` → ready_to_deliver
- `ready_to_deliver` → delivered
- `rejected` → rejected_and_acknowledged
- `aborted` → rejected_and_acknowledged (KDS bump on voided dispatch)
- `staged_awaiting_payment` → pending, aborted

**Terminal States (no transitions):**
- delivered, rejected_and_acknowledged

**Idempotency:**
- Calling with current state returns 200 success (no-op)
- Safe for retries in distributed systems

**Permissions:** 🟠 `orders.update`

Path parameters:
- `orderId` (string, required) — Order ID (kitchen_dispatch.uuid)

Request body:

```ts
{
  state: "not_sent" | "pending" | "accepted" | "in_preparation" | "ready_to_deliver" | "delivered" | "rejected" | "rejected_and_acknowledged" | "staged_awaiting_payment" | "scheduled" | "aborted"
  rejectionReason?: string
  prepTimeExtraMinutes?: unknown // Extra minutes of preparation time the kitchen bought when accepting, as a whole number of minutes. Only meaningful when…
}
```

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    orderId: string // uuid
    previousState: string
    newState: string
    tabId: string // uuid
    updatedAt?: string // date-time
    updatedBy?: number
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/orders/append-to-dispatch

Append items to an existing kitchen dispatch

Append items to an EXISTING kitchen_dispatch so they show up on the same KDS card the barista is already on.

Used by the cafe-mode "edit committed items" flow — instead of a void creating a fresh ticket, the cashier voids the original line and appends the replacement against the same dispatch ID.

**Guards:**
- Dispatch must exist and belong to the supplied tab.
- Dispatch state must be one of: `pending`, `accepted`, `in_preparation`, `ready_to_deliver`. Any other state (terminal, held, merged, staged) is rejected with 409 `DISPATCH_NOT_APPENDABLE`.
- Tab must not be closed/abandoned.
- Items + variants validated via the same path as `/orders/create`.

**Side effects:**
- New `kitchen_dispatch_items` inserted under the existing dispatch.
- Ministock decremented for items that track it.
- `order.items_added` webhook emitted (resource id = dispatch id).
- `tab.updated` webhook emitted with `kitchenDispatchUpdated: { dispatchId, itemCount }`.

**Permissions:** 🟢 `inventory.create`

Custom fields: send customFields: [{ "key": "your_field_key", "value": "your_value", "expectedVersion": 0 }]. Use either fieldId or a stable key. expectedVersion is optional; supply the last-read version to reject stale edits. Omitted fields are unchanged; null clears an optional value. See [Custom field inputs](/docs/custom-fields#values-on-normal-create-and-update-calls).

Request body:

```ts
{
  kitchenDispatchId: string // uuid
  tabId: string // uuid
  locationId: number
  order_channel: "ONLINE" | "QR" | "KIOSK" | "POS"
  items: Array<{
      id: number
      measurement_amount?: unknown
      measurement_amount_micros?: integer
      quantity_cents: number
      price_cents?: number | null
      variants: { … }[]
      comments: string[]
      tab_member_id?: string | null // uuid
      coursing_order?: number | null
      upsell_source?: "ai" | "manual"
      upsell_session_id?: string | null // uuid
      upsell_recommendation_id?: string | null // uuid
    }>
  customFields?: unknown | unknown[]
}
```

Response 200 — Items appended successfully

```ts
{
  status?: "success"
  data?: {
    kitchenDispatchId?: string // uuid
    tabId?: string // uuid
    dailyOrderNumber?: number | null
    inheritedItemState?: string
    appendedItems?: Array<{
        kitchen_dispatch_item_id?: string // uuid
        item_id?: number
        quantity?: number
        quantity_cents?: number
        price_cents?: number
        measurement?: { … } | null
        status?: string | null
      }>
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/orders/create

Create order

Unified Order Creation API - handles all order creation from POS, Storefront, Kiosk, and QR code orders

**Permissions:** 🟢 `inventory.create`

Custom fields: send customFields: [{ "key": "your_field_key", "value": "your_value", "expectedVersion": 0 }]. Use either fieldId or a stable key. expectedVersion is optional; supply the last-read version to reject stale edits. Omitted fields are unchanged; null clears an optional value. See [Custom field inputs](/docs/custom-fields#values-on-normal-create-and-update-calls).

Request body:

```ts
{
  locationId: number
  order_channel: "ONLINE" | "QR" | "KIOSK" | "POS"
  order_type: "TAKEAWAY" | "OTC" | "QR" | "TABLE" | "SURPLUS"
  order_destination: "QR" | "TAB" | "KD" | "NONE"
  order_destination_id?: string | null // uuid
  tab_member?: {
    id?: number
    email?: string
    phone?: string
    identifier?: string
  } | null
  commerce?: {
    layoutId: string // uuid
    expectedShippingCents: integer
    expectedMerchandiseCents: integer
  }
  order_contact?: {
    phone?: string
    name?: string
    email?: string
  }
  items: Array<{
      id: number
      measurement_amount?: unknown
      measurement_amount_micros?: integer
      guest_field_answers?: unknown // Scope-bound guest form submission: schemaToken and answers
      quantity_cents: number
      price_cents?: number | null
      variants: { … }[]
      comments: string[]
      upsell_source?: "ai" | "manual"
      upsell_session_id?: string | null // uuid
      upsell_recommendation_id?: string | null // uuid
      fulfillment?: {
        method: "pickup" | "ship" | "digital" | "service"
        recipientEmail?: string // email
        address?: { … }
      }
      gift_card_context?: {
        send_as_gift?: boolean
        recipient_name?: string
        recipient_email?: string // email
        sender_name?: string
        sender_email?: string // email
        email_language?: "en" | "sv"
        personal_message?: string
        code?: string
        card_mode?: "ecard" | "physical"
        face_value_cents?: integer
        physical_card_fee_cents?: integer
        delivery_fee_cents?: integer
        delivery_method?: "pickup" | "ship"
        recipient_instructions?: string
        shipping_address?: { … }
        design_id?: string
      } | null
    }>
  vouchers?: string[]
  desiredPickupTime?: number | null
  hold?: boolean
  tab_notes?: string[]
  show_notes_on_map?: boolean
  tab_metadata?: Record<string, unknown> | null
  customFields?: unknown | unknown[]
}
```

Response 200 — Order created successfully

```ts
{
  status?: "success"
  data?: {
    kitchenDispatchId?: string // uuid
    dailyOrderNumber?: number
    tabId?: string // uuid
    tabMemberId?: string // uuid
    items?: Array<{
        kitchen_dispatch_item_id?: string // uuid
        measurement?: { … } | null
        item_id?: number
        quantity_cents?: number
        price_cents?: number
        variants?: unknown[]
        status?: string | null
      }>
    totalAmount?: number
    discountAmount?: number
    finalAmount?: number
    appliedVouchers?: Array<{
        voucherCode?: string
        discountAmountCents?: number
        discountType?: string
      }>
    invalidVouchers?: string[]
    estimatedReadyTime?: number | null
    paymentRequired?: boolean
    orderFlow?: string
    kitchenDispatchItemIds?: string[]
    minCommittedCoursingOrder?: number | null
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/orders/stream

Stream orders (SSE)

Stream orders using Server-Sent Events for real-time displays.

**Use Case:** Kitchen Display Systems (KDS) and real-time dashboards where latency matters.

**How It Works:**
1. PostgreSQL results are sent first (~100ms) - recent orders
2. BigQuery results follow when ready (~1-3 seconds) - historical orders
3. Completion event sent when all data is received

**SSE Events:**
- `postgres`: Orders from PostgreSQL (recent data)
- `bigquery`: Orders from BigQuery (historical data)
- `error`: Error from a specific source
- `complete`: All data has been sent

**Client Example:**
```javascript
const eventSource = new EventSource('/api/v1/orders/stream?limit=1000')

eventSource.addEventListener('postgres', (e) => {
  const { orders } = JSON.parse(e.data)
  // Show recent orders immediately!
  setOrders(prev => mergeAndSort([...prev, ...orders]))
})

eventSource.addEventListener('bigquery', (e) => {
  const { orders } = JSON.parse(e.data)
  // Fill in historical orders
  setOrders(prev => mergeAndSort([...prev, ...orders]))
})

eventSource.addEventListener('complete', () => {
  eventSource.close()
})
```

**Headers Required:**
- `Accept: text/event-stream`
- `X-Location-Id: {locationId}`

**Permissions:** 🔵 `orders.read`

Query parameters:
- `page` (number, optional) — Page number
- `limit` (number, optional) — Items per page (max 1000)
- `includeItems` (boolean, optional) — Include order items in response. Items are returned FLAT with parentKitchenDispatchItemId for hierarchy building.
- `includeNotes` (boolean, optional) — Include tab notes attached to each order (notes are polymorphic, keyed by tab uuid). Used by the KDS.
- `includeChildren` (boolean, optional) — Include authorized descendants of the selected X-Location-Id.
- `locationIds` (string, optional) — Optional comma-separated subset of the resolved location hierarchy.
- `forcePostgres` (boolean, optional) — Force PostgreSQL only (skip BigQuery). Use for KDS/real-time displays where state must be fresh.
- `sharedKitchen` (boolean, optional) — KDS-only: at a location that owns a prep group with owner_printers_serve_children, also return its descendant venues' orders, with items limited to those routed to the shared groups. No effect elsewhere.
- `keepActiveBeyondLimit` (boolean, optional) — KDS-only: apply `limit` to terminal/done dispatches only and always return every active (in-progress) dispatch in the window, so an open ticket never ages out of the newest-N set at a high-volume venue. Leave off for paginated history/audit views.
- `excludeAbortedTabs` (boolean, optional) — Hide KDs whose parent tab was force-closed (aborted). Use for the active KDS so staff don't see orphan tickets they cannot advance. Leave off for order history and audit views.
- `dailyOrderNumber` (number, optional) — Filter by daily order number
- `state` (string | string[], optional) — Filter by state(s)
- `tabType` (string | string[], optional) — Filter by tab type(s)
- `tableId` (string, optional) — Filter by table/QR code ID
- `tabMemberEmail` (string, optional) — Filter by tab member email (partial match)
- `minPrice` (number, optional) — Minimum total price in cents
- `maxPrice` (number, optional) — Maximum total price in cents
- `source` (string | string[], optional) — Filter by order source/channel (POS, ONLINE, QR, KIOSK)
- `itemId` (number, optional) — Filter orders containing a specific inventory item ID
- `printerGroupIds` (string | string[], optional) — Filter by prep group (printer group) IDs
- `scopeToUser` (boolean, optional) — Narrow to the orders credited to the calling staff member. Attribution is per line item — who rang the item in, falling back to the tab's initiator — the same rule the POS sales report and the purchase history use. There is deliberately no parameter for scoping to another staff member: narrowing is only ever to the caller. Requires a staff (retailer user) token.
- `startDate` (string, optional) — Filter orders created after this date (date or date-time; default: 24h ago)
- `endDate` (string, optional) — Filter orders created before this date (date-only = inclusive end-of-day; default: now)
- `sort` ("created_at" | "daily_ordinal_number" | "updated_at", optional) — Field to sort by
- `order` ("asc" | "desc", optional) — Sort order

Response 200 — Server-Sent Events stream (text/event-stream)

```ts
string
```
