# Tabs

Customer tabs and running bills

## GET /api/v1/tab/{tabId}/payment-methods

Get Adyen payment methods

Returns available Adyen payment methods for the Advanced flow. No purchase is created.

Query parameters:
- `amountCents` (number, required) — Payment amount in cents. 0 is a valid value: the storefront still hits this endpoint when the cart nets to 0 (voucher fully covers the order) and the handler short-circuits with an empty paymentMethodsResponse so the drawer can route to the complimentary settlement path without a PSP method selection.
- `currencyCode` (string, required) — ISO 4217 currency code (e.g. SEK, EUR)
- `countryCode` (string, optional) — ISO 3166-1 alpha-2 country code (e.g. SE, NL)
- `tabMemberId` (string, optional) — Optional tab member UUID. When supplied, the handler resolves the paying user and includes a shopperReference so Adyen returns storedPaymentMethods (saved cards) for the shopper. Omit for guest checkouts.
- `channel` ("iOS" | "Android" | "Web", optional) — Optional client platform. Forwarded to Adyen /paymentMethods so it returns platform-native wallets — notably Google Pay (returned only for channel=Android). Omit for web (Adyen defaults to Web behaviour).

Path parameters:
- `tabId` (string, required) — Tab UUID

Response 200 — Payment methods fetched successfully

```ts
{
  status?: "success"
  data?: {
    paymentMethodsResponse?: Record<string, unknown>
    clientKey?: string
    environment?: "test" | "live"
    adyenIntegrationId?: string | null // uuid
    freeOrder?: boolean
  }
  meta?: Record<string, unknown>
}
```

## POST /api/v1/tab/cancel

Cancel tab payment

Cancel a pending tab payment by purchase intent ID

**Permissions:** 🟠 `tabs.update`

Request body:

```ts
{
  purchase_intent_id: string // uuid
  requireConfirmedCancellation?: boolean
}
```

Response 200 — Payment canceled successfully

```ts
{
  status?: "success"
  data?: {
    purchase_intent_id?: string // uuid
    tab_settlement_id?: string // uuid
    tab_id?: string // uuid
    canceled?: boolean
  }
  meta?: Record<string, unknown>
}
```

## POST /api/v1/tab/cancel-all

Cancel all tab payments

Cancel all pending payments for a specific tab

**Permissions:** 🟠 `tabs.update`

Request body:

```ts
{
  tab_id: string // uuid
}
```

Response 200 — All payments canceled successfully

```ts
{
  status?: "success"
  data?: {
    tab_id?: string // uuid
    canceled_count?: number
    purchase_intent_ids?: string[]
  }
  meta?: Record<string, unknown>
}
```

## POST /api/v1/tab/pay

Pay for tab

Process a payment for a tab using various payment methods

**Permissions:** 🟠 `tabs.update`

Request body:

```ts
{
  tab_id: string // uuid
  tab_member_id?: string
  payment_method: "prepaid" | "invoice" | "swish" | "terminal" | "online" | "gift_card"
  kitchen_dispatch_items?: string[]
  amount_cents?: number
  pre_discount_amount_cents?: number
  terminal_id?: string // uuid
  return_url?: string // uri
  tip_amount_cents?: integer
  metadata?: Record<string, unknown>
  voucher_codes?: string[]
  recipient_id?: string // uuid
  reference_id?: string // uuid
  gift_card_code?: string
  gift_card_issuer_location_id?: integer
  payment_data?: Record<string, unknown>
  cancel_existing_payment?: boolean
  save_payment_method?: boolean
  selected_saved_payment_method_id?: string
}
```

Response 200 — Payment initiated successfully

```ts
{
  status?: "success"
  data?: {
    purchase_intent_id?: string // uuid
    tab_settlement_id?: string // uuid
    payment_method?: string
    payment_mode?: "ITEM" | "AMOUNT"
    mode_switched?: boolean
    terminal_id?: string | null // uuid
    amount_cents?: integer
    complimentary?: boolean
    psp_response?: {
      success?: boolean
      message?: string
      psp?: string
      type?: string
      resultCode?: string
      action?: Record<string, unknown>
      pspReference?: string
      refusalReason?: string
      refusalReasonCode?: string
      checkoutUrl?: string
      checkoutUrlFallback?: {
        purchaseIntentId?: string
        qrCodeSvgBase64?: string
      }
    }
  }
  meta?: Record<string, unknown>
}
```

## POST /api/v1/tab/pay/details

Submit Adyen payment details

Submit additional payment details (3DS, redirect) for an existing Adyen Advanced flow purchase.

Request body:

```ts
{
  purchase_intent_id: string // uuid
  details: Record<string, unknown>
  payment_data?: string
}
```

Response 200 — Payment details processed successfully

```ts
{
  status?: "success"
  data?: {
    resultCode?: string
    action?: Record<string, unknown> | null
    pspReference?: string | null
    refusalReason?: string | null
    refusalReasonCode?: string | null
    state?: string | null
  }
  meta?: Record<string, unknown>
}
```

## POST /api/v1/tab/recalculate

Recalculate tab balance

Recalculate tab balance from source of truth (kitchen dispatch items and purchases). Useful for fixing tabs with incorrect balance values.

**Permissions:** 🟠 `tabs.update`

Request body:

```ts
{
  tab_id: string // uuid
}
```

Response 200 — Tab balance recalculated successfully

```ts
{
  status?: "success"
  data?: {
    tab_id?: string // uuid
    total_amount_cents?: number
    paid_amount_cents?: number
    remaining_balance_cents?: number
    item_count?: number
    purchase_count?: number
    previous_total?: number
    previous_paid?: number
    total_changed?: boolean
    paid_changed?: boolean
  }
  meta?: Record<string, unknown>
}
```

## GET /api/v1/tabs

List tabs

List tabs with optional filtering

**Permissions:** 🔵 `tabs.read`

Query parameters:
- `locationId` (number, optional) — Filter by location ID
- `state` ("initiated" | "started" | "instant" | "closed" | "settled" | "aborted" | "action_required", optional) — Filter by tab state
- `storefrontQrCodeId` (string, optional) — Filter by table/QR code UUID
- `page` (number, optional) — Page number
- `limit` (number, optional) — Items per page

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    tabs: Array<{
        amounts?: { … }
        currentVatBreakdown?: unknown[]
        customFields?: unknown[]
        metadata?: { … }
        uuid: string // uuid
        locationId: number
        latestState: "initiated" | "started" | "instant" | "closed" | "settled" | "aborted" | "action_required"
        selectedPaymentFlow?: "open_tab" | "pay_upfront"
        paymentMode: "ITEM" | "AMOUNT"
        paymentSplit?: number | null
        totalAmountCents: number
        paidAmountCents: number
        remainingBalanceCents: number
        storefrontQrCodeId?: string | null
        bookingId?: string | null // uuid
        bookingConfirmationCode?: string | null
        tabMembers: unknown[]
        memberCount?: number
        kitchenDispatchItems?: unknown[]
        kitchenDispatches?: unknown[]
        vouchers?: unknown[]
        notes?: unknown[]
        createdAt: string // date-time
        updatedAt: string // date-time
      }>
    pagination: {
      page: number
      limit: number
      total: number
      totalPages: number
    }
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/tabs/{id}

Get tab details

Get a single tab by ID with members and kitchen dispatch items

**Permissions:** 🔵 `tabs.read`

Path parameters:
- `id` (string, required) — Tab 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>
    }
    uuid: string // uuid
    locationId: number
    latestState: "initiated" | "started" | "instant" | "closed" | "settled" | "aborted" | "action_required"
    selectedPaymentFlow?: "open_tab" | "pay_upfront"
    paymentMode: "ITEM" | "AMOUNT"
    paymentSplit?: number | null
    totalAmountCents: number
    paidAmountCents: number
    remainingBalanceCents: number
    storefrontQrCodeId?: string | null
    bookingId?: string | null // uuid
    bookingConfirmationCode?: string | null
    tabMembers: Array<{
        uuid: string // uuid
        storefrontSessionId?: string | null
        initiator: boolean
        memberType: string
        userId?: number | null
        retailUserId?: number | null
        clientId?: string | null
        userEmail?: string | null
        userName?: string | null
        isEnrolledInLoyalty: boolean
        hasCardOnFile?: boolean
        createdAt: string // date-time
        updatedAt: string // date-time
      }>
    memberCount?: number
    kitchenDispatchItems?: Array<{
        amounts?: { … }
        vatRateBasisPoints?: integer | null
        uuid: string // uuid
        kitchenDispatchId: string // uuid
        saleItemId?: number | null
        itemId?: number | null
        tabMemberId?: string | null
        parentKitchenDispatchItemId?: string | null
        price: number
        basePrice: number
        discount?: number | null
        quantity: number
        measurement?: { … } | null
        quantityCents?: number | null
        splitOfKdi?: string | null // uuid
        tabSettlementId?: string | null
        settlementState?: string | null
        status?: string | null
        itemState?: string | null
        zeroTicket?: boolean | null
        voidedQuantityCents?: number | null
        paidAmount?: number | null
        coursingOrder?: number | null
        createdAt: string // date-time
        firedAt?: string | null // date-time
        updatedAt: string // date-time
        itemTitle?: string | null
        itemTitlePrep?: string | null
        kitchenSortOrder?: number | null
        itemDescription?: string | null
        itemImageUrl?: string | null
        tabMember?: { … } | null
        comments?: unknown[]
        variants?: unknown[]
      }>
    kitchenDispatches?: Array<{
        amounts?: { … }
        currentVatBreakdown?: unknown[]
        customFields?: unknown[]
        metadata?: { … } | null
        uuid: string // uuid
        tabId: string // uuid
        locationId: number
        dailyOrderNumber?: number | null
        latestState: string
        createdAt: string // date-time
        updatedAt: string // date-time
        kitchenDispatchItems: unknown[]
      }>
    vouchers?: Array<{
        id: number
        discountId: number
        discountCode: string
        appliedAt: string // date-time
        voucherName?: string | null
        voucherType?: string | null
        typeSpecification?: { … } | null
        maxDiscountAmountCents?: number | null
        maxUses?: number | null
        timesUsed: number
        hasUsesLeft: boolean
        remainingBalanceCents?: number | null
        displayTitle?: string | null
        displayDescription?: string | null
        displayImageUrl?: string | null
      }>
    notes?: Array<{
        uuid: string // uuid
        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
      }>
    createdAt: string // date-time
    updatedAt: string // date-time
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## PATCH /api/v1/tabs/{id}

Update tabs 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:
- `id` (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/tabs/{id}/close

Close a tab

Close a tab by setting its state to closed or aborted. Use force=true to close any tab regardless of state.

**Permissions:** 🟠 `tabs.update`

Path parameters:
- `id` (string, required) — Tab UUID

Request body:

```ts
{
  comment?: string
  force?: boolean
}
```

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>
    }
    uuid: string // uuid
    locationId: number
    latestState: "initiated" | "started" | "instant" | "closed" | "settled" | "aborted" | "action_required"
    selectedPaymentFlow?: "open_tab" | "pay_upfront"
    paymentMode: "ITEM" | "AMOUNT"
    paymentSplit?: number | null
    totalAmountCents: number
    paidAmountCents: number
    remainingBalanceCents: number
    storefrontQrCodeId?: string | null
    bookingId?: string | null // uuid
    bookingConfirmationCode?: string | null
    tabMembers: Array<{
        uuid: string // uuid
        storefrontSessionId?: string | null
        initiator: boolean
        memberType: string
        userId?: number | null
        retailUserId?: number | null
        clientId?: string | null
        userEmail?: string | null
        userName?: string | null
        isEnrolledInLoyalty: boolean
        hasCardOnFile?: boolean
        createdAt: string // date-time
        updatedAt: string // date-time
      }>
    memberCount?: number
    kitchenDispatchItems?: Array<{
        amounts?: { … }
        vatRateBasisPoints?: integer | null
        uuid: string // uuid
        kitchenDispatchId: string // uuid
        saleItemId?: number | null
        itemId?: number | null
        tabMemberId?: string | null
        parentKitchenDispatchItemId?: string | null
        price: number
        basePrice: number
        discount?: number | null
        quantity: number
        measurement?: { … } | null
        quantityCents?: number | null
        splitOfKdi?: string | null // uuid
        tabSettlementId?: string | null
        settlementState?: string | null
        status?: string | null
        itemState?: string | null
        zeroTicket?: boolean | null
        voidedQuantityCents?: number | null
        paidAmount?: number | null
        coursingOrder?: number | null
        createdAt: string // date-time
        firedAt?: string | null // date-time
        updatedAt: string // date-time
        itemTitle?: string | null
        itemTitlePrep?: string | null
        kitchenSortOrder?: number | null
        itemDescription?: string | null
        itemImageUrl?: string | null
        tabMember?: { … } | null
        comments?: unknown[]
        variants?: unknown[]
      }>
    kitchenDispatches?: Array<{
        amounts?: { … }
        currentVatBreakdown?: unknown[]
        customFields?: unknown[]
        metadata?: { … } | null
        uuid: string // uuid
        tabId: string // uuid
        locationId: number
        dailyOrderNumber?: number | null
        latestState: string
        createdAt: string // date-time
        updatedAt: string // date-time
        kitchenDispatchItems: unknown[]
      }>
    vouchers?: Array<{
        id: number
        discountId: number
        discountCode: string
        appliedAt: string // date-time
        voucherName?: string | null
        voucherType?: string | null
        typeSpecification?: { … } | null
        maxDiscountAmountCents?: number | null
        maxUses?: number | null
        timesUsed: number
        hasUsesLeft: boolean
        remainingBalanceCents?: number | null
        displayTitle?: string | null
        displayDescription?: string | null
        displayImageUrl?: string | null
      }>
    notes?: Array<{
        uuid: string // uuid
        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
      }>
    createdAt: string // date-time
    updatedAt: string // date-time
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/tabs/{id}/correct

Correct (rättelse) a tab

Force-close a tab as a correction (rättelse). Same mechanics as a force-close (state becomes "aborted") but tagged in tabs.metadata.closeType="correction" and gated by its own permission.

**Permissions:** ⚪ `tabs.correct`

Path parameters:
- `id` (string, required) — Tab UUID

Request body:

```ts
{
  comment?: string
}
```

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>
    }
    uuid: string // uuid
    locationId: number
    latestState: "initiated" | "started" | "instant" | "closed" | "settled" | "aborted" | "action_required"
    selectedPaymentFlow?: "open_tab" | "pay_upfront"
    paymentMode: "ITEM" | "AMOUNT"
    paymentSplit?: number | null
    totalAmountCents: number
    paidAmountCents: number
    remainingBalanceCents: number
    storefrontQrCodeId?: string | null
    bookingId?: string | null // uuid
    bookingConfirmationCode?: string | null
    tabMembers: Array<{
        uuid: string // uuid
        storefrontSessionId?: string | null
        initiator: boolean
        memberType: string
        userId?: number | null
        retailUserId?: number | null
        clientId?: string | null
        userEmail?: string | null
        userName?: string | null
        isEnrolledInLoyalty: boolean
        hasCardOnFile?: boolean
        createdAt: string // date-time
        updatedAt: string // date-time
      }>
    memberCount?: number
    kitchenDispatchItems?: Array<{
        amounts?: { … }
        vatRateBasisPoints?: integer | null
        uuid: string // uuid
        kitchenDispatchId: string // uuid
        saleItemId?: number | null
        itemId?: number | null
        tabMemberId?: string | null
        parentKitchenDispatchItemId?: string | null
        price: number
        basePrice: number
        discount?: number | null
        quantity: number
        measurement?: { … } | null
        quantityCents?: number | null
        splitOfKdi?: string | null // uuid
        tabSettlementId?: string | null
        settlementState?: string | null
        status?: string | null
        itemState?: string | null
        zeroTicket?: boolean | null
        voidedQuantityCents?: number | null
        paidAmount?: number | null
        coursingOrder?: number | null
        createdAt: string // date-time
        firedAt?: string | null // date-time
        updatedAt: string // date-time
        itemTitle?: string | null
        itemTitlePrep?: string | null
        kitchenSortOrder?: number | null
        itemDescription?: string | null
        itemImageUrl?: string | null
        tabMember?: { … } | null
        comments?: unknown[]
        variants?: unknown[]
      }>
    kitchenDispatches?: Array<{
        amounts?: { … }
        currentVatBreakdown?: unknown[]
        customFields?: unknown[]
        metadata?: { … } | null
        uuid: string // uuid
        tabId: string // uuid
        locationId: number
        dailyOrderNumber?: number | null
        latestState: string
        createdAt: string // date-time
        updatedAt: string // date-time
        kitchenDispatchItems: unknown[]
      }>
    vouchers?: Array<{
        id: number
        discountId: number
        discountCode: string
        appliedAt: string // date-time
        voucherName?: string | null
        voucherType?: string | null
        typeSpecification?: { … } | null
        maxDiscountAmountCents?: number | null
        maxUses?: number | null
        timesUsed: number
        hasUsesLeft: boolean
        remainingBalanceCents?: number | null
        displayTitle?: string | null
        displayDescription?: string | null
        displayImageUrl?: string | null
      }>
    notes?: Array<{
        uuid: string // uuid
        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
      }>
    createdAt: string // date-time
    updatedAt: string // date-time
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## PATCH /api/v1/tabs/{id}/coursing

Advance tab coursing

Advance the coursing order for a tab. If coursingOrder is provided in the body, sets to that specific value. Otherwise, advances to the next available coursing level from the location's coursings. Used for POS coursing feature to control when items from each course can be sent to the kitchen.

**Permissions:** 🟠 `tabs.update`

Path parameters:
- `id` (string, required) — Tab UUID

Request body:

```ts
{
  coursingOrder?: number | null
}
```

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    tabId: string // uuid
    previousCoursingOrder: number | null
    coursingOrder: number | null
    coursingTitle?: string | null
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/tabs/{id}/debug

Debug: single tab

Debug endpoint for a specific tab with detailed balance verification, per-item allocation breakdowns, and per-purchase line item details. Useful for diagnosing individual tab balance issues.

**Permissions:** 🔵 `tabs.read`

Path parameters:
- `id` (string, required) — Tab UUID

Response 200 — Detailed debug info for a single tab

```ts
{
  status?: "success"
  data?: {
    tab?: Record<string, unknown>
    balance?: Record<string, unknown>
    summary?: Record<string, unknown>
    verification?: Record<string, unknown>
    purchases?: Record<string, unknown>[]
    items?: Record<string, unknown>[]
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/tabs/{id}/join

Join a tab by its UUID

Join a specific tab directly by UUID — no QR code required.

Used by the direct tab link feature (?tab=UUID in storefront-web).
Returns 400 if the tab is closed/settled.
Idempotent — safe to call multiple times with the same identifier.

**Permissions:** 🔵 `tabs.read`

Path parameters:
- `id` (string, required) — Tab UUID

Request body:

```ts
{
  tabMember: {
    id?: number
    email?: string // email
    phone?: string
    identifier?: string
  }
}
```

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    tabId: string // uuid
    tabMemberId: string // uuid
    isNewMember: boolean
    tabState: string
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/tabs/{id}/members

Add a member to a tab

Add a user as a member to an existing tab. Looks up user by userId (priority 1), email (priority 2), or phone (priority 3). If user is already a member, returns existing member. Useful for POS "Add Loyalty Member" functionality.

**Permissions:** 🟠 `tabs.update`

Path parameters:
- `id` (string, required) — Tab UUID

Request body:

```ts
{
  userId?: number
  email?: string // email
  phone?: string
}
```

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    tabMember: {
      uuid: string // uuid
      storefrontSessionId?: string | null
      initiator: boolean
      memberType: string
      userId?: number | null
      retailUserId?: number | null
      clientId?: string | null
      userEmail?: string | null
      userName?: string | null
      isEnrolledInLoyalty: boolean
      hasCardOnFile?: boolean
      createdAt: string // date-time
      updatedAt: string // date-time
    }
    isNewMember: boolean
    user: {
      id: number
      email?: string | null
      name?: string | null
      phone?: string | null
    } | null
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## DELETE /api/v1/tabs/{id}/members/{memberUuid}

Remove a tab member

Delete a tab member row. Refuses with 409 MEMBER_HAS_ORDERS when the member is referenced by any kitchen dispatch, settlement, voucher reservation, purchase, or comment (history is never cascade-deleted). Refuses with 409 CANNOT_REMOVE_INITIATOR for the initiator member. Use this from the POS guests tab to remove guests added by mistake.

**Permissions:** 🟠 `tabs.update`

Path parameters:
- `id` (string, required) — Tab UUID
- `memberUuid` (string, required) — Tab member UUID to remove

Response 200 — Default Response

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

## PATCH /api/v1/tabs/{id}/members/{memberUuid}/contact

Upsert guest contact on a tab member

Overwrite email/phone/name on the user row attached to an anonymous tab member. Mirrors the kiosk-commit contact-overwrite semantics so a kiosk pay-upfront flow can record the guest's email before payment (avoiding "no recipient address on file" skips on the receipt-email cloud function). Registered accounts (users.guest=false) are refused — use the identify endpoint for those.

**Permissions:** 🟠 `tabs.update`

Path parameters:
- `id` (string, required) — Tab UUID
- `memberUuid` (string, required) — Tab member UUID

Request body:

```ts
{
  email?: string // email
  phone?: string
  name?: string
}
```

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    tabMember: {
      uuid: string // uuid
      userId: number
    }
    user: {
      id: number
      email?: string | null
      phone?: string | null
      name?: string | null
    }
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## PATCH /api/v1/tabs/{id}/members/{memberUuid}/identify

Identify an anonymous tab member

Link an anonymous tab member to a known user. Looks up user by userId (priority 1), email (priority 2), or phone (priority 3), then updates the tab member record to associate with that user. Useful for POS "Identify Guest" functionality when an anonymous guest needs to be linked to their loyalty account.

**Permissions:** 🟠 `tabs.update`

Path parameters:
- `id` (string, required) — Tab UUID
- `memberUuid` (string, required) — Tab member UUID to identify

Request body:

```ts
{
  userId?: number
  email?: string // email
  phone?: string
}
```

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    tabMember: {
      uuid: string // uuid
      storefrontSessionId?: string | null
      initiator: boolean
      memberType: string
      userId?: number | null
      retailUserId?: number | null
      clientId?: string | null
      userEmail?: string | null
      userName?: string | null
      isEnrolledInLoyalty: boolean
      hasCardOnFile?: boolean
      createdAt: string // date-time
      updatedAt: string // date-time
    }
    user: {
      id: number
      email?: string | null
      name?: string | null
      phone?: string | null
    } | null
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## PATCH /api/v1/tabs/{id}/metadata

Merge tab metadata

Shallow-merge top-level keys into the tab metadata JSONB bag

**Permissions:** 🟠 `tabs.update`

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:
- `id` (string, required) — Tab UUID

Request body:

```ts
{
  metadata: Record<string, unknown>
  customFields?: unknown | unknown[]
}
```

Response 200 — Default Response

```ts
{
  success?: boolean
  data?: {
    metadata: Record<string, unknown>
  }
  meta?: {
    requestId?: string
    timestamp?: string
    executionTimeMs?: number
  }
}
```

## POST /api/v1/tabs/{id}/settle

Settle a fully paid tab

Settle a tab when its remaining balance is 0. This endpoint:

1. **Validates** the tab can be settled:
   - Tab must exist
   - Tab must have remaining_balance_cents = 0 (fully paid)
   - Tab must not already be closed/settled/aborted

2. **Updates** the tab state to 'settled'

3. **Converts amount payments** to proportional item allocations (if any):
   - Amount payments (type='amount') get converted to product allocations
   - Each payer gets a proportional share of each item based on their pre-discount payment value
   - Original amount line items get quantity_cents set to 0 to mark as converted

**Use cases:**
- When balance reaches 0 via payments
- Manual settlement by staff
- This is the single source of truth for settling tabs

**Note:** If all payments were for specific items (ITEM mode), no conversion is needed.

**Permissions:** 🟠 `tabs.update`

Path parameters:
- `id` (string, required) — Tab 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>
    }
    uuid?: string // uuid
    locationId?: number
    latestState?: "initiated" | "started" | "instant" | "closed" | "settled" | "aborted" | "action_required"
    selectedPaymentFlow?: "open_tab" | "pay_upfront"
    paymentMode?: "ITEM" | "AMOUNT"
    paymentSplit?: number | null
    totalAmountCents?: number
    paidAmountCents?: number
    remainingBalanceCents?: number
    storefrontQrCodeId?: string | null
    bookingId?: string | null // uuid
    bookingConfirmationCode?: string | null
    tabMembers?: Array<{
        uuid: string // uuid
        storefrontSessionId?: string | null
        initiator: boolean
        memberType: string
        userId?: number | null
        retailUserId?: number | null
        clientId?: string | null
        userEmail?: string | null
        userName?: string | null
        isEnrolledInLoyalty: boolean
        hasCardOnFile?: boolean
        createdAt: string // date-time
        updatedAt: string // date-time
      }>
    memberCount?: number
    kitchenDispatchItems?: Array<{
        amounts?: { … }
        vatRateBasisPoints?: integer | null
        uuid: string // uuid
        kitchenDispatchId: string // uuid
        saleItemId?: number | null
        itemId?: number | null
        tabMemberId?: string | null
        parentKitchenDispatchItemId?: string | null
        price: number
        basePrice: number
        discount?: number | null
        quantity: number
        measurement?: { … } | null
        quantityCents?: number | null
        splitOfKdi?: string | null // uuid
        tabSettlementId?: string | null
        settlementState?: string | null
        status?: string | null
        itemState?: string | null
        zeroTicket?: boolean | null
        voidedQuantityCents?: number | null
        paidAmount?: number | null
        coursingOrder?: number | null
        createdAt: string // date-time
        firedAt?: string | null // date-time
        updatedAt: string // date-time
        itemTitle?: string | null
        itemTitlePrep?: string | null
        kitchenSortOrder?: number | null
        itemDescription?: string | null
        itemImageUrl?: string | null
        tabMember?: { … } | null
        comments?: unknown[]
        variants?: unknown[]
      }>
    kitchenDispatches?: Array<{
        amounts?: { … }
        currentVatBreakdown?: unknown[]
        customFields?: unknown[]
        metadata?: { … } | null
        uuid: string // uuid
        tabId: string // uuid
        locationId: number
        dailyOrderNumber?: number | null
        latestState: string
        createdAt: string // date-time
        updatedAt: string // date-time
        kitchenDispatchItems: unknown[]
      }>
    vouchers?: Array<{
        id: number
        discountId: number
        discountCode: string
        appliedAt: string // date-time
        voucherName?: string | null
        voucherType?: string | null
        typeSpecification?: { … } | null
        maxDiscountAmountCents?: number | null
        maxUses?: number | null
        timesUsed: number
        hasUsesLeft: boolean
        remainingBalanceCents?: number | null
        displayTitle?: string | null
        displayDescription?: string | null
        displayImageUrl?: string | null
      }>
    notes?: Array<{
        uuid: string // uuid
        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
      }>
    createdAt?: string // date-time
    updatedAt?: string // date-time
    amountPaymentsConverted?: boolean
    amountPaymentsCount?: number
    newLineItemsCount?: number
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/tabs/{id}/timeline

Get tab timeline

Get a unified timeline of all events for a specific tab, including state transitions, order events, member joins, payments, and tab move/merge operations. Events are sorted by creation time descending.

**Permissions:** 🔵 `tabs.read`

Path parameters:
- `id` (string, required) — Tab UUID

Response 200 — Tab timeline events

```ts
{
  status: "success"
  data: {
    events: Array<{
        uuid: string
        source: "tab_event" | "order_event" | "member_joined" | "purchase" | "tab_operation"
        eventType: string
        description: string
        createdAt: string // date-time
        metadata?: { … } | null
      }>
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/tabs/{tabId}/checkout-summary

Get checkout summary

Get checkout summary for a tab member's locked items. Returns items, totals, VAT breakdown, and kitchenDispatchItems array for payment processing.

Path parameters:
- `tabId` (string, required) — Tab UUID

Request body:

```ts
{
  tabMember: {
    id?: string
    email?: string
    phone?: string
    identifier?: string
  }
}
```

Response 200 — Checkout summary retrieved successfully

```ts
{
  status: "success"
  data: {
    paymentMode: "ITEM" | "AMOUNT"
    tabId: string
    tabMemberId: string
    clientId: string | null
    items?: Array<{
        measurement?: { … } | null
        kitchenDispatchItemId: string
        itemId: number
        title: string
        quantity: number
        basePrice: number
        variantsPrice: number
        totalPrice: number
        discount?: number | null
        vat: number
        vatId: number
        vatAmountCents: number
        variants: unknown[]
        comments: unknown[]
      }>
    totals?: {
      subtotal: number
      discounts: number
      totalAmountInCents: number
      vatBreakdown: Record<string, unknown>
    }
    kitchenDispatchItems?: string[]
    paymentSplit?: number
    totalAmountCents?: number
    splitAmountCents?: number
    paidAmountCents?: number
    remainingBalanceCents?: number
    paidSplits?: number
    remainingSplits?: number
    vatBreakdown?: Record<string, unknown>
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/tabs/{tabId}/discounts

Get tab discounts

Retrieve all items with discounts or price changes on a tab

Query parameters:
- `changeType` ("DISCOUNT_PERCENTAGE" | "DISCOUNT_FIXED" | "PRICE_SET" | "DISCOUNT_REMOVED", optional) — Filter by type of price change
- `appliedBy` (integer, optional) — Filter by retailer user ID who applied the change

Path parameters:
- `tabId` (string, required) — UUID of the tab

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    events: Array<{
        id: integer
        kitchenDispatchItemId: string // uuid
        tabId: string // uuid
        appliedBy: { … }
        previousPrice: integer
        newPrice: integer
        previousDiscount?: integer | null
        newDiscount?: integer | null
        basePrice: integer
        changeType: "DISCOUNT_PERCENTAGE" | "DISCOUNT_FIXED" | "PRICE_SET" | "DISCOUNT_REMOVED"
        discountPercentage?: number | null
        discountAmountCents?: integer | null
        reasonCode: string
        notes?: string | null
        createdAt: string // date-time
      }>
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/tabs/{tabId}/items

Get selected items

Get current selected items state for a tab

Path parameters:
- `tabId` (string, required) — UUID of the tab

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    stateValidToEpoch: number
    selectedItems: Array<{
        tabMemberId: string // uuid
        clientId: string // uuid
        itemId: string // uuid
        isLocked: boolean
      }>
    hasLockedMembers: boolean
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## DELETE /api/v1/tabs/{tabId}/items/{itemId}/discount

Remove item discount

Remove any discount from an item and restore its base price

Path parameters:
- `tabId` (string, required) — UUID of the tab containing the item
- `itemId` (string, required) — UUID of the kitchen dispatch item to restore

Request body:

```ts
{
  reasonCode: string
  notes?: string
}
```

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    priceEvent: {
      id: integer
      kitchenDispatchItemId: string // uuid
      tabId: string // uuid
      appliedBy: {
        userId: integer
        name?: string | null
        email?: string | null // email
      }
      previousPrice: integer
      newPrice: integer
      previousDiscount?: integer | null
      newDiscount?: integer | null
      basePrice: integer
      changeType: "DISCOUNT_PERCENTAGE" | "DISCOUNT_FIXED" | "PRICE_SET" | "DISCOUNT_REMOVED"
      discountPercentage?: number | null
      discountAmountCents?: integer | null
      reasonCode: string
      notes?: string | null
      createdAt: string // date-time
    }
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## PUT /api/v1/tabs/{tabId}/items/{itemId}/price

Update item price

Apply a discount or set a new price on a kitchen dispatch item

Path parameters:
- `tabId` (string, required) — UUID of the tab containing the item
- `itemId` (string, required) — UUID of the kitchen dispatch item to modify

Request body:

```ts
{
  action: "discount" | "set_price"
  discountType?: "percentage" | "fixed"
  value?: number
  priceInCents?: integer
  reasonCode: string
  notes?: string
}
```

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    priceEvent: {
      id: integer
      kitchenDispatchItemId: string // uuid
      tabId: string // uuid
      appliedBy: {
        userId: integer
        name?: string | null
        email?: string | null // email
      }
      previousPrice: integer
      newPrice: integer
      previousDiscount?: integer | null
      newDiscount?: integer | null
      basePrice: integer
      changeType: "DISCOUNT_PERCENTAGE" | "DISCOUNT_FIXED" | "PRICE_SET" | "DISCOUNT_REMOVED"
      discountPercentage?: number | null
      discountAmountCents?: integer | null
      reasonCode: string
      notes?: string | null
      createdAt: string // date-time
    }
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/tabs/{tabId}/items/{itemId}/price-history

Get item price history

Retrieve the complete price change history for a kitchen dispatch item

Path parameters:
- `tabId` (string, required) — UUID of the tab
- `itemId` (string, required) — UUID of the kitchen dispatch item

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    events: Array<{
        id: integer
        kitchenDispatchItemId: string // uuid
        tabId: string // uuid
        appliedBy: { … }
        previousPrice: integer
        newPrice: integer
        previousDiscount?: integer | null
        newDiscount?: integer | null
        basePrice: integer
        changeType: "DISCOUNT_PERCENTAGE" | "DISCOUNT_FIXED" | "PRICE_SET" | "DISCOUNT_REMOVED"
        discountPercentage?: number | null
        discountAmountCents?: integer | null
        reasonCode: string
        notes?: string | null
        createdAt: string // date-time
      }>
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/tabs/{tabId}/items/batch

Batch claim and unclaim items

Atomically claim and unclaim items for a tab member in a single operation. Emits a single webhook event for the combined change.

Path parameters:
- `tabId` (string, required) — UUID of the tab

Request body:

```ts
{
  tabMemberId: string // uuid
  clientId: string // uuid
  claim?: string[]
  unclaim?: string[]
  mode?: "ITEM" | "AMOUNT"
}
```

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    success: true
    stateValidToEpoch: number
    selectedItems: Array<{
        tabMemberId: string // uuid
        clientId: string // uuid
        itemId: string // uuid
        isLocked: boolean
      }>
    hasLockedMembers: boolean
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## PUT /api/v1/tabs/{tabId}/items/bulk-price

Bulk update item prices

Apply discounts or set new prices on multiple kitchen dispatch items in a single request. Only sends items that need to be updated.

Path parameters:
- `tabId` (string, required) — UUID of the tab containing the items

Request body:

```ts
{
  items: Array<{
      kitchenDispatchItemId: string // uuid
      newPrice?: integer
      discountPercent?: number
      removeDiscount?: boolean
    }>
  reasonCode: string
  notes?: string
}
```

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    results: Array<{
        kitchenDispatchItemId: string // uuid
        previousPrice: integer
        newPrice: integer
        changeType: "DISCOUNT_PERCENTAGE" | "DISCOUNT_FIXED" | "PRICE_SET" | "DISCOUNT_REMOVED"
        success: boolean
        error?: string
      }>
    totalUpdated?: integer
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/tabs/{tabId}/items/claim

Claim items

Claim specific items for a tab member

Path parameters:
- `tabId` (string, required) — UUID of the tab

Request body:

```ts
{
  tabMemberId: string // uuid
  clientId: string // uuid
  items: string[]
  mode?: "ITEM" | "AMOUNT"
}
```

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    success: true
    stateValidToEpoch: number
    selectedItems: Array<{
        tabMemberId: string // uuid
        clientId: string // uuid
        itemId: string // uuid
        isLocked: boolean
      }>
    hasLockedMembers: boolean
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/tabs/{tabId}/items/lock

Lock item selections

Lock a tab member's selections for checkout (prevents further modifications)

Path parameters:
- `tabId` (string, required) — UUID of the tab

Request body:

```ts
unknown | unknown
```

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    success: true
    stateValidToEpoch: number
    selectedItems: Array<{
        tabMemberId: string // uuid
        clientId: string // uuid
        itemId: string // uuid
        isLocked: boolean
      }>
    hasLockedMembers: boolean
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/tabs/{tabId}/items/split-mode

Set group split mode

Set the advisory group-coordination split mode for a tab (e.g. when a guest chooses "pay by items"). Written to the per-tab LiveObject map only — no DB column, not used for pricing. Distributes to all tab clients with no refetch.

Path parameters:
- `tabId` (string, required) — UUID of the tab

Request body:

```ts
{
  split: "NONE" | "BY_ITEM" | "BY_AMOUNT" | "BY_AMOUNT_FREE"
}
```

Response 200 — Default Response

```ts
{
  status?: "success"
  data?: {
    split?: "NONE" | "BY_ITEM" | "BY_AMOUNT" | "BY_AMOUNT_FREE"
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/tabs/{tabId}/items/unclaim

Unclaim items

Unclaim (release) specific items for a tab member

Path parameters:
- `tabId` (string, required) — UUID of the tab

Request body:

```ts
{
  tabMemberId: string // uuid
  items: string[]
  mode?: "ITEM" | "AMOUNT"
}
```

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    success: true
    stateValidToEpoch: number
    selectedItems: Array<{
        tabMemberId: string // uuid
        clientId: string // uuid
        itemId: string // uuid
        isLocked: boolean
      }>
    hasLockedMembers: boolean
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/tabs/{tabId}/items/unlock

Unlock item selections

Unlock a tab member's selections (allows modifications again)

Path parameters:
- `tabId` (string, required) — UUID of the tab

Request body:

```ts
unknown | unknown
```

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    success: true
    stateValidToEpoch: number
    selectedItems: Array<{
        tabMemberId: string // uuid
        clientId: string // uuid
        itemId: string // uuid
        isLocked: boolean
      }>
    hasLockedMembers: boolean
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/tabs/{tabId}/move/dispatch

Move dispatch to another table

Move an entire kitchen_dispatch from a tab to another table

Path parameters:
- `tabId` (string, required) — Source tab UUID

Request body:

```ts
{
  dispatchId: string // uuid
  destinationTableId: string // uuid
}
```

Response 200 — Dispatch moved successfully

```ts
{
  status?: "success"
  data?: {
    success: boolean
    message: string
    sourceTabId: string // uuid
    destinationTabId: string // uuid
    movedItemCount: integer
    tabMerged: boolean
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/tabs/{tabId}/move/items

Move items to another table

Move specific kitchen_dispatch_items from a tab to another table

Path parameters:
- `tabId` (string, required) — Source tab UUID

Request body:

```ts
{
  itemIds: string[]
  destinationTableId: string // uuid
}
```

Response 200 — Items moved successfully

```ts
{
  status?: "success"
  data?: {
    success: boolean
    message: string
    sourceTabId: string // uuid
    destinationTabId: string // uuid
    movedItemCount: integer
    tabMerged: boolean
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/tabs/{tabId}/move/tab

Move tab to another table

Move an entire tab to another table. If destination table has an active tab, tabs are merged.

Path parameters:
- `tabId` (string, required) — Source tab UUID

Request body:

```ts
{
  destinationTableId: string // uuid
}
```

Response 200 — Tab moved or merged successfully

```ts
{
  status?: "success"
  data?: {
    success: boolean
    message: string
    sourceTabId: string // uuid
    destinationTabId: string // uuid
    movedItemCount: integer
    tabMerged: boolean
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/tabs/{tabId}/payment-split

Set payment split

Set payment split for a tab (switches to AMOUNT mode).

This endpoint allows users to split the entire tab bill evenly among multiple people.
When a split is set, the tab switches from ITEM mode to AMOUNT mode.

**Constraints:**
- Split can only be set when no payments have been made yet
- Split can be changed until someone locks items for checkout
- Split value must be between 2 and 10
- Tab must have items (total_amount_cents > 0)

Path parameters:
- `tabId` (string, required) — The UUID of the tab

Request body:

```ts
{
  payment_split: integer
}
```

Response 200 — Default Response

```ts
{
  status?: "success"
  data?: {
    tabId?: string // uuid
    paymentMode?: "AMOUNT"
    paymentSplit?: integer
    totalAmountCents?: integer
    splitAmountCents?: integer
    remainingBalanceCents?: integer
    paidSplits?: integer
    remainingSplits?: integer
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## DELETE /api/v1/tabs/{tabId}/payment-split

Clear payment split

Clear payment split for a tab (reverts to ITEM mode).

This endpoint allows users to cancel/clear a previously set payment split.
When cleared, the tab switches from AMOUNT mode back to ITEM mode.

**Constraints:**
- Can only be cleared when no payments have been made yet
- Cannot be cleared while items are locked for checkout
- Tab must currently be in AMOUNT mode (split mode)

**Use cases:**
- User changed their mind about splitting
- Split was set by mistake
- Group decided to pay individually instead

Path parameters:
- `tabId` (string, required) — The UUID of the tab

Response 200 — Default Response

```ts
{
  status?: "success"
  data?: {
    tabId?: string // uuid
    paymentMode?: "ITEM"
    previousPaymentSplit?: integer | null
    clearedSelections?: boolean
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/tabs/{tabId}/purchases

Get purchases for a tab

Get all confirmed purchases for a specific tab, including payer information and totals. Used to display payment history in the POS tab view.

**Permissions:** 🔵 `purchases.read`

Query parameters:
- `locationId` (number, required) — Location ID

Path parameters:
- `tabId` (string, required) — Tab UUID

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    purchases: Array<{
        customFields?: unknown[]
        metadata?: { … } | null
        id: number
        intentId: string
        purchasedAt: string // date-time
        state: string
        currencyCode?: string
        payerUuid?: string | null // uuid
        payerName?: string | null
        payerEmail?: string | null
        totalAmountCents: number
        lineItemCount: number
      }>
    summary: {
      totalPaidCents: number
      purchaseCount: number
      totalItemCount: number
    }
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/tabs/check-exists

Check if tab exists for QR code (without joining)

Checks if an active tab exists for a QR code WITHOUT joining the user to it. This is useful for instant-pay flows that want to show a "Join Tab" option but should not auto-join. Returns hasActiveTab: true/false with the tabId if one exists.

**Permissions:** 🔵 `tabs.read`

Request body:

```ts
{
  locationId: number
  qrCodeId: string // uuid
}
```

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    hasActiveTab: boolean
    tabId: string | null // uuid
    tabMemberId: string | null // uuid
    isNewMember: boolean
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/tabs/closed

List force-closed / corrected tabs

Operator monitoring view: list force-closed (aborted) and corrected (rättelse) tabs with date / type / comment filters.

**Permissions:** 🔵 `tabs.read`

Query parameters:
- `locationId` (number, optional) — Filter by location ID (required)
- `startDate` (string, optional) — ISO start of close-date range
- `endDate` (string, optional) — ISO end of close-date range
- `closeType` ("correction" | "force", optional) — Filter by close type: 'correction' (rättelse) or 'force' (regular force-close). Omit for both.
- `commentSearch` (string, optional) — Case-insensitive substring match on the close comment
- `minTotalCents` (number, optional) — Minimum tab total (cents)
- `maxTotalCents` (number, optional) — Maximum tab total (cents)
- `closedByRetailerUserId` (number, optional) — Filter by the staff member who closed the tab
- `tableName` (string, optional) — Comma-separated table names to filter by
- `tabType` (string, optional) — Comma-separated tab types/channels (table, over_the_counter, takeaway)
- `page` (number, optional) — Page number
- `limit` (number, optional) — Items per page

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    tabs: Array<{
        customFields?: unknown[]
        metadata?: { … } | null
        tabId: string // uuid
        locationId: number
        totalAmountCents: number
        paidAmountCents?: number
        tabType?: string | null
        storefrontQrCodeId?: string | null
        tableName?: string | null
        closedAt?: string | null
        closeType: "correction" | "force"
        comment?: string | null
        closedByRetailerUserId?: number | null
        closedByName?: string | null
        closedByEmail?: string | null
      }>
    summary: {
      totalAmountCentsSum: integer
    }
    pagination: {
      page: number
      limit: number
      total: number
      totalPages: number
    }
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/tabs/correct-bulk

Correct (rättelse) many tabs

Force-close many tabs as corrections (rättelse) in a single request, scoped to a location.

**Permissions:** ⚪ `tabs.correct`

Request body:

```ts
{
  tabIds: string[]
  locationId: number
  comment?: string
}
```

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    correctedCount: number
    correctedTabIds: string[]
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/tabs/debug/recent

Debug: recent tabs

Debug endpoint that returns recent tabs with balance verification, item allocations, and purchase breakdowns. Useful for diagnosing tab balance discrepancies.

**Permissions:** 🔵 `tabs.read`

Query parameters:
- `limit` (number, optional) — Number of recent tabs to return (max 20)
- `locationId` (number, optional) — Filter by location ID

Response 200 — Debug info for recent tabs

```ts
{
  status?: "success"
  data?: {
    tabs?: Record<string, unknown>[]
    query?: Record<string, unknown>
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/tabs/join-if-existing

Join existing tab for QR code

Returns all active joinable tabs (≤7 days old) for the QR code. When exactly one tab exists, auto-joins the user. When multiple tabs exist, returns the list for user selection without joining (pass tabId in a second request to join a specific tab). Returns hasActiveTab:false if no active tabs exist.

**Permissions:** 🔵 `tabs.read`

Request body:

```ts
{
  locationId: number
  qrCodeId: string // uuid
  tabMember: {
    id?: number
    email?: string // email
    phone?: string
    identifier?: string
  }
  tabId?: string // uuid
}
```

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    hasActiveTab: boolean
    tabId: string | null // uuid
    tabMemberId: string | null // uuid
    isNewMember: boolean
    joinableTabs: Array<{
        tabId: string // uuid
        selectedPaymentFlow: string | null
        tableName: string | null
        memberCount: number
        totalAmountCents: number
        createdAt: string // date-time
        members: unknown[]
      }>
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/tabs/mine

List the authenticated guest's active tabs

Returns all non-terminal tabs (latest_state NOT IN closed/settled/aborted) where the Bearer-authenticated guest user is a tab_member. Includes members and a minimal location stub. Identity resolves via the same JWT path as buyer endpoints (HS256 mobile or OIDC RS256 storefront).

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    tabs: Array<{
        amounts?: { … }
        currentVatBreakdown?: unknown[]
        customFields?: unknown[]
        metadata?: { … }
        uuid: string // uuid
        locationId: number
        latestState: "initiated" | "started" | "instant" | "closed" | "settled" | "aborted" | "action_required"
        selectedPaymentFlow?: "open_tab" | "pay_upfront"
        paymentMode: "ITEM" | "AMOUNT"
        paymentSplit?: number | null
        totalAmountCents: number
        paidAmountCents: number
        remainingBalanceCents: number
        storefrontQrCodeId?: string | null
        bookingId?: string | null // uuid
        bookingConfirmationCode?: string | null
        tabMembers: unknown[]
        memberCount?: number
        kitchenDispatchItems?: unknown[]
        kitchenDispatches?: unknown[]
        vouchers?: unknown[]
        notes?: unknown[]
        createdAt: string // date-time
        updatedAt: string // date-time
        location: { … }
      }>
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/tabs/mine/{id}

Get a single tab the authenticated guest is a member of

Returns the same tab shape as GET /tabs/:id (members + items + location stub) but gated on tab_members.user_id matching the Bearer-authenticated guest. Returns 404 when the user is not a member of the tab.

Path parameters:
- `id` (string, required) — Tab 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>
    }
    uuid: string // uuid
    locationId: number
    latestState: "initiated" | "started" | "instant" | "closed" | "settled" | "aborted" | "action_required"
    selectedPaymentFlow?: "open_tab" | "pay_upfront"
    paymentMode: "ITEM" | "AMOUNT"
    paymentSplit?: number | null
    totalAmountCents: number
    paidAmountCents: number
    remainingBalanceCents: number
    storefrontQrCodeId?: string | null
    bookingId?: string | null // uuid
    bookingConfirmationCode?: string | null
    tabMembers: Array<{
        uuid: string // uuid
        storefrontSessionId?: string | null
        initiator: boolean
        memberType: string
        userId?: number | null
        retailUserId?: number | null
        clientId?: string | null
        userEmail?: string | null
        userName?: string | null
        isEnrolledInLoyalty: boolean
        hasCardOnFile?: boolean
        createdAt: string // date-time
        updatedAt: string // date-time
      }>
    memberCount?: number
    kitchenDispatchItems?: Array<{
        amounts?: { … }
        vatRateBasisPoints?: integer | null
        uuid: string // uuid
        kitchenDispatchId: string // uuid
        saleItemId?: number | null
        itemId?: number | null
        tabMemberId?: string | null
        parentKitchenDispatchItemId?: string | null
        price: number
        basePrice: number
        discount?: number | null
        quantity: number
        measurement?: { … } | null
        quantityCents?: number | null
        splitOfKdi?: string | null // uuid
        tabSettlementId?: string | null
        settlementState?: string | null
        status?: string | null
        itemState?: string | null
        zeroTicket?: boolean | null
        voidedQuantityCents?: number | null
        paidAmount?: number | null
        coursingOrder?: number | null
        createdAt: string // date-time
        firedAt?: string | null // date-time
        updatedAt: string // date-time
        itemTitle?: string | null
        itemTitlePrep?: string | null
        kitchenSortOrder?: number | null
        itemDescription?: string | null
        itemImageUrl?: string | null
        tabMember?: { … } | null
        comments?: unknown[]
        variants?: unknown[]
      }>
    kitchenDispatches?: Array<{
        amounts?: { … }
        currentVatBreakdown?: unknown[]
        customFields?: unknown[]
        metadata?: { … } | null
        uuid: string // uuid
        tabId: string // uuid
        locationId: number
        dailyOrderNumber?: number | null
        latestState: string
        createdAt: string // date-time
        updatedAt: string // date-time
        kitchenDispatchItems: unknown[]
      }>
    vouchers?: Array<{
        id: number
        discountId: number
        discountCode: string
        appliedAt: string // date-time
        voucherName?: string | null
        voucherType?: string | null
        typeSpecification?: { … } | null
        maxDiscountAmountCents?: number | null
        maxUses?: number | null
        timesUsed: number
        hasUsesLeft: boolean
        remainingBalanceCents?: number | null
        displayTitle?: string | null
        displayDescription?: string | null
        displayImageUrl?: string | null
      }>
    notes?: Array<{
        uuid: string // uuid
        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
      }>
    createdAt: string // date-time
    updatedAt: string // date-time
    location: {
      id: number
      name: string
      logoImageUrl: string | null
      currency: string | null
      timezone: string | null
    }
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/tabs/open

List open tabs

Operator monitoring view: list currently-open tabs (not closed/settled/aborted) for end-of-night reconciliation, with table / channel / total filters.

**Permissions:** 🔵 `tabs.read`

Query parameters:
- `locationId` (number, optional) — Filter by location ID (required)
- `startDate` (string, optional) — ISO start of opened-date range
- `endDate` (string, optional) — ISO end of opened-date range
- `minTotalCents` (number, optional) — Minimum tab total (cents)
- `maxTotalCents` (number, optional) — Maximum tab total (cents)
- `tableName` (string, optional) — Comma-separated table names to filter by
- `tabType` (string, optional) — Comma-separated tab types/channels (table, over_the_counter, takeaway)
- `page` (number, optional) — Page number
- `limit` (number, optional) — Items per page

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    tabs: Array<{
        customFields?: unknown[]
        metadata?: { … } | null
        tabId: string // uuid
        locationId: number
        totalAmountCents: number
        paidAmountCents?: number
        remainingBalanceCents?: number
        tabType?: string | null
        storefrontQrCodeId?: string | null
        tableName?: string | null
        latestState?: string | null
        paymentFlow?: string | null
        openedByName?: string | null
        openedByType?: string | null
        openedAt?: string | null
      }>
    summary: {
      totalAmountCentsSum: integer
    }
    pagination: {
      page: number
      limit: number
      total: number
      totalPages: number
    }
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/tabs/reconnect-payment

Reconnect orphaned Adyen payment

Reconnect a payment that was authorized in Adyen but not recorded on the tab (terminal timeout). Re-attaches the EXISTING purchase intent behind the Adyen PSP reference to a new tab settlement and captures it; a reference nobody owns is 404, an already reversed authorisation is 409. Karmeleon-only.

**Permissions:** 🟠 `tabs.update`

Request body:

```ts
{
  tab_id: string // uuid
  psp_reference: string
  terminal_id?: string // uuid
  amount_cents?: number
}
```

Response 200 — Payment reconnected successfully

```ts
{
  status?: "success"
  data?: {
    purchase_intent_id?: string // uuid
    tab_settlement_id?: string | null // uuid
    tab_id?: string // uuid
    psp_reference?: string
    amount_cents?: number
    reconnected?: boolean
    already_settled?: boolean
    owner?: "purchase-service" | "payment-service"
    rescued_from_state?: string | null
  }
  meta?: Record<string, unknown>
}
```

## POST /api/v1/tabs/resolve

Resolve tab for user

Find existing tab or create new tab for a user. Handles QR code tables, user resolution with priority system (id > email > phone > identifier), tab member management, and order channel routing. Perfect for storefront integration before item commits.

**Permissions:** 🟢 `tabs.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
  qrCodeId: string | null
  tabType: "table" | "takeaway" | "over_the_counter"
  paymentFlow: "open_tab" | "pay_upfront"
  orderChannel: "ONLINE" | "QR" | "KIOSK" | "POS"
  tabMember: {
    id?: number
    email?: string // email
    phone?: string
    identifier?: string
  }
  customFields?: unknown | unknown[]
}
```

Response 200 — Default Response

```ts
{
  status: "success"
  data: {
    tabId: string // uuid
    tabMemberId: string // uuid
    isNewTab: boolean
    isNewTabMember: boolean
    tab: {
      amounts?: {
        grossAmountCents: integer
        vatAmountCents: integer | null
        netAmountCents: integer | null
        basis: "current"
        vatComplete: boolean
      }
      currentVatBreakdown?: { … }[]
      customFields?: { … }[]
      metadata?: {
        customFields?: { … }
      }
      uuid: string // uuid
      locationId: number
      latestState: "initiated" | "started" | "instant" | "closed" | "settled" | "aborted" | "action_required"
      selectedPaymentFlow?: "open_tab" | "pay_upfront"
      paymentMode: "ITEM" | "AMOUNT"
      paymentSplit?: number | null
      totalAmountCents: number
      paidAmountCents: number
      remainingBalanceCents: number
      storefrontQrCodeId?: string | null
      bookingId?: string | null // uuid
      bookingConfirmationCode?: string | null
      tabMembers: { … }[]
      memberCount?: number
      kitchenDispatchItems?: { … }[]
      kitchenDispatches?: { … }[]
      vouchers?: { … }[]
      notes?: { … }[]
      createdAt: string // date-time
      updatedAt: string // date-time
    }
    tabMembers: Array<{
        uuid: string // uuid
        storefrontSessionId?: string | null
        initiator: boolean
        memberType: string
        userId?: number | null
        retailUserId?: number | null
        clientId?: string | null
        userEmail?: string | null
        userName?: string | null
        isEnrolledInLoyalty: boolean
        hasCardOnFile?: boolean
        createdAt: string // date-time
        updatedAt: string // date-time
      }>
  }
  meta: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/tabs/resolve-external

Resolve an open external (POS) tab by table QR

Cold-start pickup for POS-integrated tables. Peeks the POS (Trivec/Domino) for an open ticket on the scanned table; only if one exists does it create/find a Karma tab and pull the ticket onto it. Returns { tabId: null, resolution: "anonymous" } when there is no open ticket (the storefront then shows the menu, no ghost tab is created). Called by storefront-web when active-tab resolves to anonymous on an open_tab QR.

**Permissions:** 🟢 `tabs.create`

Request body:

```ts
{
  locationId: number
  qrCodeId: string | null
  tabType: "table" | "takeaway" | "over_the_counter"
  paymentFlow: "open_tab" | "pay_upfront"
  orderChannel: "ONLINE" | "QR" | "KIOSK" | "POS"
  tabMember: {
    id?: number
    email?: string // email
    phone?: string
    identifier?: string
  }
}
```

Response 200 — Default Response

```ts
{
  status?: string
  data?: {
    tabId: string | null // uuid
    tabMemberId: string | null // uuid
    resolution: "external_ticket" | "anonymous"
    isNewTab?: boolean
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v2/tab/{tabId}/payment-methods

Get Adyen payment methods

Returns available Adyen payment methods for the Advanced flow. No purchase is created.

Query parameters:
- `amountCents` (number, required) — Payment amount in cents. 0 is a valid value: the storefront still hits this endpoint when the cart nets to 0 (voucher fully covers the order) and the handler short-circuits with an empty paymentMethodsResponse so the drawer can route to the complimentary settlement path without a PSP method selection.
- `currencyCode` (string, required) — ISO 4217 currency code (e.g. SEK, EUR)
- `countryCode` (string, optional) — ISO 3166-1 alpha-2 country code (e.g. SE, NL)
- `tabMemberId` (string, optional) — Optional tab member UUID. When supplied, the handler resolves the paying user and includes a shopperReference so Adyen returns storedPaymentMethods (saved cards) for the shopper. Omit for guest checkouts.
- `channel` ("iOS" | "Android" | "Web", optional) — Optional client platform. Forwarded to Adyen /paymentMethods so it returns platform-native wallets — notably Google Pay (returned only for channel=Android). Omit for web (Adyen defaults to Web behaviour).

Path parameters:
- `tabId` (string, required) — Tab UUID

Response 200 — Payment methods fetched successfully

```ts
{
  status?: "success"
  data?: {
    paymentMethodsResponse?: Record<string, unknown>
    clientKey?: string
    environment?: "test" | "live"
    adyenIntegrationId?: string | null // uuid
    freeOrder?: boolean
  }
  meta?: Record<string, unknown>
}
```
