# Analytics Enterprise real-time analytics with BigQuery ## POST /api/v1/analytics/batch Execute multiple analytics queries in parallel Request body: ```ts { queries: Array<{ queryType: "sales_hourly" | "channels" | "top_items" | "location_performance" locationId: number startDate?: string // date-time endDate?: string // date-time granularity?: "hour" | "day" | "week" includeChildren?: boolean filters?: Record }> } ``` Response 200 — Default Response ```ts { status?: "success" data?: { charts?: Array<{ id: string title: string type: "line" | "bar" | "area" | "pie" | "metric" | "table" data: unknown[] metadata?: { … } }> errors?: Array<{ queryIndex?: number queryType?: string error?: string }> } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number totalQueries?: number successfulQueries?: number failedQueries?: number } } ``` ## GET /api/v1/analytics/channels Get channel behavior metrics (POS, App, Kiosk, Delivery, QR) Query parameters: - `locationId` (number, required) — Location ID - `startDate` (string, optional) — Start date (ISO 8601) - `endDate` (string, optional) — End date (ISO 8601) - `includeChildren` (boolean, optional) — Include child locations Response 200 — Default Response ```ts { status?: "success" data?: Array<{ orderChannel?: string orderCount?: number revenue?: number averageOrderValue?: number uniqueCustomers?: number conversionRate?: number | null }> meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number cacheHit?: boolean bytesProcessed?: number } } ``` ## GET /api/v1/analytics/dashboard Get comprehensive dashboard metrics in a single call. Returns sales inc/ex VAT, orders, products, tips, refunds, discounts, AOV, VAT breakdown, time series, top products, top categories, and channel breakdown. Query parameters: - `locationId` (number, required) — Location ID - `startDate` (string, optional) — Start date (ISO 8601) - `endDate` (string, optional) — End date (ISO 8601) - `includeChildren` (boolean, optional) — Include child locations - `granularity` ("hour" | "day" | "week", optional) — Time series granularity - `productSearch` (string, optional) — Search filter for product titles - `productLimit` (number, optional) — Max products to return (default 10, max 500) - `sections` (string, optional) — Optional comma-separated dashboard sections. Omit for the complete backward-compatible response. - `tableGroupIds` (string, optional) — Comma-separated list of table group IDs (storefront_qr_code_groups.id) to restrict results to. When set, excludes orders without a QR code (e.g. pickup, delivery). - `costCenterIds` (string, optional) — Comma-separated list of cost center IDs (cost_centers.id) to restrict results to. Matches on COALESCE(tabs.cost_center_id, storefront_qr_codes.cost_center_id) — tab override beats table default. Orders without a cost center are excluded when this filter is active. - `accountIds` (string, optional) — Comma-separated list of bookkeeping account IDs (item_accounts.id, UUIDs) to restrict results to. This is a LINE filter, not an order filter: only line items whose product books to one of these accounts are counted, so tips and other non-product lines are excluded while it is active. Takeaway lines resolve through items.takeaway_account_id when the product has one. - `categoryTagIds` (string, optional) — Comma-separated list of category tag IDs (tags.id) to restrict results to. Like accountIds this is a LINE filter — only line items whose product carries one of these tags are counted. - `locationIds` (string, optional) — Comma-separated list of location IDs to narrow the resolved set to an explicit subset. The permitted set is always resolved server-side via the location hierarchy (respecting includeChildren) and this list is intersected against it — IDs outside the permitted tree are ignored. If omitted or the intersection is empty, the full permitted set is used. - `breakdownByLocation` (boolean, optional) — When true and the resolved set contains more than one location, the response additionally includes a per-location `byLocation` breakdown (same metrics + time-series shape per location). Response 200 — Default Response ```ts { status?: "success" data?: { metrics?: { salesIncVat?: number salesExVat?: number totalVat?: number netSalesIncVat?: number netSalesExVat?: number netTotalVat?: number orderCount?: number productCount?: number tipsTotal?: number refundCount?: number refundTotal?: number discountCount?: number discountTotal?: number aov?: number } audience?: { followersTotal?: number followersGained?: number reviewsTotal?: number reviewsPeriod?: number scoreAllTime?: number | null scorePeriod?: number | null reviewTags?: { … }[] } | null vatBreakdown?: Array<{ vatRateBasisPoints?: number salesIncVat?: number vatAmount?: number salesExVat?: number orderCount?: number }> timeSeries?: Array<{ label?: string paymentSalesIncVat?: number paymentSalesExVat?: number paymentOrders?: number kitchenSalesIncVat?: number kitchenSalesExVat?: number kitchenOrders?: number }> topProducts?: Array<{ title?: string originItemId?: number | null measurementTotalsAtoms?: { … } unitsSold?: number revenue?: number revenueExVat?: number }> topCategories?: Array<{ categoryKey?: string categoryName?: string unitsSold?: number revenue?: number revenueExVat?: number }> topAccounts?: Array<{ account?: string description?: string | null unitsSold?: number revenue?: number revenueExVat?: number }> channels?: Array<{ channel?: string orderCount?: number revenue?: number revenueExVat?: number aov?: number uniqueCustomers?: number }> topStaff?: Array<{ retailUserId?: number name?: string orderCount?: number revenue?: number revenueExVat?: number }> topProductsByCategory?: Array<{ categoryKey?: string categoryName?: string products?: unknown[] }> costCenters?: Array<{ costCenterId?: number | null name?: string | null orderCount?: number unitsSold?: number revenue?: number revenueExVat?: number }> tableGroups?: Array<{ tableGroupId?: number | null name?: string | null orderCount?: number unitsSold?: number revenue?: number revenueExVat?: number }> byLocation?: Array<{ locationId?: number locationName?: string metrics?: { … } timeSeries?: unknown[] }> } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/analytics/datastream-latency Get DataStream latency metrics Query parameters: - `locationId` (number, required) — Location ID Response 200 — Default Response ```ts { status?: "success" data?: { avgLatencyMs?: number p50LatencyMs?: number p95LatencyMs?: number p99LatencyMs?: number lastOrderLatencyMs?: number | null } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number cacheHit?: boolean bytesProcessed?: number } } ``` ## GET /api/v1/analytics/drilldown Get drill-down analytics (Location → Hour → Item) Query parameters: - `locationId` (number, required) — Location ID - `startDate` (string, optional) — Start date (ISO 8601) - `endDate` (string, optional) — End date (ISO 8601) - `hour` (number, optional) — Hour of day (0-23) - `itemId` (number, optional) — Item ID for detailed orders Response 200 — Default Response ```ts { status?: "success" data?: { level: "location" | "hour" | "item" data: Array<{ label: string value: number timestamp?: string // date-time metadata?: { … } }> metadata: { parentLocationId?: number parentLocationName?: string hour?: number itemId?: number } } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/analytics/locations Get location performance with hierarchy Query parameters: - `locationId` (number, required) — Parent location ID - `startDate` (string, optional) — Start date (ISO 8601) - `endDate` (string, optional) — End date (ISO 8601) - `includeChildren` (boolean, optional) — Include child locations Response 200 — Default Response ```ts { status?: "success" data?: Array<{ locationId?: number locationName?: string totalSales?: number totalOrders?: number averageOrderValue?: number hourlyBreakdown?: { … }[] alerts?: string[] }> meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number cacheHit?: boolean bytesProcessed?: number } } ``` ## GET /api/v1/analytics/overview Get overview dashboard with summary and charts Query parameters: - `locationId` (number, required) — Location ID - `startDate` (string, optional) — Start date (ISO 8601) - `endDate` (string, optional) — End date (ISO 8601) - `includeChildren` (boolean, optional) — Include child locations Response 200 — Default Response ```ts { status?: "success" data?: { summary?: { totalSales?: number totalOrders?: number averageOrderValue?: number topChannel?: string lastUpdated?: string // date-time } charts?: Array<{ id: string title: string type: "line" | "bar" | "area" | "pie" | "metric" | "table" data: unknown[] metadata?: { … } }> dataStreamLatency?: number | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/analytics/sales Get sales metrics with hourly breakdown Query parameters: - `locationId` (number, required) — Location ID - `startDate` (string, optional) — Start date (ISO 8601) - `endDate` (string, optional) — End date (ISO 8601) - `includeChildren` (boolean, optional) — Include child locations - `granularity` ("hour" | "day" | "week", optional) — Time granularity Response 200 — Default Response ```ts { status?: "success" data?: { totalSales: number totalOrders: number totalItemsSold: number averageOrderValue: number hourlySales: Array<{ label: string value: number timestamp?: string // date-time metadata?: { … } }> topItems: Array<{ label: string value: number timestamp?: string // date-time metadata?: { … } }> channelBreakdown: Array<{ label: string value: number timestamp?: string // date-time metadata?: { … } }> } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number cacheHit?: boolean bytesProcessed?: number } } ``` ## GET /api/v1/analytics/tips-by-staff Get tips totals per staff for the period. Tips are split proportionally across staff based on each staff member's share of committed-item revenue on the same tab. Unattributed share (no staff on tab) is returned with retailUserId = null. Query parameters: - `locationId` (number, required) — Location ID - `startDate` (string, optional) — Start date (ISO 8601) - `endDate` (string, optional) — End date (ISO 8601) - `includeChildren` (boolean, optional) — Include child locations - `tableGroupIds` (string, optional) — Comma-separated list of table group IDs to restrict to. Response 200 — Default Response ```ts { status?: "success" data?: { summary?: { tipsTotal?: number tippedOrderCount?: number orderCount?: number averageTip?: number } staff?: Array<{ retailUserId?: number | null name?: string | null tipsTotal?: number orderCount?: number }> } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # CCR Reports Cash-register compliance (CCR) report generation ## GET /api/v1/ccr-reports List SKV reports for a location Query parameters: - `locationId` (integer, optional) - `dateFrom` (string, required) — ISO 8601 datetime - `dateTo` (string, required) — ISO 8601 datetime - `reportKind` ("skv_x" | "skv_z", required) Response 200 — Default Response ```ts { status?: "success" data?: { reports?: Array<{ id?: string reportKind?: "skv_x" | "skv_z" createdAt?: string }> } meta?: { requestId?: string timestamp?: string executionTimeMs?: integer } } ``` ## GET /api/v1/ccr-reports/{reportId}/render Render a CCR report as HTML Path parameters: - `reportId` (string, required) Response 200 — Default Response ```ts { status?: "success" data?: { html?: string } meta?: { requestId?: string timestamp?: string executionTimeMs?: integer } } ``` ## POST /api/v1/ccr-reports/generate-x Generate an X (intraday) report Query parameters: - `locationId` (integer, optional) Response 201 — Default Response ```ts { status?: "success" data?: { reportId?: string } meta?: { requestId?: string timestamp?: string executionTimeMs?: integer } } ``` ## GET /api/v1/ccr-reports/settings Get cash register settings for a location Query parameters: - `locationId` (integer, optional) Response 200 — Default Response ```ts { status?: "success" data?: { settings?: { crStoreId?: string state?: "active" | "disabled" autoEodReports?: boolean reportRecipientEmail?: string | null } } meta?: { requestId?: string timestamp?: string executionTimeMs?: integer } } ``` ## PATCH /api/v1/ccr-reports/settings Update cash register report settings for a location Query parameters: - `locationId` (integer, optional) Request body: ```ts { autoEodReports?: boolean reportRecipientEmail?: string | null } ``` Response 200 — Default Response ```ts { status?: "success" data?: { settings?: { crStoreId?: string state?: "active" | "disabled" autoEodReports?: boolean reportRecipientEmail?: string | null } } meta?: { requestId?: string timestamp?: string executionTimeMs?: integer } } ``` # Cost Centers Bookkeeping cost centers and the location default ## GET /api/v1/cost-centers List cost centers List cost centers for a location, including inherited ones from parent locations **Permissions:** 🔵 `cost_centers.read` Query parameters: - `locationId` (number, required) — Filter by location ID (required) Response 200 — Default Response ```ts { status: "success" data: { costCenters: Array<{ customFields?: unknown[] metadata?: { … } | null id: number uuid: string // uuid name: string description?: string | null code?: string | null locationId: number inheritToChildren: boolean active: boolean isInherited: boolean createdAt: string // date-time updatedAt: string // date-time }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/cost-centers Create cost center Create a new cost center **Permissions:** 🟢 `cost_centers.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 { name: string description?: string code?: string | null locationId: number inheritToChildren?: boolean customFields?: unknown | unknown[] } ``` Response 201 — Default Response ```ts { status: "success" data: { 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 } | null id: number uuid: string // uuid name: string description?: string | null code?: string | null locationId: number inheritToChildren: boolean active: boolean isInherited: boolean createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/cost-centers/{id} Get cost center Get a single cost center by ID **Permissions:** 🔵 `cost_centers.read` Path parameters: - `id` (number, required) — Cost center ID Response 200 — Default Response ```ts { status: "success" data: { 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 } | null id: number uuid: string // uuid name: string description?: string | null code?: string | null locationId: number inheritToChildren: boolean active: boolean isInherited: boolean createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/cost-centers/{id} Update cost center Update an existing cost center (only own location cost centers, not inherited) **Permissions:** 🟠 `cost_centers.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` (number, required) — Cost center ID Request body: ```ts { name?: string description?: string | null code?: string | null inheritToChildren?: boolean customFields?: unknown | unknown[] } ``` Response 200 — Default Response ```ts { status: "success" data: { 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 } | null id: number uuid: string // uuid name: string description?: string | null code?: string | null locationId: number inheritToChildren: boolean active: boolean isInherited: boolean createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/cost-centers/{id} Delete cost center Soft delete a cost center (sets active=false). Only own location cost centers, not inherited. **Permissions:** 🔴 `cost_centers.delete` Path parameters: - `id` (number, required) — Cost center ID Response 200 — Default Response ```ts { status?: "success" data?: { deleted?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/cost-centers/location-default Get location default cost center Get the location default cost center — the fallback stamped onto tabs at create time when neither the operator nor the table/QR resolved a cost center. null = no default. **Permissions:** 🔵 `cost_centers.read` Query parameters: - `locationId` (number, required) — Location ID (required) Response 200 — Default Response ```ts { status: "success" data: { defaultCostCenterId: number | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/cost-centers/location-default Set location default cost center Set (or clear, when null) the location default cost center. Must reference an active cost center available to the location. **Permissions:** 🟠 `cost_centers.update` Request body: ```ts { locationId: number defaultCostCenterId?: number | null } ``` Response 200 — Default Response ```ts { status: "success" data: { defaultCostCenterId: number | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/integrations/fortnox/cost-centers/sync Sync cost centers from Fortnox Import all cost centres from the location's connected Fortnox and upsert them into Karma (keyed on code). Non-destructive and idempotent. Requires Fortnox to be connected. **Permissions:** 🟠 `cost_centers.update` Request body: ```ts { locationId: number } ``` Response 200 — Default Response ```ts { status?: "success" data?: { created?: number updated?: number total?: number } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Data Streaming Real-time data streaming and sync ## GET /api/v1/data-streaming/connection Get connection details including BigQuery credentials and available datasets Get data streaming connection for current company Response 200 — Data streaming connection details ```ts { status?: "success" data?: { id?: string // uuid companyId?: integer status?: "pending" | "active" | "suspended" | "inactive" bqProjectId?: string | null bqDatasetId?: string | null gcpServiceAccountEmail?: string | null availableDatasets?: string[] currentMonthBytesScanned?: integer lastUsageSyncAt?: string | null // date-time createdAt?: string // date-time updatedAt?: string // date-time activatedAt?: string | null // date-time } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/data-streaming/usage Get monthly usage history including bytes scanned and estimated costs Get usage statistics for data streaming connection Query parameters: - `months` (integer, optional) — Number of months of usage history to return Response 200 — Usage statistics ```ts { status?: "success" data?: { months?: Array<{ month?: string bytesScanned?: integer queryCount?: integer estimatedCostSek?: number }> totalBytesScanned?: integer totalQueryCount?: integer totalCostSek?: number } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Payout Reports Payout report generation and download ## GET /api/v1/payout-reports List payout reports for a location Query parameters: - `locationId` (integer, required) — Location ID to list reports for - `page` (integer, optional) — Page number (1-based) - `limit` (integer, optional) — Items per page - `dateFrom` (string, optional) — Filter by payout date from (YYYY-MM-DD) - `dateTo` (string, optional) — Filter by payout date to (YYYY-MM-DD) - `status` ("ok" | "discrepancy" | "complete" | "generating" | "pending" | "failed", optional) — Filter by report status or discrepancy status - `reportType` ("payout" | "custom", optional) — Filter by report type Response 200 — Default Response ```ts { status?: "success" data?: { data?: Array<{ id?: string // uuid payoutDate?: string periodStart?: string periodEnd?: string netAmountCents?: integer currencyCode?: string hasDiscrepancy?: boolean status?: string }> pagination?: { page?: integer limit?: integer total?: integer } } meta?: { requestId?: string timestamp?: string executionTimeMs?: integer } } ``` ## GET /api/v1/payout-reports/{id} Get payout report detail Path parameters: - `id` (string, required) — Report ID Response 200 — Default Response ```ts { status?: "success" data?: { report?: { id?: string // uuid locationId?: integer payoutReference?: string status?: string payoutDate?: string periodStart?: string periodEnd?: string netAmountCents?: integer currencyCode?: string hasDiscrepancy?: boolean discrepancyAmountCents?: integer | null pdfFilename?: string | null csvFilename?: string | null hasPdf?: boolean hasCsv?: boolean generatedAt?: string | null createdAt?: string } } meta?: { requestId?: string timestamp?: string executionTimeMs?: integer } } ``` ## GET /api/v1/payout-reports/{id}/download/{format} Get signed download URL for report PDF or CSV Path parameters: - `id` (string, required) — Report ID - `format` ("pdf" | "csv", required) — Download format (pdf or csv) Response 200 — Default Response ```ts { status?: "success" data?: { url?: string filename?: string } meta?: { requestId?: string timestamp?: string executionTimeMs?: integer } } ``` ## POST /api/v1/payout-reports/{id}/regenerate Trigger report re-generation Path parameters: - `id` (string, required) — Report ID Response 200 — Default Response ```ts { status?: "success" data?: { message?: string } meta?: { requestId?: string timestamp?: string executionTimeMs?: integer } } ``` ## POST /api/v1/payout-reports/aggregates Generate an aggregate payout report for a location hierarchy Header parameters: - `idempotency-key` (string, optional) — Optional retry key. Reuse it to address the same report resource. Request body: ```ts { locationId: integer dateFrom: string dateTo: string } ``` Response 202 — Default Response ```ts { status?: "success" data?: { reportId?: string // uuid payoutReference?: string sourceReportCount?: integer locationCount?: integer } meta?: { requestId?: string timestamp?: string executionTimeMs?: integer } } ``` ## POST /api/v1/payout-reports/generate Generate on-demand custom report for a date range Request body: ```ts { locationId: integer dateFrom: string dateTo: string } ``` Response 202 — Default Response ```ts { status?: "success" data?: { reportId?: string // uuid payoutReference?: string } meta?: { requestId?: string timestamp?: string executionTimeMs?: integer } } ``` ## GET /api/v1/payout-reports/settings Get payout report settings Response 200 — Default Response ## PUT /api/v1/payout-reports/settings Update payout report settings Response 200 — Default Response ## GET /api/v1/payout-reports/summary What Adyen paid over a range of payout dates, one row per location Summed in SQL from the itemised payout lines, so it answers at once and ties out to the öre: paidOutCents is the payouts' net and bankTransfersCents the cash moved. Only itemised payouts count; payouts before 2026-04-16 were never itemised. The POST /aggregates PDF is the printable counterpart. Query parameters: - `locationId` (integer, required) — Root location. With includeChildren every descendant with a payout in range gets its own row. - `dateFrom` (string, required) — First payout date, inclusive. - `dateTo` (string, required) — Last payout date, inclusive. At most 366 days after dateFrom. - `includeChildren` (boolean, optional) — Include every non-deleted descendant of locationId. - `locationIds` (string, optional) — Optional comma-separated subset of the resolved hierarchy. Ids outside the hierarchy are rejected; the caller must have access to every included location. Response 200 — Default Response ```ts { status?: "success" data?: { payoutSummary?: { dateFrom?: string dateTo?: string rootLocationId?: integer includeChildren?: boolean generatedAt?: string rows?: { … }[] totals?: { currencyCode?: string locationCount?: integer payoutCount?: integer transactionCount?: integer unmatchedCount?: integer grossCapturedCents?: integer tipsCents?: integer karmaFeesCents?: integer refundsCents?: integer chargebacksCents?: integer pspFeesCents?: integer reversedNonSaleNetCents?: integer unmatchedCapturesCents?: integer surplusFeeRecoveryCents?: integer otherCents?: integer paidOutCents?: integer bankTransfersCents?: integer } | null } } meta?: { requestId?: string timestamp?: string executionTimeMs?: integer } } ``` # Reporting Purchase summary reports and financial reporting ## GET /api/v1/reporting/export Generate and download a SIE / CSV / PDF accounting export Server-side generation of the reporting export the frontend used to build in the browser. Split flags and chart-of-account mappings are read from the saved location_export_settings (sie) row. Output is byte-identical to the frontend export. Query parameters: - `locationId` (integer, required) — Location ID to export for - `startDate` (string, required) — Start date in YYYY-MM-DD format - `endDate` (string, required) — End date in YYYY-MM-DD format (inclusive) - `format` ("sie" | "csv" | "pdf", required) — Export file format - `includeChildren` (boolean, optional) — Include every descendant of locationId using each location's own timezone and business-day boundary. - `locationIds` (string, optional) — Optional comma-separated subset of the server-resolved hierarchy. - `splitByCostCenter` (boolean, optional) — Overrides the saved location_export_settings split_by_cost_center flag for this request. Omit to use the saved setting. ## GET /api/v1/reporting/export-settings Get saved accounting export configuration for a location Query parameters: - `locationId` (integer, required) - `exportType` ("sie" | "gbat10", required) - `locationIds` (string, optional) — Comma-separated subset of the hierarchy, narrowing `includeDescendants` to the same locations the export will cover. Must lie inside the location's own subtree. - `includeDescendants` (boolean, optional) — Also report the effective prepaid accounts of accessible descendants in an export run from this location, including their own fallback accounts and the accounts their ancestors impose. Read-only in the parent. Response 200 — Default Response ```ts { status?: "success" data?: { exportSettings?: { locationId?: integer exportType?: "sie" | "gbat10" mappings?: Record includeKarmaFees?: boolean splitByCostCenter?: boolean splitByPrepaidType?: boolean useBusinessDay?: boolean splitByInvoice?: boolean excludeInvoice?: boolean applyPrepaidAccountsToDescendants?: boolean updatedAt?: string updatedByRetailerUserId?: integer | null updatedByName?: string | null } | null prepaidAccountOverride?: { locationId: integer locationName: string accounts: Record imposedBy: Record } | null descendantPrepaidAccounts?: Array<{ code: string label: string account: string | null sources: unknown[] }> } meta?: { requestId?: string timestamp?: string executionTimeMs?: integer } } ``` ## PUT /api/v1/reporting/export-settings Save accounting export configuration for a location Request body: ```ts { locationId: integer exportType: "sie" | "gbat10" mappings: Record includeKarmaFees: boolean splitByCostCenter?: boolean splitByPrepaidType?: boolean useBusinessDay?: boolean splitByInvoice?: boolean excludeInvoice?: boolean applyPrepaidAccountsToDescendants?: boolean } ``` Response 200 — Default Response ```ts { status?: "success" data?: { exportSettings?: { locationId?: integer exportType?: "sie" | "gbat10" mappings?: Record includeKarmaFees?: boolean splitByCostCenter?: boolean splitByPrepaidType?: boolean useBusinessDay?: boolean splitByInvoice?: boolean excludeInvoice?: boolean applyPrepaidAccountsToDescendants?: boolean updatedAt?: string updatedByRetailerUserId?: integer | null updatedByName?: string | null } | null } meta?: { requestId?: string timestamp?: string executionTimeMs?: integer } } ``` ## POST /api/v1/reporting/export/fortnox Generate the SIE file for the given date range and upload it (CP437-encoded) to the location's Fortnox Inbox ("Vouchers" folder). Requires an active Fortnox integration whose token carries the inbox scope. Request body: ```ts { locationId: integer startDate: string endDate: string } ``` Response 200 — Default Response ```ts { status?: "success" data?: { fortnoxFileId?: string fileName?: string } meta?: { requestId?: string timestamp?: string executionTimeMs?: integer } } ``` ## GET /api/v1/reporting/fortnox-sie-exports Recent SIE file delivery attempts for a location. Successful files are available in the Fortnox Inbox (Vouchers) and must be imported manually in Fortnox. Query parameters: - `locationId` (integer, required) - `limit` (integer, optional) - `cursor` (string, optional) — Opaque cursor returned as nextCursor by the previous page Response 200 — Default Response ```ts { status?: "success" data?: { exports?: Array<{ exportStartDate?: string exportEndDate?: string trigger?: "manual" | "nightly" status?: "success" | "failed" error?: string | null fortnoxFileId?: string | null fileName?: string createdAt?: string }> nextCursor?: string | null abandonedDays?: string[] } meta?: { requestId?: string timestamp?: string executionTimeMs?: integer } } ``` ## GET /api/v1/reporting/fortnox-sie-settings Fortnox connection state and whether the nightly SIE export to the Fortnox Inbox is enabled for the location. Query parameters: - `locationId` (integer, required) Response 200 — Default Response ```ts { status?: "success" data?: { connected?: boolean nightlyExportEnabled?: boolean bookingMode?: "inbox" | "voucher" nightlyRunsAt?: string timezone?: string } meta?: { requestId?: string timestamp?: string executionTimeMs?: integer } } ``` ## PUT /api/v1/reporting/fortnox-sie-settings Update the Fortnox SIE settings for a location: toggle the nightly export and/or choose the booking mode ('inbox' = upload SIE file for manual import; 'voucher' = book a verifikation directly into the ledger). Provide at least one field. Request body: ```ts { locationId: integer nightlyExportEnabled?: boolean bookingMode?: "inbox" | "voucher" } ``` Response 200 — Default Response ```ts { status?: "success" data?: { connected?: boolean nightlyExportEnabled?: boolean bookingMode?: "inbox" | "voucher" nightlyRunsAt?: string timezone?: string } meta?: { requestId?: string timestamp?: string executionTimeMs?: integer } } ``` ## GET /api/v1/reporting/pos-sales-report Sales split by payment method for one business day or shift, for printing at the till Operational counterpart to purchase-summary. Tips are excluded, marketplace orders are included, and figures are net of confirmed refunds with the refunded amount reported separately. Prepaid always splits into its configured types. Query parameters: - `locationId` (integer, required) — Location to report on - `date` (string, optional) — Business day in YYYY-MM-DD, or the day a `shiftId` window is anchored to. Omit for the current one as the location reckons it — resolved server-side so a POS device with a wrong clock cannot print the wrong day. - `shiftId` (string, optional) — Report on one of the location's configured shift windows instead of the whole business day. The window comes from the location's settings, never from the request — a shift running past midnight (20:00-05:00) ends the following morning. Omit the date too and the most recent occurrence of the shift that has already started is used. - `costCenterId` (integer, optional) — Restrict to purchases whose tab (or its QR code) carries this cost center. - `scopeToUser` (boolean, optional) — Restrict to the authenticated staff member's sales. Which sales those are is `attribution`'s call. A flag rather than an id: a request may only ever narrow the report to its own caller. - `attribution` ("rang" | "settled", optional) — With scopeToUser: `rang` counts the items this person put on a tab, per line (a tab two people served splits between them) — the rule the analytics top-staff figures use. `settled` counts the purchases this person took the payment for, whole — the drawer's view, by tender. A purchase nobody on staff settled (a guest paying by QR) is on no personal slip in `settled` mode. Tips are allocated by `rang` in both modes. Ignored without scopeToUser. Response 200 — Default Response ```ts { status?: "success" data?: { posSalesReport?: { period?: { date?: string startAt?: string endAt?: string timezone?: string businessDayStartMinutes?: integer shift?: { … } | null } scope?: { locationId?: integer locationName?: string costCenterId?: integer | null costCenterName?: string | null retailerUserId?: integer | null retailerUserName?: string | null attribution?: "rang" | "settled" } currencyCode?: string paymentMethods?: { … }[] vats?: { … }[] totals?: { grossInclVatCents?: integer tipsCents?: integer salesInclVatCents?: integer salesExclVatCents?: integer vatCents?: integer refundsCents?: integer transactionCount?: integer openTabsCents?: integer openTabsCount?: integer } } } meta?: Record } ``` ## GET /api/v1/reporting/purchase-summary Get aggregated purchase data for accounting export Query parameters: - `locationId` (integer, required) — Location ID to get purchase summary for - `startDate` (string, required) — Start date in YYYY-MM-DD format - `endDate` (string, required) — End date in YYYY-MM-DD format (inclusive) - `includeChildren` (boolean, optional) — Include every descendant of locationId. The server resolves the hierarchy and requires access to the complete selected scope. - `locationIds` (string, optional) — Optional comma-separated subset of the server-resolved hierarchy. Requires includeChildren=true to select descendants. - `splitByCostCenter` (boolean, optional) — When true, rows in items / vats / psp gain optional costCenterCode + costCenterName, and the response includes a top-level costCenters array. Defaults to false for byte-identical legacy behaviour. - `splitByPrepaidType` (boolean, optional) — When true, the single `prepaid` PSP row splits into one row per resolved type (e.g. `prepaid_wolt`, `prepaid_cash`). The response gains a `prepaidTypes` array mapping each synthetic code to a merchant-facing label. - `useBusinessDay` (boolean, optional) — The report day is always anchored to the location timezone (local calendar midnight). When true, the boundary additionally shifts by business_day_start_minutes (the business-day rollover). - `splitByInvoice` (boolean, optional) — When true, the single `invoice` PSP row splits into one row per invoice (`invoice_`, plus `invoice_unassigned` for invoice-paid purchases not on any invoice). The response gains an `invoices` array mapping each synthetic code to its label. - `excludeInvoice` (boolean, optional) — When true, invoice-paid purchases are dropped from the summary entirely — no invoice amounts in items / vats and no `invoice` PSP row. Mutually exclusive with splitByInvoice. Response 200 — Default Response ```ts { status?: "success" data?: { purchaseSummary?: { items: { … }[] vats: { … }[] psp: { … }[] fees: { … }[] invoicedFees?: { … }[] costCenters?: { … }[] prepaidTypes?: { … }[] invoices?: { … }[] } } meta?: { requestId?: string timestamp?: string executionTimeMs?: integer } } ``` ## GET /api/v1/reporting/sales-and-fees Monthly sales & fees statement, one row per location Confirmed sales, refunds made in the month, Karma fees as charged, and the resulting payout for a location and, with includeChildren, every location under it. Marketplace orders are excluded because the marketplace settles them itself. Query parameters: - `locationId` (integer, required) — Root location of the statement. With includeChildren every descendant gets its own row. - `period` (string, required) — Calendar month in YYYY-MM, anchored to each location's own timezone. Sales count by purchase time, refunds by the time they were made. - `includeChildren` (boolean, optional) — Include every non-deleted descendant of locationId, one row each. - `locationIds` (string, optional) — Optional comma-separated subset of the resolved hierarchy. Ids outside the hierarchy are rejected; the caller must have access to every included location. - `format` ("json" | "csv", optional) — json returns the statement in the standard envelope; csv downloads it with the legacy company-report column names first. # Reviews Customer reviews and feedback for purchases ## POST /api/v1/reviews Create review Create a new review for a purchase. Reviews are linked to purchases via purchase_intent_id and can only be created once per purchase (duplicate submissions are idempotent). Request body: ```ts { purchaseIntentId: string // uuid reviewTags?: string[] rating: integer } ``` Response 200 — Default Response ```ts { status: "success" data: { reviewId: string // uuid } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/reviews/{purchaseIntentId} Get review by purchase intent ID Get a review by purchase intent ID. Returns null if no review exists for the given purchase (not a 404 error). Path parameters: - `purchaseIntentId` (string, required) — UUID of the purchase intent to get the review for Response 200 — Default Response ```ts { status: "success" data: { reviewId?: string // uuid reviewTags?: string[] | null rating?: integer } | null meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # BigQuery changelog (/docs/bigquery-changelog) This log covers the **customer-scoped BigQuery views**, not the [REST API changelog](/docs/changelog). An entry describes an observed schema or a verified source-model change; a source migration date is **not** a promise that every customer view was refreshed on that date. Check your own environment's `INFORMATION_SCHEMA.COLUMNS` before depending on a new field. New nullable fields and view changes can appear at different times in development, staging, and production. The [BigQuery guide](/docs/bigquery) explains how to export safely; the [field reference](/docs/bigquery-fields) records a dated example of the available views. ## 2026-10-26 — Ledger tabs: purchase lines say price, discount and net (source code, not deployed) [#2026-10-26--ledger-tabs-purchase-lines-say-price-discount-and-net-source-code-not-deployed] * **Status:** a source-model and code change under review, not applied in any environment. * **Changed, `purchase_line_items` on ledger tabs** (`tabs.ledger_account_id` set): * `total_amount_cents`: the price of the units the payment covers before every discount (was: after discounts given before payment). * `applied_total_discount_amount_cents`: every discount on those units for the payment, given before payment (staff discounts, price changes, loyalty-integration discounts, vouchers on the order) or at it (was: the payment-time discount only). * `pre_discount_vat_amount_cents`: the VAT of `total_amount_cents`. * **Unchanged:** `total_amount_cents - applied_total_discount_amount_cents`, `final_amount_cents`, the VAT columns, `purchase_vats`, purchase amounts, refunds, and every line outside the ledger. * **Action:** on ledger tabs, read gross sales as `SUM(total_amount_cents)` and discounts as `SUM(applied_total_discount_amount_cents)`. Ledger lines written before this release keep the earlier meaning. This replaces the purchase-line points of the 2026-10-22 entry. ## 2026-10-22 — Tab ledger: what the release changes in your data (source code, not deployed) [#2026-10-22--tab-ledger-what-the-release-changes-in-your-data-source-code-not-deployed] * **Status:** a source-model and code change under review. It is not applied in development, staging, or production, and no customer view of the new tables exists yet. This entry describes the release as it will ship and replaces the tab-ledger and voucher entries of October 19–21. A later entry will record each environment, and each customer view once it is checked. * **Which tabs:** a location turns the tab ledger on through a location feature, location by location. Only tabs **created** after that get a ledger account. Tabs that already exist, open or closed, are not backfilled and finish as before. Everything below marked "ledger tabs" applies only to tabs with a ledger account. * **New nullable UUID columns** on existing views: * `tabs.ledger_account_id`: the tab's ledger account. * `purchases.discharge_id`: set when a payment on a ledger tab is confirmed. * `purchase_refunds.discharge_id`: set when a refund on a ledger tab is confirmed. * Setting `discharge_id` also advances `updated_at`. * A payment's `discharge_id` changes again only if the purchase leaves `confirmed`, or its payer moves to another tab. * **A tab can leave the ledger.** When the ledger cannot record a change, the change still goes through and the tab continues on the previous calculation. * `tabs.ledger_account_id` becomes NULL, and that account's ledger rows stop growing. * Later payments have a NULL `discharge_id`. * Treat a ledger account as complete only while a tab still points at it. * **Planned tables** (not in any customer view yet; fields in the [field reference](/docs/bigquery-fields)): * The ledger: `ledger_accounts`, `ledger_charges`, `item_charges`, `fee_charges`, `charge_tax_rates`, `ledger_reductions`, `ledger_discharges`, `ledger_allocations`. * Applied discounts: `account_discounts` (name, kind, value) and `discount_reductions`. * The sources of those discounts: `manual_discounts` (given by staff) and `voucher_discounts` (one discount per redeemed Karma voucher). * `tab_purchase_discounts`: the payment attempt each discount came with, and its `scope`. `payment` means bound to that payment, in its lines' `applied_total_discount_amount_cents`. `order` means given on the items before payment. * Every table is append-only: a correction is a new row with `reverses_id`, never an edit. * Identifiers are time-ordered UUIDs (version 7). * The tables have no `location_id`; the planned views scope them through the owning tab. * `account_discounts` holds applied discounts and is unrelated to the `discounts` definitions view. * Integration-specific discount detail, partner redemption records, and the ledger's internal planning and failure tables are not planned as customer views. * **Purchase lines on ledger tabs.** The meaning of every column is unchanged, and the paid line is still `total_amount_cents - applied_total_discount_amount_cents`. What is new: * **No discount entries in `applied_modifications`.** Vouchers and other discounts no longer appear there with a negative `priceAdjustmentCents`. A discount's name and source are in the ledger tables. * **Amount payments** (a guest paying part of a tab by amount) are `type = 'product'` lines carrying the share's `quantity_cents`, which can be fractional. They are not `type = 'amount'` lines. * **Line VAT:** `total_vat_amount_cents` is the VAT of the line's net total. It can differ from per-unit VAT times quantity by at most one minor unit per unit. `pre_discount_vat_amount_cents` is always written. `purchase_vats` is the sum of the lines' VAT per rate. * **Discounts given before payment** are already in the kitchen line's `price` and in the purchase line's `total_amount_cents`, as before. On ledger tabs this covers staff discounts, price changes and loyalty-integration discounts. The ledger writes `kitchen_dispatch_items.price`, `discount` and `allocated_*`. Nothing is written to `kitchen_dispatch_items.discount_details`. * **Tab columns on ledger tabs:** * `paid_amount_cents` and `paid_amount` are payments net of refunds, plus the discounts bound to those payments. * A purchase that leaves `confirmed` lowers paid at once. * `remaining_balance_cents` is `total_amount_cents - paid_amount_cents`, never below zero. * Report sales from purchases and refunds, not from tab columns. * **Marketplace orders, on every tab:** * The intake writes the prepaid purchase's lines before confirming it, so the tab shows the purchase amount as paid from intake: goods plus booked marketplace fee lines, minus the merchant-funded discount. Previously it stayed 0 until a later update. * Fee lines are not part of the tab total, so paid can exceed `total_amount_cents`. * A repeated delivery of the same external order creates nothing. * On ledger tabs, the merchant-funded discount is `applied_total_discount_amount_cents` on the food lines, with no negative `marketplace_discount` line. Tabs outside the ledger keep the negative line. * Some marketplace purchases stay `pending` for a moment between intake and confirmation. * **Value vouchers of a POS integration** are a payment of their own, on every tab: * A voucher that pays a whole order is one purchase with a new provider value in `purchases.psp`. * A voucher that pays part makes two purchases on the tab: the voucher's, then the rest paid by the guest's method. * Neither purchase's lines carry the voucher as a discount. * Promotion codes of that integration are applied discounts, as Karma vouchers are. * **`discounts` view:** * Stored value is a gift card, never a voucher. A source migration sets `enabled = false` on every enabled `GIFT_CARD` voucher that is not a template. * New vouchers hold no balance (`balance_mode = 'NONE'`), and `remaining_balance_cents` is no longer deducted on redemption. Existing balances stay as recorded. * Gift-card templates are unchanged. * **Voucher rules at payment**, on every payment path: * Loyalty enrollment and tier are checked against the paying guest. * Per-person limits count each guest on a shared tab separately. * Two vouchers that cannot be combined are not both accepted. * A refused code refuses the payment's other codes. * User, e-mail, phone and segment restrictions are not checked at payment, as before. * On ledger tabs, each voucher is its own applied discount, named by the voucher's public title or `Voucher: `, and is sized on what the paying guest pays. * **Breaking: `kitchen_dispatch_items.discount_details` is dropped.** A source migration that runs after the deploy removes it. A replicated column can linger with its old values, and the customer view loses it at its next refresh. Stop selecting it before then. An integration's internal tab-lock and receipt tables, which no customer view exposes, are dropped too. * **Unchanged:** every tab outside the ledger keeps today's lines, VAT and tab columns, apart from the marketplace, value-voucher and voucher-rule changes above. * **Validation:** * Replaying 189,985 production tabs through the ledger: 189,959 reproduced their recorded money. 24 (0.013 %) would leave the ledger, for documented reasons: 17 items paid above their current price, and 7 overpayments. 2 differ because of concurrent payments the stored rows cannot reproduce. * The ledger's line builder against 4,854 recorded purchases: 0 differences in total, discount, quantity, rate or line count. * 41 end-to-end payment flows: no tab left the ledger. * **Consumer action:** * Stop selecting `discount_details`. * Do not expect discount entries in `applied_modifications` or `type = 'amount'` lines for tabs with a `ledger_account_id`. * Treat an unknown `purchases.psp` value as a payment, not as card revenue. * To report discounts by name and source, plan to read `account_discounts` with `tab_purchase_discounts` once a later entry announces the views in your environment. ## 2026-10-20 — Development source changes; customer-view availability remains separate [#2026-10-20--development-source-changes-customer-view-availability-remains-separate] The October 19–20 development-source review adds per-item voucher rates, receipt-specific item/variant names and loyalty identifiers to the release preview. This review performs no customer-view refresh, replication check or warehouse backfill and announces no new production BigQuery columns or views. * **Mixed voucher rates:** checkout carries the matched rate of each participating line through discount and VAT allocation, including amount-mode partial payments. For sales reporting, use the recorded line and purchase amounts; a single blanket percentage cannot reproduce every mixed-rate bill. Existing restrictions, excluded nested amounts and maximum discounts remain relevant. * **Receipt names:** the source adds optional API `receiptTitle` values and saved purchase-line receipt text. A guest-facing receipt label is distinct from the internal/reporting item title. Do not assume `receipt_title` is selectable from a customer view until that view's metadata confirms it. * **Loyalty and usage details:** unique guest fields, program identifiers and AI request-cost estimates do not by themselves establish advertised warehouse fields. Historical AI estimates and gross request costs are not final invoice amounts or a sales-ledger backfill. * **Reporting fixes:** configured organisation numbers appear in application PDF headers; Caspeco staff attribution and payment recovery fixes do not announce a new customer-view schema or a historical data rewrite. The previously checked price-cluster view availability and its development/test scope remain unchanged. See the [API changelog](/docs/changelog) for the source contracts and the [BigQuery guide](/docs/bigquery) for the verified warehouse contract. ## 2026-10-19 — Upcoming dev changes with reporting implications [#2026-10-19--upcoming-dev-changes-with-reporting-implications] Source review compares `dev` with the production branch, `main`, on October 19. These changes are pending on `main`. **This source review itself performs no customer-view refresh, production replication check or historical backfill.** The separately recorded October 19 price-cluster sync below is the latest development/staging customer-view update on `dev`; the older field reference remains a dated snapshot. Check your dataset before changing an export. * **Historical refunds and Save service fees:** the dev refund flow supports eligible historical purchases and returns the refundable Save service-fee portion. The REST purchase response exposes fee/refund details, including `serviceFeeRefundAmountCents`; that camel-case response property is not a verified customer-view column. Reconcile confirmed refunds, their product allocations and any service-fee return separately; do not assume a product-line sum includes the entire provider refund. Existing nullable `purchase_refund_line_items.quantity_cents` semantics remain unchanged: explicit units use hundredths, while amount-only and historical rows need not have a quantity. No quantity backfill is announced. * **Remakes and supplements:** the dev order model introduces `ambulance_kind` with `charged` and `supplement` values. Charged remakes retain their price; supplements and their children are forced to zero and handled separately on kitchen tickets/KDS. The application requires central database migrations, but this review does not establish a corresponding customer-view column, its BigQuery type or its null behavior. Do not substitute `zero_ticket` for a remake classification or infer one from a zero amount. The follow-up also exposes nullable `ambulanceKind` on REST purchase lines and labels remakes in merchant history; this property comes from the linked kitchen dispatch item and does not establish a new purchase-line column in BigQuery. * **Inventory, recipes and history:** the optional ingredient/recipe model, reviewed stocktakes and `inventory_item_versions` configuration journal are source/application additions. No ingredient, recipe, stocktake or version-history customer views were verified for this entry. Recipe-derived purchase cost, nutrition and current shared/cluster prices are configuration or estimates; they do not rewrite recorded purchase-line prices, VAT or historical cost. Inventory configuration history excludes stock-only changes and starts with the first captured baseline. * **Current prices and table grouping:** Foodora, Wolt and Uber Eats publication uses the shared pricing implementation with location/channel overrides; this changes current menu publication, not the historical sale ledger. The reporting API's `hasTable` distinction separates OTC sales from tables without a group; it is not an announced BigQuery column. For warehouse reporting, use only table relationships actually present in your authorized views. * **Receipt presentation and storefront settings:** dev receipt rendering replaces voucher-code labels with public discount names or a generic label and masks codes in voucher payment summaries. This does not establish changes to stored discount/voucher data, customer-view columns or historical rows. The storefront ratings toggle and confirmation-content editor are presentation changes, not warehouse schema additions. * **Caspeco corrections:** the dev exporter sends confirmed refunds for already-exported sales as deduplicated correction tickets, booked on the refund's day and linked to the original receipt. This is an integration export behavior, not a new BigQuery refund schema or an independent refund payout-method field. It does not change the original purchase's sale date or state. Refund corrections default on and can be date-restricted or disabled in the integration settings. ## 2026-10-12–18 — Production-branch reporting fixes [#2026-10-1218--production-branch-reporting-fixes] These direct commits and cherry-picks were recorded on `main` during October 12–18 in Europe/Stockholm. They describe application behavior in that branch; deployment, Datastream replication and customer-view availability are separate. * **October 12 — Per-location reporting days:** combined SIE/CSV/PDF exports, Fortnox vouchers and purchase summaries apply each included location's calendar/business-day setting. The exporting location honors the requested setting; children use their own saved setting, defaulting to calendar day. A warehouse reconciliation must reproduce those per-location windows rather than apply the parent's window to every child. This changes export selection, not the meaning or type of `purchases.purchased_at`, and announces no data backfill. * **October 12–13 — State and configuration fixes:** nearest-parent pricing and inherited course names resolve correctly, stock reports honor their filters, and verified late-authorization cancellations are reconciled. Continue filtering finalized sales/refunds by their recorded confirmed state; an authorization or cancellation is not itself a finalized sale or refund. No new customer-view column or historical rewrite is established by these commits. * **October 12–15 — Operational and commercial reporting:** Adyen report-cost processing is batched and bounded. Team Pulse/progression uses verified daily activity and transaction revenue, including Swish totals checked against the canonical intent, with corrected contributor attribution, deduplication and Stockholm daily limits. These internal reporting fixes do not add customer-scoped BigQuery views or alter the warehouse payment/refund contract. * **Other production-only fixes:** saved-card CVC capability, scheduled article visibility, refund receipt labels, voucher reset, the HOLD banner and the October 17 POS touch-quantity cherry-pick are included in the [release notes](/docs/changelog). They announce no customer-view schema change. ## 2026-10-19 — Price-cluster sync enabled in development and staging [#2026-10-19--price-cluster-sync-enabled-in-development-and-staging] * Added `price_clusters`, `price_cluster_entries`, `price_cluster_locations`, and `price_cluster_applications` to the existing PostgreSQL publications and Datastream streams in development and staging/beta. All four backfills completed in each environment, and both streams reported running without stream errors. * Created and authorized the four customer-scoped views in development `customer_location_5182` and staging `customer_location_1500000000`. Each dataset now advertises **449 columns across 30 views**, with matching table/column/type inventories. Cluster ownership is restricted through the existing `location_members`; location/application rows also require an in-scope target location. * All four views are queryable. The checked EH scopes currently return zero rows, matching the scoped source data; this is not missing replication. Existing dataset permissions were preserved. Connectors should refresh their view inventory and metadata. * These views describe current pricing configuration, not historical charged prices. Use recorded purchase lines for past sales. This operation did not change or verify production customer views. ## 2026-10-12 — Reporting clarification and dev/test parity recheck [#2026-10-12--reporting-clarification-and-devtest-parity-recheck] * Rechecked the existing development `customer_location_5182` and staging `customer_location_1500000000` view inventories: **429 advertised columns across 26 views**, with identical table/column/type tuples. `zero_ticket` is `BOOL`, refund-line `quantity_cents` is `INT64`, and `printer_jobs.rendered_content` is absent. No top-level `BYTES` column is advertised in either checked inventory. This is a recheck, not a new deployment or a production/customer-wide availability claim; the customer's connector run must be verified separately. * Clarified that sales-line `item_type` is extensible text. The guide lists current goods/non-goods classifications and adds an unknown-value preflight before irreversible uploads. Inventory catalogue `itemType` and legacy item-type references are separate contracts. * Added the checked multi-purchase tab example, fractional product allocations, flat split-bill modifier limitations and the kitchen-link article fallback. These explain recorded data; no corrected marketplace price split, modifier parent-link repair or historical backfill is announced. * Documented historical pre-discount line VAT and purchase/line rounding differences. Current discount writers calculate post-discount line VAT, but the example observations do not establish an exact rollout timestamp or repair older rows. Use recorded purchase-level VAT for purchase reporting. * Documented partner-specific tab member lookup and its mutable tab scope. Confirm the integration's actual JSON key with Karma. An immutable per-payment member export, independent refund payout method and returns without an original Karma sale are not supplied by the checked customer-view contract. These are explicit limitations, not new fields. ## 2026-10-08 — Development/staging customer-view schema convergence [#2026-10-08--developmentstaging-customer-view-schema-convergence] * Refreshed the existing customer views in the checked development (`customer_location_5182`) and staging (`customer_location_1500000000`) datasets. Afterward, each advertises **429 top-level columns across 26 views**, with identical table/column/type tuples. The existing location filters and authorized-view grants were preserved; this is **not** a claim about production datasets or every customer's separate dataset. * `kitchen_dispatch_items.zero_ticket` is now advertised as `BOOL` in both views (KAR-150). `printer_jobs.rendered_content` (`BYTES`) is removed from both customer views (KAR-149); the upstream operational table still contains it. `rendered_at` and `rendered_media_type` are advertised in both customer views. Connectors should re-probe view metadata, not the raw source table. * `purchase_refund_line_items.quantity_cents` is now advertised as `INT64` in **both** checked customer views. Historical and amount-only allocations remain nullable; no quantity backfill is asserted. Previously hidden source columns in `items`, `kitchen_dispatch_items`, `kitchen_dispatches`, and `purchase_line_items` were refreshed into both views; consult the [field reference](/docs/bigquery-fields) for the newly observed fields. * Dev-only `purchase_line_items.pre_discount_value_cents` and `purchases.processed_by_retailer_user_id` were removed **from the dev customer views**: neither exists in the checked development PostgreSQL source, although both linger in its replicated BigQuery source tables. The dev-only `kitchen_dispatch_items.is_surplus` customer-view column was also removed to match staging: it exists in dev PostgreSQL but not staging PostgreSQL, and is **not** interchangeable with `item_type = 'surplus'` (seven mismatches in a checked recent dev sample). For a sold-line surplus flag, use `purchase_line_items.is_surplus`. None of these three changes deletes source data. * The parity check compared stored `INFORMATION_SCHEMA.COLUMNS`, not just a `SELECT *` sample. After the view refresh, no table/column/type difference remained between the two checked customer datasets. Raw Datastream tables can still differ from live PostgreSQL when dropped columns linger; do not treat this view parity as upstream CDC cleanup. ## 2026-10-06 — Production source release; customer-view availability is separate [#2026-10-06--production-source-release-customer-view-availability-is-separate] * The October 6 production release applied the refund-control and price-cluster source migrations, including linked cluster pricing, hidden refund reasons and descendant prepaid-account export settings. This supersedes the earlier source-branch availability check below; it does **not** verify production Datastream replication or customer-view provisioning. * Product refund allocations can record nullable `purchase_refund_line_items.quantity_cents` in hundredths of a unit: **100 means one unit**. Amount-only and historical refunds can remain `NULL`; no quantity backfill is announced. Check your customer view's metadata before selecting it. The October 8 observation above verifies the column in the checked development and staging views only. * Price clusters describe current menu-price configuration, not the historical price charged. For historical sales use the recorded purchase lines. No production customer-view availability is asserted for the four price-cluster tables or the refund-settings tables. ### Earlier October 6 preparation check [#earlier-october-6-preparation-check] * A source migration defines `price_clusters`, `price_cluster_entries`, `price_cluster_locations`, and `price_cluster_applications` for current grouped-price configuration. The migration was **not in the checked remote database `dev` branch** on October 6; this is not an announcement of replicated tables or customer views. * Customer-scoped, authorized views for all four tables have been prepared. They require the owner location to be in the customer's `location_members`; location/application rows additionally require their target location to be in scope. Do not query them until replication, customer-view provisioning, and dataset metadata are verified for your environment. These tables are not a historical sale-price ledger. ## 2026-10-05 — Discounts in development/staging; refund quantity in development [#2026-10-05--discounts-in-developmentstaging-refund-quantity-in-development] * The `discounts` PostgreSQL source was **already included** in the running main Datastream streams in development, staging, and production, with completed backfill objects and replicated `analytics_eupublic.discounts` tables in all three. No stream inclusion change was needed. The separate legacy production stream does not own this dataset and was left untouched. * Customer-scoped `discounts` views were created and authorized in the existing development and staging customer datasets on October 5. Each filters `discounts.location_id` through that dataset's `location_members`; the checked staging view returned 13 rows and zero out-of-scope rows. There was **no production customer dataset** in the checked production project, so this entry does not claim a production customer view or customer access. The view reports current discount/voucher definitions, including gift-card configuration, **not** an applied-discount or redemption ledger. The [field reference](/docs/bigquery-fields) records 34 view columns observed in staging. * `purchase_refund_line_items.quantity_cents` is now present in the development upstream BigQuery table and advertised as `INT64` by the refreshed development customer view. It remains absent from the checked staging upstream table and customer view; production upstream metadata also does not advertise it, and no production customer dataset was available to check. It is not included in the staging inventory. Amount-only and historical refunds still lack a recorded quantity; no backfill is announced. Before selecting the column, confirm its presence in **your** customer view. ## 2026-10-04 — Refund quantities in remote `dev` source model (not yet in customer views) [#2026-10-04--refund-quantities-in-remote-dev-source-model-not-yet-in-customer-views] * The database `dev` branch adds nullable, positive-integer `purchase_refund_line_items.quantity_cents`. The purchase service's `dev` branch writes it when an explicit product quantity is selected for a refund: **100 means one unit**, and 150 means 1.5 units. This is the quantity on a refund allocation to the original purchase line, not a currency amount and not the original sale's `purchase_line_items.quantity_cents`. * An amount-only refund leaves the new field `NULL`; older refunds also have no recorded refunded quantity. Do **not** infer returned units from monetary allocations or treat `NULL` as zero returned units. Continue using `total_amount_cents` for the refunded monetary magnitude and restrict financial reporting to confirmed refunds. * **Availability check (October 4):** Neither the development nor staging customer view advertises this column in `INFORMATION_SCHEMA.COLUMNS`; it was also absent from the checked development upstream BigQuery table. No customer-view column addition or backfill is announced. Before selecting it, check your own view metadata and confirm it is queryable. The [field reference](/docs/bigquery-fields) retains its September 28 inventory and notes this pending field separately. * The same source migration adds location refund settings and reason definitions; no corresponding customer views were observed. Other recent source additions (including gift-card details and location seasonality) were not observed as newly advertised columns in the checked customer views. They are **not** part of this customer-view schema announcement. ## 2026-09-28 — Staging schema inventory [#2026-09-28--staging-schema-inventory] * A staging customer dataset exposed **25 views and 380 advertised top-level columns** when the field reference was prepared. This is a **snapshot, not a versioned promise** about other customer datasets or production. * Confirmed reporting interpretation: `purchases.id` identifies the purchase within an environment; `purchases.purchased_at` is the UTC sale time represented as a BigQuery `TIMESTAMP`; `purchase_refunds.purchase_id` ties refunds back to the original purchase. `purchases.cr_receipt_id` is a separate, nullable fiscal reference and not a universal export key. * A confirmed sale remains `confirmed` after a refund; a technical zero-refund may instead reference a failed purchase. Only `confirmed` purchases count as finalized sales, and only `confirmed` refunds count as finalized refund records. For an article export, `purchase_line_items.type = 'product'` is the starting filter, with non-goods `item_type` exclusions and separate treatment of tips and amount payments. * **Known schema mismatch:** `kitchen_dispatch_items.zero_ticket` exists and resolves as `BOOL` when queried in the staging customer view, but that view's stored schema and `INFORMATION_SCHEMA.COLUMNS` do not yet list it. Inferring the type from one nullable row can produce a false string/boolean mismatch. A view-schema refresh and connector recheck are needed; this log does **not** claim the fix has been deployed. * **Known connector limitation:** `printer_jobs.rendered_content` is `BYTES` in the staging view. Connectors that cannot ingest binary data from `SELECT *` must omit it or use a deliberate Base64 `STRING` projection. No customer-view type change is announced here; confirm whether you need the payload before changing an established column. ## 2026-09-22 — Variants in development and staging [#2026-09-22--variants-in-development-and-staging] The customer-scoped `variants` view and its read authorization were verified in development and staging. It restricts variants through the owning variant group's location and the customer's `location_members`. A variant's optional `item_id` is **not** the ownership filter. This does not assert that every production customer dataset already has the view; check your dataset's view list before integrating. ## 2026-09-18 — Prepaid reporting code in the source model [#2026-09-18--prepaid-reporting-code-in-the-source-model] The purchase payment-method model gained nullable `prepaid_type_code`: the configured prepaid reporting code snapshotted at payment time. The same purchase-method record also carries a Karma `payment_method_id` and a human-readable `brand`. The code is the stable choice for an agreed customer reporting mapping; the label is not. The column was observed in the staging customer view in the September 28 inventory. Previously replicated rows may lack detail; do not treat `NULL` as a failed payment. Card-provider enrichment fields also remain nullable. ## 2026-09-15 — Zero-ticket flag in the source model [#2026-09-15--zero-ticket-flag-in-the-source-model] `kitchen_dispatch_items.zero_ticket` was added as a source-side boolean for an item recorded on the tab but already prepared elsewhere. Its **staging customer-view metadata remains stale as of September 28** (see the known mismatch above). Source schema additions and customer-view schema visibility are separate steps. ## 2026-08-13 — Rendered printer payload in the source model [#2026-08-13--rendered-printer-payload-in-the-source-model] `printer_jobs.rendered_content` was added as a binary payload. In the September 28 staging inventory, the customer view exposes it as BigQuery `BYTES`. A generic `SELECT *` export can fail in connectors without binary support; select the columns you use or agree a transformed customer view before ingesting it. ## 2026-06-26 — Fiscal refund registration fields in the source model [#2026-06-26--fiscal-refund-registration-fields-in-the-source-model] Refund records gained nullable `cr_receipt_id` and `cr_registered_at` for the **refund's** later fiscal cash-register booking. Both fields were observed in the staging customer view in the September 28 inventory. They do not replace `purchase_refunds.purchase_id`, `purchase_refunds.id`, or the original sale's `purchased_at`. ## Maintaining this log [#maintaining-this-log] When a customer-visible BigQuery field or view changes, record **the environment, date observed, view/column, old and new types/meaning, null/backfill behavior, and any required consumer action**. Do not call a source migration “available to customers” until the customer view and its metadata have been checked. Customer dataset access and production rollout are verified separately from the source schema. # BigQuery field reference (/docs/bigquery-fields) This reference inventories **414 advertised fields in 26 customer views**, observed in a staging customer dataset on **2026-10-05**. It is a dated example, **not a guarantee** that your environment exposes the same columns at the same time. Always inspect your own dataset with `INFORMATION_SCHEMA.COLUMNS` (query in the [BigQuery guide](/docs/bigquery)). Column descriptions are based on the documented reporting contract or source model where known; “operational/source-specific” explicitly means the field has **no promised customer reporting semantics**, not that its name should be interpreted as a contract. **October 8, 2026 update:** The checked development and staging customer datasets now each advertise **429 fields in 26 views**, with matching names and types. Relative to the staging inventory below, the following fields are newly advertised in both datasets: | View | Added fields (BigQuery type) | Meaning | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `items` | `allowed_tab_types` (`JSON`), `measurement` (`JSON`), `pricing_group_id` (`STRING`), `print_preparation` (`BOOL`), `variable_quantity` (`BOOL`) | Current catalogue configuration; not a historical sale-line snapshot. | | `kitchen_dispatch_items` | `allocated_amount_cents` (`INT64`), `allocated_vat_amount_cents` (`INT64`), `guest_field_answers` (`JSON`), `measured_stock_id` (`INT64`), `measurement` (`JSON`), `zero_ticket` (`BOOL`) | Order-side allocation and preparation metadata; not a substitute for confirmed purchase amounts. `zero_ticket` marks an item prepared elsewhere and is now in view metadata. | | `purchase_line_items` | `guest_field_answers` (`JSON`), `measurement` (`JSON`), `pre_discount_vat_amount_cents` (`INT64`), `revenue_account` (`STRING`) | Sale-line attributes; `pre_discount_vat_amount_cents` is **not** authoritative purchase-level VAT (use `purchase_vats`). | | `purchase_refund_line_items` | `quantity_cents` (`INT64`) | Explicit refunded product quantity in hundredths of a unit (100 = one unit), nullable for amount-only and older refunds. | `printer_jobs.rendered_content` (`BYTES`) was **removed** from both checked customer views, while remaining in the upstream operational table; the October 5 `printer_jobs` table below is a historical snapshot and should not be used as a current `SELECT *` schema. Dev-only `purchases.processed_by_retailer_user_id`, `purchase_line_items.pre_discount_value_cents`, and `kitchen_dispatch_items.is_surplus` were also removed from **dev customer views** for parity; use `purchase_line_items.is_surplus` for the sale-line flag. These changes are detailed in the [changelog](/docs/bigquery-changelog). Other customer datasets, especially production, must be verified separately. BigQuery view metadata reports nullable columns even when a source column has a database default or NOT NULL constraint. Do not derive source constraints from this inventory. Types and names are those advertised by the customer **views**, not the upstream PostgreSQL schema. All tables below are read-only views scoped to the customer; joins and money rules are in the [guide](/docs/bigquery). **Planned, not part of the 414-field inventory:** Price clusters are defined in the source model but are not verified as customer views. These source fields explain the intended customer-scoped tables and relationships; they are **not advertised BigQuery column types or availability promises**: | Planned table | Source fields | Meaning | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `price_clusters` | `id` (UUID), `location_id` (integer), `name` (text), `version` (integer), `created_at`, `updated_at` (timestamps) | Current cluster owned by `location_id`; `version` is a configuration revision, not a purchase price history. | | `price_cluster_entries` | `cluster_id` (UUID), `item_id` (integer), `price_cents` (bigint) | Configured item price in minor currency units; join `cluster_id` to `price_clusters.id`. | | `price_cluster_locations` | `cluster_id` (UUID), `location_id` (integer) | Child location assigned to a cluster; not necessarily every customer-visible location. | | `price_cluster_applications` | `cluster_id` (UUID), `location_id` (integer), `item_id` (integer), `previous_pricing_override` (JSONB) | Currently applied location/item combination; JSON stores the previous `pricings` and `pricing_group_id` keys for restoration, not the active price. | In the planned customer views, the cluster owner must be in `location_members`; assignment/application locations must additionally be in `location_members`. Check actual BigQuery types and row availability after replication and view provisioning. See the [changelog](/docs/bigquery-changelog). * [`cost_centers`](#cost-centers) * [`discounts`](#discounts) * [`item_accounts`](#item-accounts) * [`item_accounts_map`](#item-accounts-map) * [`items`](#items) * [`kitchen_dispatch_events`](#kitchen-dispatch-events) * [`kitchen_dispatch_item_modifications`](#kitchen-dispatch-item-modifications) * [`kitchen_dispatch_items`](#kitchen-dispatch-items) * [`kitchen_dispatches`](#kitchen-dispatches) * [`location_members`](#location-members) * [`locations`](#locations) * [`prepaid_types`](#prepaid-types) * [`printer_jobs`](#printer-jobs) * [`purchase_applied_fees`](#purchase-applied-fees) * [`purchase_line_items`](#purchase-line-items) * [`purchase_payment_method`](#purchase-payment-method) * [`purchase_refund_line_items`](#purchase-refund-line-items) * [`purchase_refund_vats`](#purchase-refund-vats) * [`purchase_refunds`](#purchase-refunds) * [`purchase_vats`](#purchase-vats) * [`purchases`](#purchases) * [`retailer_user_actions`](#retailer-user-actions) * [`tab_events`](#tab-events) * [`tab_members`](#tab-members) * [`tabs`](#tabs) * [`variants`](#variants) ## cost\_centers [#cost_centers] Cost-center definitions assigned to locations and used for reporting or accounting. | Field | BigQuery type | Meaning | | --------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `id` | `INT64` | Row identifier; join according to the table's documented relationships. | | `uuid` | `STRING` | UUID identifier for the cost center. | | `name` | `STRING` | Display name. | | `description` | `STRING` | Description. | | `location_id` | `INT64` | Location identifier, scoped by this dataset's location membership. | | `inherit_to_children` | `BOOL` | Whether the definition is inherited by child locations. | | `active` | `BOOL` | Whether this definition is active. | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `updated_at` | `TIMESTAMP` | Time this row was last updated; child-table updates need separate tracking. | | `code` | `STRING` | Configured reporting code. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | ## discounts [#discounts] Current location-owned discount/voucher definitions, including gift-card configurations; not a per-purchase redemption ledger. | Field | BigQuery type | Meaning | | --------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | `INT64` | Identifier for this configured discount; not an applied discount or purchase ID. | | `location_id` | `INT64` | Location that owns this discount; rows are scoped to the customer's location membership. | | `name` | `STRING` | Configured display name; not a stable reporting identifier. | | `enabled` | `BOOL` | Current enabled flag; not the historical state at purchase time. | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `updated_at` | `TIMESTAMP` | Time this row was last updated; child-table updates need separate tracking. | | `valid_from` | `TIMESTAMP` | Configured start of the discount's validity period. | | `valid_until` | `TIMESTAMP` | Nullable configured end of the discount's validity period. | | `type_specification` | `JSON` | JSON defining the configured discount type and calculation; not a paid discount amount. | | `code` | `STRING` | Configured discount code; identify uniquely with location\_id, not code alone. | | `applies_to_alcohol` | `BOOL` | Whether the discount configuration permits alcohol. | | `program_id` | `STRING` | Optional associated discount program identifier. | | `voucher_type` | `STRING` | Configured voucher category, such as discount code or promotion. GIFT\_CARD rows are gift-card templates; stored value lives on gift cards, not vouchers. | | `balance_mode` | `STRING` | Balance-tracking mode; vouchers hold no balance, so new vouchers have NONE. Not a purchase payment method. | | `max_uses` | `INT64` | Optional maximum overall usage count. | | `times_used` | `INT64` | Mutable usage counter; not an individual redemption record. | | `per_user_uses` | `INT64` | Optional maximum uses per user. | | `min_order_amount_cents` | `INT64` | Optional minimum eligible order amount in currency minor units. | | `max_discount_amount_cents` | `INT64` | Optional maximum discount amount in currency minor units. | | `initial_balance_cents` | `INT64` | Initial balance on older vouchers, in currency minor units; vouchers hold no balance and new ones leave it empty. | | `remaining_balance_cents` | `INT64` | Balance left on older vouchers, in currency minor units; no longer deducted on redemption. | | `can_combine` | `BOOL` | Whether the configuration permits combination with other discounts. | | `auto_apply` | `BOOL` | Whether this discount is configured for automatic application. | | `company_id` | `INT64` | Optional associated company identifier. | | `display_title` | `STRING` | Current customer-facing voucher title, where configured. | | `display_description` | `STRING` | Current customer-facing voucher description, where configured. | | `display_image_url` | `STRING` | Current customer-facing voucher image URL, where configured. | | `always_visible` | `BOOL` | Whether the promotion is configured to remain visible. | | `is_template` | `BOOL` | Whether this voucher is a blueprint rather than a redeemable instance. | | `template_voucher_id` | `INT64` | Identifier of the template voucher from which this one was created, if any. | | `purchase_line_item_id` | `INT64` | Optional purchase-line reference for a voucher created through a purchase. | | `deleted_at` | `TIMESTAMP` | Soft-deletion time, where set; do not treat the current row as a historical sale snapshot. | | `design_id` | `STRING` | Optional voucher/gift-card design identifier. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | ## item\_accounts [#item_accounts] Accounting account definitions used for catalogue mapping. | Field | BigQuery type | Meaning | | --------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `id` | `STRING` | Row identifier; join according to the table's documented relationships. | | `account` | `STRING` | Configured accounting account code. | | `description` | `STRING` | Accounting account description. | | `location_id` | `INT64` | Location identifier, scoped by this dataset's location membership. | | `company_id` | `INT64` | Company associated with this account, if any. | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `updated_at` | `TIMESTAMP` | Time this row was last updated; child-table updates need separate tracking. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | | `inherit_to_children` | `BOOL` | Whether child locations inherit this account. | ## item\_accounts\_map [#item_accounts_map] Links catalogue items to accounting accounts. | Field | BigQuery type | Meaning | | --------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `id` | `STRING` | Row identifier; join according to the table's documented relationships. | | `item_id` | `INT64` | Catalogue item (items.id). | | `account_id` | `STRING` | Accounting account (item\_accounts.id). | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `updated_at` | `TIMESTAMP` | Time this row was last updated; child-table updates need separate tracking. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | ## items [#items] Current catalogue items; not historical purchase-line snapshots. | Field | BigQuery type | Meaning | | ------------------------ | --------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | `id` | `INT64` | Row identifier; join according to the table's documented relationships. | | `company_id` | `INT64` | Internal identifier/reference; confirm the target table before joining. | | `location_id` | `INT64` | Location identifier, scoped by this dataset's location membership. | | `title` | `STRING` | Current catalogue item title; a past sale keeps its own line title. | | `description` | `STRING` | Current catalogue description. | | `old_image_url` | `STRING` | Legacy image URL. | | `ingredients` | `STRING` | Configured ingredient text for the current catalogue item. | | `weight` | `INT64` | Configured weight value; unit is not standardized in this reporting contract. | | `discount_percentage` | `INT64` | Catalogue-level discount configuration; not a sale-line discount snapshot. | | `base_price` | `INT64` | Catalogue base price; not an authoritative historical sale amount. | | `deleted` | `BOOL` | Catalogue deletion flag; not evidence that an old purchase was canceled. | | `created_date` | `TIMESTAMP` | Time this catalogue record was created. | | `integration_id` | `INT64` | Associated integration identifier. | | `integration_item_id` | `STRING` | Item's identifier in an external integration. | | `producer` | `STRING` | Configured product producer text. | | `weight_string` | `STRING` | Display text for the configured weight. | | `semafor_image_url` | `STRING` | Legacy/integration image URL. | | `vat_id` | `INT64` | Standard VAT configuration reference. | | `images` | `JSON` | Catalogue image metadata as JSON. | | `matabas_product_id` | `STRING` | Internal identifier/reference; confirm the target table before joining. | | `full_price` | `BOOL` | Catalogue pricing flag; not a paid sale-line amount. | | `image_url` | `STRING` | Current image URL. | | `karma_cut_percentage` | `INT64` | Configured platform-share percentage; not a purchase fee row. | | `type` | `STRING` | Catalogue item category/type. | | `made_to_order` | `BOOL` | Whether the catalogue item is prepared on demand. | | `context` | `JSON` | Context JSON; contents depend on the producing workflow and can change. | | `title_prep` | `STRING` | Preparation-facing item title. | | `prep_time_minutes` | `INT64` | Configured preparation duration in minutes. | | `coursing_order` | `INT64` | Configured course ordering for the item. | | `purchase_cost_cents` | `INT64` | Configured purchase cost in currency minor units; not a sale amount. | | `is_alcohol` | `BOOL` | Boolean flag for the named property; not a purchase-finality test. | | `is_available` | `BOOL` | Current availability, not historical sale availability. | | `item_type` | `STRING` | Extensible catalogue classification, not a closed enum; distinct from the recorded sales-line item\_type. | | `parent_item_id` | `INT64` | Parent catalogue item, when inherited/related. | | `is_inherited` | `BOOL` | Whether this record inherits catalogue data. | | `source_location_id` | `INT64` | Source location for an inherited catalogue item. | | `overridden_fields` | `JSON` | JSON describing fields overridden locally. | | `inherit_to_children` | `BOOL` | Whether this catalogue record can be inherited by child locations. | | `inheritance_updated_at` | `TIMESTAMP` | Last inheritance-related update. | | `allowed_overrides` | `JSON` | JSON describing fields permitted to be overridden. | | `variable_pricing` | `BOOL` | Whether variable pricing is configured. | | `color` | `STRING` | Catalogue display color. | | `takeaway_vat_id` | `INT64` | Takeaway VAT configuration reference. | | `min_price_cents` | `INT64` | Configured minimum price in currency minor units. | | `max_price_cents` | `INT64` | Configured maximum price in currency minor units. | | `preparation` | `STRING` | Configured preparation instructions/text. | | `kitchen_sort_order` | `INT64` | Configured kitchen display order. | | `metadata` | `JSON` | Partner-specific JSON identifiers; case-sensitive and not a historical sale snapshot. | | `ean` | `STRING` | Configured EAN barcode, if present. | | `updated_at` | `TIMESTAMP` | Time this row was last updated; child-table updates need separate tracking. | | `takeaway_account_id` | `STRING` | Accounting account used for takeaway, when configured. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | ## kitchen\_dispatch\_events [#kitchen_dispatch_events] Events recorded against kitchen dispatches. | Field | BigQuery type | Meaning | | --------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `uuid` | `STRING` | UUID-shaped row identifier; join according to the table's documented relationships. | | `kitchen_dispatch_id` | `STRING` | Reference to kitchen\_dispatches.uuid. | | `event` | `STRING` | Kitchen dispatch event name. | | `comment` | `STRING` | Optional event comment. | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `updated_at` | `TIMESTAMP` | Time this row was last updated; child-table updates need separate tracking. | | `meta_data` | `JSON` | Extensible JSON metadata; keys are case-sensitive and not a universal integration contract. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | ## kitchen\_dispatch\_item\_modifications [#kitchen_dispatch_item_modifications] Variant/modification links recorded against kitchen lines. | Field | BigQuery type | Meaning | | -------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `id` | `INT64` | Row identifier; join according to the table's documented relationships. | | `kitchen_dispatch_item_id` | `STRING` | Kitchen item (kitchen\_dispatch\_items.uuid). | | `variant_id` | `INT64` | Catalogue variant (variants.id). | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `status` | `STRING` | Modification lifecycle status. | | `quantity_cents` | `INT64` | Quantity in hundredths of one unit; 150 means 1.5 units. | | `price_cents` | `INT64` | Modification price in currency minor units. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | | `updated_at` | `TIMESTAMP` | Time this row was last updated; child-table updates need separate tracking. | ## kitchen\_dispatch\_items [#kitchen_dispatch_items] Kitchen preparation/order lines; not equivalent to completed sales. | Field | BigQuery type | Meaning | | --------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `uuid` | `STRING` | UUID-shaped row identifier; join according to the table's documented relationships. | | `kitchen_dispatch_id` | `STRING` | Parent kitchen dispatch (kitchen\_dispatches.uuid). | | `sale_item_id` | `INT64` | Source sale-item identifier where populated; not purchase\_line\_items.id. | | `tab_member_id` | `STRING` | Tab member associated with this line. | | `price` | `INT64` | Kitchen/order-side price; use purchase lines for paid sale amounts. | | `discount` | `INT64` | Order-side per-unit adjustment; negative when a price was raised, not a reliable discount amount. Discounts given before payment are already in price. | | `comments` | `JSON` | Order-line notes/JSON; may contain customer-provided text. | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `updated_at` | `TIMESTAMP` | Time this row was last updated; child-table updates need separate tracking. | | `tab_settlement_id` | `STRING` | Internal identifier/reference; confirm the target table before joining. | | `base_price` | `INT64` | Order-side base price; not the historical paid sale-line total. | | `split_of_kitchen_dispatch_item` | `STRING` | Original kitchen line when this line was split. | | `status` | `STRING` | Kitchen-item workflow status; not purchase finality. | | `paid_amount` | `INT64` | Kitchen-side payment allocation; not the purchase total. | | `coursing_order` | `INT64` | Course ordering of this kitchen line. | | `item_id` | `INT64` | Reference to a catalogue item (items.id) where populated. | | `quantity_cents` | `INT64` | Quantity in hundredths of one unit; 150 means 1.5 units. | | `parent_kitchen_dispatch_item_id` | `STRING` | Parent kitchen line, where this line is a child/add-on. | | `upsell_source` | `STRING` | Source of an upsell association, if recorded. | | `upsell_session_id` | `STRING` | Internal identifier/reference; confirm the target table before joining. | | `upsell_recommendation_id` | `STRING` | Internal identifier/reference; confirm the target table before joining. | | `quantity` | `INT64` | Quantity in whole-unit compatibility form; use quantity\_cents for fractional lines. | | `variants` | `JSON` | Selected variant detail as JSON; do not treat as independent purchase lines. | | `discount_details` | `JSON` | Order-side discount detail as JSON; integration-specific, not a reporting contract. A pending source migration drops this column; do not depend on it. | | `item_state` | `STRING` | Item-level kitchen state; not a purchase state. | | `is_takeaway` | `BOOL` | Line-level takeaway choice; more precise than a tab\_type shortcut. | | `pre_order` | `BOOL` | Whether this item belongs to a pre-order flow. | | `context` | `JSON` | Context JSON; contents depend on the producing workflow and can change. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | | `item_type` | `STRING` | Kitchen item classification; not purchase\_line\_items.type. | | `surplus_entry_id` | `INT64` | Surplus entry associated with this line, if any. | | `fired_at` | `TIMESTAMP` | Time the line was fired to preparation, if available. | | `vat_rate_basis_points` | `INT64` | VAT rate in basis points (1200 = 12%). | | `external_line_id` | `STRING` | Internal identifier/reference; confirm the target table before joining. | **Historical metadata exception (staging, 2026-09-28):** `zero_ticket` was queryable as `BOOL`, but an older customer view's stored schema did not advertise it. It is now advertised as `BOOL` in the checked development and staging customer views as of October 8, 2026. Check other datasets independently. ## kitchen\_dispatches [#kitchen_dispatches] Kitchen orders/dispatches, which can be separate from payments. | Field | BigQuery type | Meaning | | -------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `uuid` | `STRING` | UUID-shaped row identifier; join according to the table's documented relationships. | | `tab_id` | `STRING` | Tab (tabs.uuid). | | `daily_ordinal_number` | `INT64` | Daily display/order sequence; not a stable purchase identifier. | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `updated_at` | `TIMESTAMP` | Time this row was last updated; child-table updates need separate tracking. | | `desired_pickup_time` | `TIMESTAMP` | Requested pickup time. | | `store_commitment_timeout` | `TIMESTAMP` | Store commitment deadline. | | `time_slot_id` | `INT64` | Selected time-slot identifier. | | `time_slot_starts_at` | `TIMESTAMP` | Selected time-slot start. | | `time_slot_ends_at` | `TIMESTAMP` | Selected time-slot end. | | `latest_state` | `STRING` | Kitchen dispatch lifecycle state; not a completed payment. | | `location_id` | `INT64` | Location identifier, scoped by this dataset's location membership. | | `tab_member_id` | `STRING` | Tab member associated with this kitchen dispatch. | | `source` | `STRING` | Order source/channel. | | `message` | `STRING` | Kitchen/order message; may contain customer-provided text. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | | `external_order_id` | `STRING` | External platform's order identifier. | | `external_order_number` | `STRING` | External display/order number. | | `metadata` | `JSON` | Extensible JSON metadata; keys are case-sensitive and not a universal integration contract. | ## location\_members [#location_members] Location IDs included in this customer dataset's access scope. | Field | BigQuery type | Meaning | | ----- | ------------- | -------------------------------------------------------------------- | | `id` | `INT64` | Location ID included in this customer's hierarchy and dataset scope. | ## locations [#locations] Limited, customer-scoped location details. | Field | BigQuery type | Meaning | | --------------- | ------------- | ------------------------------------------------------------------------------------------------ | | `location_id` | `INT64` | Location identifier (same namespace as purchases.location\_id). | | `name` | `STRING` | Location display name. | | `external_name` | `STRING` | Location's external name. | | `country` | `STRING` | Country code/label as exposed by the view; not a timezone. | | `metadata` | `JSON` | Limited customer-visible location JSON; not a source of guaranteed timezone or all private keys. | ## prepaid\_types [#prepaid_types] Configured prepaid payment types, labels, and reporting codes. | Field | BigQuery type | Meaning | | --------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `id` | `STRING` | Row identifier; join according to the table's documented relationships. | | `location_id` | `INT64` | Location identifier, scoped by this dataset's location membership. | | `code` | `STRING` | Configured reporting code; use this rather than a display label for mappings. | | `label` | `STRING` | Human-readable prepaid method label. | | `sort_order` | `INT64` | Display ordering. | | `cost_center_id` | `INT64` | Linked cost center, if configured. | | `requires_comment` | `BOOL` | Whether a comment is required when using this type. | | `archived_at` | `TIMESTAMP` | Time the prepaid type was archived, if archived. | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `updated_at` | `TIMESTAMP` | Time this row was last updated; child-table updates need separate tracking. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | ## printer\_jobs [#printer_jobs] Operational print jobs; rendered content can be binary. | Field | BigQuery type | Meaning | | --------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `id` | `STRING` | Row identifier; join according to the table's documented relationships. | | `printer_group_id` | `STRING` | Printer group intended for the job. | | `claimed_by_printer` | `STRING` | Printer that claimed the job, if any. | | `kitchen_dispatch_id` | `STRING` | Related kitchen dispatch, if this is a kitchen print. | | `tab_id` | `STRING` | Related tab, if this is a tab/receipt print. | | `retries` | `INT64` | Number of print attempts/retries. | | `content` | `JSON` | Original print content; inspect its type before using it in a connector. | | `meta_data` | `JSON` | Extensible JSON metadata; keys are case-sensitive and not a universal integration contract. | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `updated_at` | `TIMESTAMP` | Time this row was last updated; child-table updates need separate tracking. | | `latest_state` | `STRING` | Most recent print-job state; not a sale state. | | `idempotency_key` | `STRING` | Deduplication key for creating the print job. | | `correlation_id` | `STRING` | Correlation identifier for tracing the print request. | | `origin_service` | `STRING` | Service that produced the print job, where recorded. | | `target_printer_id` | `STRING` | Explicit printer target, when provided. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | | `rendered_media_type` | `STRING` | MIME/media type of rendered output. | | `rendered_at` | `TIMESTAMP` | Time rendered output was created. | | `rendered_content` | `BYTES` | Raw rendered print payload (BYTES); cast to Base64 only for a consumer that needs it. | ## purchase\_applied\_fees [#purchase_applied_fees] Commercial/payment fees associated with a purchase, not sold articles. | Field | BigQuery type | Meaning | | ------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `id` | `INT64` | Row identifier; join according to the table's documented relationships. | | `purchase_id` | `INT64` | Reference to purchases.id; for refunds this is the ORIGINAL sale. | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `charged_amount_cents` | `INT64` | Commercial fee charged, in currency minor units; not a product sale. | | `vat_amount_cents` | `INT64` | VAT included in the applied fee, in currency minor units. | | `vat_rate_basis_points` | `INT64` | VAT rate in basis points (1200 = 12%). | | `fee_type` | `STRING` | Configured fee category. | | `contract_fee_id` | `INT64` | Contract fee definition, when applicable. | | `price_plan_stage_fee_id` | `INT64` | Price-plan fee definition, when applicable. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | | `fee_rule_id` | `STRING` | Matched fee rule, when applicable. | | `charge_mode` | `STRING` | How/when this fee is charged. | | `usage_accumulated_at` | `TIMESTAMP` | Time fee usage was accumulated, if applicable. | | `updated_at` | `TIMESTAMP` | Time this row was last updated; child-table updates need separate tracking. | ## purchase\_line\_items [#purchase_line_items] Sale/payment lines belonging to purchases. | Field | BigQuery type | Meaning | | ------------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | `INT64` | Stable line identifier; unique within this environment. | | `type` | `STRING` | product (goods), tip (gratuity), or amount (unallocated tab payment). | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `purchase_id` | `INT64` | Reference to purchases.id; for refunds this is the ORIGINAL sale. | | `title` | `STRING` | Title saved with this sale; prefer over current catalogue title for receipts. | | `final_amount_cents` | `INT64` | VAT-inclusive, post-discount PER-UNIT amount in currency minor units. | | `vat_rate_basis_points` | `INT64` | VAT rate in basis points (1200 = 12%). | | `vat_amount_cents` | `INT64` | VAT component per unit, in currency minor units. | | `article_no` | `STRING` | Internal/fiscal reference; not guaranteed to match a customer's SKU. | | `quantity` | `INT64` | Quantity in whole-unit compatibility form; use quantity\_cents for fractional lines. | | `parent_line_item_id` | `INT64` | Parent sale-line ID when recorded; split amount allocations can be flat. Kitchen parent links may recover the relationship within the same purchase. | | `applied_total_discount_amount_cents` | `INT64` | VAT-inclusive discount on the whole line. On ledger tabs every discount on the line for this payment, given before or at payment; on other tabs the payment-time discount, such as a voucher. Paid line = total\_amount\_cents minus this discount. | | `origin_item_id` | `INT64` | Recorded catalogue item ID when present; a kitchen\_dispatch\_item\_id link can recover item identity on some third-party lines with a NULL origin. | | `applied_modifications` | `JSON` | Inline pricing/modification JSON; not automatically extra sale rows. | | `quantity_cents` | `INT64` | Quantity in hundredths of one unit; 150 means 1.5 units. | | `total_amount_cents` | `INT64` | Whole-line total before the line's discount (applied\_total\_discount\_amount\_cents). On ledger tabs the price before every discount; on other tabs a discount given on the item before payment is already in it. Do not multiply by quantity. | | `total_vat_amount_cents` | `INT64` | Whole-line VAT in currency minor units; historical discounted rows can be pre-discount. Use purchase\_vats for purchase-level reporting. | | `kitchen_dispatch_item_id` | `STRING` | Related kitchen\_dispatch\_items.uuid, where linked. | | `total_with_modifications_cents` | `INT64` | Pre-discount total including modifications; nullable receipt-oriented field. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | | `item_type` | `STRING` | Extensible sales-line text, not a closed enum. Keep regular/surplus; exclude service\_fee, marketplace\_fee and marketplace\_discount; hold unknown values for review. | | `is_surplus` | `BOOL` | Marks a surplus product; surplus is still a real sale. | | `updated_at` | `TIMESTAMP` | Time this row was last updated; child-table updates need separate tracking. | ## purchase\_payment\_method [#purchase_payment_method] Purchase-level payment method details and later provider enrichment. | Field | BigQuery type | Meaning | | --------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | `INT64` | Row identifier; join according to the table's documented relationships. | | `purchase_id` | `INT64` | Purchase this payment-method detail describes; keep purchase as the reporting grain. | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `updated_at` | `TIMESTAMP` | Time this row was last updated; child-table updates need separate tracking. | | `brand` | `STRING` | Provider method/brand or prepaid display label; not a universal tender-code enum. | | `last4` | `STRING` | Last four card digits when supplied; sensitive payment detail. | | `shopper_ref` | `STRING` | Provider shopper reference, if supplied; handle as sensitive data. | | `alias` | `STRING` | Provider payment alias, if supplied. | | `funding_source` | `STRING` | Provider enrichment; not a complete standalone tender classification. | | `issuer_country` | `STRING` | Issuer country when returned by the payment provider. | | `card_bin` | `STRING` | Card BIN when supplied; handle as sensitive payment detail. | | `card_fingerprint` | `STRING` | Provider card fingerprint, if supplied; handle as sensitive data. | | `psp_fingerprint` | `STRING` | Provider fingerprint, if supplied; handle as sensitive data. | | `payment_account_reference` | `STRING` | Provider account reference, if supplied; handle as sensitive data. | | `matched_user_id` | `INT64` | Karma user associated by payment-method matching, where available. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | | `payment_method_variant` | `STRING` | Provider-specific variant, when supplied. | | `payment_method_id` | `STRING` | Karma payment-method UUID; prepaid configuration joins to prepaid\_types.id. Not the partner reporting code. | | `prepaid_type_code` | `STRING` | String reporting code snapshotted at payment time; location-scoped, nullable historically, and not guaranteed to match later-edited configuration. | ## purchase\_refund\_line\_items [#purchase_refund_line_items] Refund allocations to original purchase lines; monetary amounts do not establish returned quantities. | Field | BigQuery type | Meaning | | ------------------------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | `INT64` | Row identifier; join according to the table's documented relationships. | | `purchase_refund_id` | `INT64` | Reference to purchase\_refunds.id. | | `purchase_line_item_id` | `INT64` | Reference to the original purchase\_line\_items.id. | | `total_amount_cents` | `INT64` | Positive monetary amount allocated to the original line, not units returned. | | `seller_amount_cents` | `INT64` | Seller's portion of the refund allocation in currency minor units. | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | | `quantity_cents` (not in this October 5 snapshot) | `INT64` | Available in checked development and staging views since October 8; explicit refunded product quantity in hundredths of a unit (100 = one unit), NULL for amount-only or historical allocations. Check your view before querying. | **Historical rollout note:** `quantity_cents` became advertised as `INT64` in a checked development customer view on October 5, 2026. As of October 8 it is also advertised in the checked staging customer view; no production customer-view availability is claimed. See the [changelog](/docs/bigquery-changelog). ## purchase\_refund\_vats [#purchase_refund_vats] Refunded VAT amounts grouped by VAT rate. | Field | BigQuery type | Meaning | | --------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `purchase_refund_id` | `INT64` | Reference to purchase\_refunds.id. | | `vat_rate_basis_points` | `INT64` | VAT rate in basis points (1200 = 12%). | | `refunded_vat_amount_cents` | `INT64` | Positive VAT magnitude refunded at this rate. | | `id` | `INT64` | Row identifier; join according to the table's documented relationships. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | ## purchase\_refunds [#purchase_refunds] Refund attempts/results referring to original purchases. | Field | BigQuery type | Meaning | | --------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `id` | `INT64` | Identifier for this refund attempt/result; use separately from the original purchase ID. | | `purchase_id` | `INT64` | Original purchase (purchases.id); inspect its state: technical zero-refunds can reference failed purchases. | | `psp_request_ref` | `STRING` | Provider reference for the refund request, if available. | | `psp_response_ref` | `STRING` | Provider reference returned for the refund, if available. | | `psp_refunded_amount_cents` | `INT64` | Amount reported refunded by the provider, when known; for confirmed refunds use this before falling back to amount\_cents. | | `psp_karma_provided_ref` | `STRING` | Karma-supplied provider correlation reference, if available. | | `state` | `STRING` | pending, canceled, confirmed, or failed; only confirmed is a finalized refund. | | `reason` | `STRING` | Free-text refund reason. | | `initiated_by` | `STRING` | Actor that initiated the refund, where recorded. | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `updated_at` | `TIMESTAMP` | Time this row was last updated; child-table updates need separate tracking. | | `refunded_fee_amount_cents` | `INT64` | Amount of fees refunded, if known. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | | `amount_cents` | `INT64` | Nullable requested/recorded refund magnitude in currency minor units; may be NULL even on a confirmed refund. | | `idempotency_key` | `STRING` | Deduplication key used for this refund request. | | `cr_receipt_id` | `STRING` | Nullable fiscal refund/credit receipt reference; not the original sale receipt. | | `cr_registered_at` | `TIMESTAMP` | Nullable time the confirmed refund was later registered with the cash register. | ## purchase\_vats [#purchase_vats] VAT totals grouped by rate for each purchase. | Field | BigQuery type | Meaning | | ----------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `purchase_id` | `INT64` | Reference to purchases.id; for refunds this is the ORIGINAL sale. | | `vat_rate_basis_points` | `INT64` | VAT rate in basis points (1200 = 12%). | | `vat_amount_cents` | `INT64` | Purchase VAT total for this rate, in currency minor units. | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `id` | `INT64` | Row identifier; join according to the table's documented relationships. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | ## purchases [#purchases] One purchase/payment record; the primary sales reporting grain. | Field | BigQuery type | Meaning | | ------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | `INT64` | Stable per-purchase key in this environment; use for a per-purchase export. | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `updated_at` | `TIMESTAMP` | Time this row was last updated; child-table updates need separate tracking. | | `intent_id` | `STRING` | Payment-intent UUID used to correlate the payment attempt. | | `psp` | `STRING` | Payment provider/type; combine with purchase\_payment\_method for tender details. | | `psp_request_ref` | `STRING` | Provider reference on payment request, when present. | | `psp_response_ref` | `STRING` | Provider reference returned after payment, when present. | | `state` | `STRING` | pending, canceled, confirmed, or failed; only confirmed is a finalized purchase. | | `location_id` | `INT64` | Location identifier, scoped by this dataset's location membership. | | `paying_tab_member_id` | `STRING` | Nullable tab-member reference; LEFT JOIN to tab\_members.uuid, then tab\_members.tab\_id to tabs.uuid. A confirmed purchase may have no tab member. | | `paying_user_id` | `INT64` | Paying Karma user, when known. | | `currency_code` | `STRING` | ISO 4217 currency code for the purchase amount. | | `legacy_payment_id` | `INT64` | Link to a legacy payment, only when applicable. | | `cr_receipt_id` | `STRING` | Nullable cash-register receipt reference; not a universal sale key. | | `search_tags` | `STRING` | Optional searchable text with related references; not an export key. | | `purchased_at` | `TIMESTAMP` | UTC payment-confirmation instant; BigQuery exposes it as TIMESTAMP. | | `psp_karma_provided_ref` | `STRING` | Karma-supplied provider correlation reference, when present. | | `terminal_id` | `STRING` | Terminal identifier, when payment involved a terminal. | | `initiated_by_service` | `STRING` | Service that initiated the purchase, where recorded. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | | `metadata` | `JSON` | Extensible JSON metadata; keys are case-sensitive and not a universal integration contract. | | `amount_cents` | `INT64` | Nullable purchase/payment amount in currency minor units, including NULL on ordinary POS sales; not a universal paid-total column. | | `version` | `INT64` | Internal version of the purchase row; not an API/schema version. | | `idempotency_key` | `STRING` | Purchase request deduplication key, where populated. | | `disputed` | `BOOL` | Whether the purchase has a dispute flag. | | `psp_fee_forward_rule_id` | `STRING` | Fee forwarding rule, if matched. | | `psp_fee_forwarded` | `BOOL` | Whether provider-fee forwarding was recorded. | ## retailer\_user\_actions [#retailer_user_actions] Operational audit actions by retailer users; not sales or payments. | Field | BigQuery type | Meaning | | --------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `id` | `INT64` | Row identifier; join according to the table's documented relationships. | | `action` | `STRING` | Recorded retailer user action. | | `retailer_user_id` | `INT64` | Actor retailer user identifier. | | `location_id` | `INT64` | Location identifier, scoped by this dataset's location membership. | | `metadata` | `JSON` | Extensible JSON metadata; keys are case-sensitive and not a universal integration contract. | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `action_type` | `STRING` | Action classification. | | `entity_type` | `STRING` | Type of entity affected. | | `entity_id` | `INT64` | Identifier of entity affected. | | `changes` | `JSON` | JSON describing the change. | | `ip_address` | `STRING` | Actor IP address; sensitive audit information. | | `user_agent` | `STRING` | Actor user-agent string; sensitive audit information. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | ## tab\_events [#tab_events] State/events recorded against a tab. | Field | BigQuery type | Meaning | | --------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `uuid` | `STRING` | UUID-shaped row identifier; join according to the table's documented relationships. | | `tab_id` | `STRING` | Reference to tabs.uuid. | | `event` | `STRING` | Tab event name. | | `comment` | `STRING` | Optional event comment. | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `updated_at` | `TIMESTAMP` | Time this row was last updated; child-table updates need separate tracking. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | ## tab\_members [#tab_members] Participation/payment identity within a tab. | Field | BigQuery type | Meaning | | ---------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `uuid` | `STRING` | Tab member UUID; purchases.paying\_tab\_member\_id points here. | | `tab_id` | `STRING` | Parent tab UUID (tabs.uuid). | | `storefront_session_id` | `STRING` | Storefront session reference, when applicable. | | `initiator` | `BOOL` | Whether this member initiated the tab. | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `updated_at` | `TIMESTAMP` | Time this row was last updated; child-table updates need separate tracking. | | `member_type` | `STRING` | Type of tab participant. | | `user_id` | `INT64` | Customer/user ID, when known. | | `retail_user_id` | `INT64` | Retail user ID, when known. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | | `auto_pay_payment_method_id` | `STRING` | Auto-pay method reference, if configured. | ## tabs [#tabs] Ordering/payment container; a tab can have multiple purchases. | Field | BigQuery type | Meaning | | ------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `uuid` | `STRING` | Tab UUID; not a per-purchase/receipt ID. | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `updated_at` | `TIMESTAMP` | Time this row was last updated; child-table updates need separate tracking. | | `location_id` | `INT64` | Location identifier, scoped by this dataset's location membership. | | `storefront_qr_code_id` | `STRING` | Internal identifier/reference; confirm the target table before joining. | | `selected_payment_flow` | `STRING` | Payment-flow selection for the tab. | | `latest_state` | `STRING` | Tab lifecycle state; not a finalized sale test. | | `order_amount` | `INT64` | Legacy/order-side amount; confirm semantics before financial reporting. | | `paid_amount` | `INT64` | Legacy/order-side paid amount; use monetary purchase records for sales. | | `tip_amount` | `INT64` | Tab-side gratuity amount; reconcile separately from goods. | | `tab_type` | `STRING` | Table, takeaway, or over-the-counter context; not a definitive consumption flag. | | `coursing_order` | `INT64` | Course ordering recorded on the tab. | | `payment_mode` | `STRING` | Tab payment mode. | | `total_amount_cents` | `INT64` | Tab total in currency minor units; not one purchase amount. | | `paid_amount_cents` | `INT64` | Amount paid on the tab (non-tip lines of confirmed purchases minus confirmed refunds), in currency minor units. Fee lines count in paid but not in total\_amount\_cents, so paid can exceed the total, as on marketplace orders with a booked delivery fee. | | `payment_split` | `INT64` | Tab split-payment configuration/value. | | `booking_id` | `STRING` | Related booking, if any. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | | `remaining_balance_cents` | `INT64` | Outstanding tab balance in currency minor units: total\_amount\_cents minus paid\_amount\_cents, never below zero. | | `cost_center_id` | `INT64` | Selected cost center, if any. | | `external_ref` | `STRING` | External system reference, if any. | | `external_source` | `STRING` | Source of the external reference. | | `external_fetched_at` | `TIMESTAMP` | Time external tab data was fetched. | | `metadata` | `JSON` | Extensible JSON metadata; keys are case-sensitive and not a universal integration contract. | | `service_fee_charged_intent_id` | `STRING` | Internal identifier/reference; confirm the target table before joining. | | `service_fee_charged_at` | `TIMESTAMP` | Timestamp of the named event; not necessarily sale time. | | `pos_owns_total` | `BOOL` | Whether the POS owns the tab total. | ## variants [#variants] Current variant/add-on catalogue definitions; not historical sale snapshots. | Field | BigQuery type | Meaning | | ----------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `id` | `INT64` | Row identifier; join according to the table's documented relationships. | | `variant_group_id` | `INT64` | Variant group identifier. | | `amount` | `INT64` | Configured variant amount; not an authoritative sale-line amount. | | `type` | `STRING` | Variant category/type. | | `default_selection` | `BOOL` | Whether preselected in the catalogue. | | `name` | `STRING` | Current variant display name; not a historical sale-line title. | | `description` | `STRING` | Current variant description; not a historical sale-line snapshot. | | `meta_data` | `JSON` | Extensible JSON metadata; keys are case-sensitive and not a universal integration contract. | | `created_at` | `TIMESTAMP` | Time this row was created; not necessarily when payment completed. | | `updated_at` | `TIMESTAMP` | Time this row was last updated; child-table updates need separate tracking. | | `rank` | `INT64` | Display ordering. | | `item_id` | `INT64` | Associated catalogue item (items.id), if a standalone item backs the variant. | | `quantity` | `INT64` | Quantity in whole-unit compatibility form; use quantity\_cents for fractional lines. | | `allow_quantity_change` | `BOOL` | Whether the variant quantity may be adjusted. | | `min_quantity` | `INT64` | Configured minimum quantity in whole-unit compatibility form. | | `max_quantity` | `INT64` | Configured maximum quantity in whole-unit compatibility form. | | `quantity_cents` | `INT64` | Configured variant quantity in hundredths of a unit. | | `min_quantity_cents` | `INT64` | Minimum configured quantity in hundredths. | | `max_quantity_cents` | `INT64` | Maximum configured quantity in hundredths. | | `datastream_metadata` | `STRUCT` | Replication metadata (including source timestamp); not a business-event time or promised export cursor. | ## Planned: tab ledger (not in this view inventory) [#planned-tab-ledger-not-in-this-view-inventory] The tab ledger records what a tab owes, every discount by name and source, and how each payment and refund is spread over the tab's lines. It is defined in the source model but not verified as customer views. The fields below are **PostgreSQL source fields**, not advertised BigQuery types or availability promises. Every ledger identifier is a time-ordered UUID (version 7). Every ledger table is append-only: a correction is a new row with `reverses_id` pointing at the row it cancels, never an edit. A payment's discharge is recorded once, from the purchase's lines when it turns confirmed; it is reversed only if the purchase leaves `confirmed` or its payer moves to another tab. | Planned table | Source fields | Meaning | | ------------------------ | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ledger_accounts` | `id` (UUID), `currency` (text), `created_at` | One account per owner; a ledger tab points at it with `tabs.ledger_account_id`. | | `ledger_charges` | `id`, `account_id`, `reverses_id` (UUID), `amount_cents` (integer), `created_at` | Gross amount owed per charge. | | `item_charges` | `charge_id`, `kitchen_dispatch_item_id` (UUID), `quantity_cents`, `unit_price_cents` (integer) | Charge for one order line. | | `fee_charges` | `charge_id` (UUID), `fee_type` (text) | Charge for a fee or other non-item line; not part of the tab total. | | `charge_tax_rates` | `charge_id` (UUID), `tax_rate_bps` (integer) | VAT rate per charge; no row when the rate is unknown. | | `ledger_reductions` | `id`, `charge_id`, `reverses_id` (UUID), `amount_cents` (integer), `created_at` | Discount amount taken off a charge. | | `account_discounts` | `id`, `account_id` (UUID), `label`, `kind` (text), `value` (numeric), `created_at` | Applied discount: name, kind and value. Its source is the source table that points at it. Not the `discounts` definitions. | | `discount_reductions` | `reduction_id`, `discount_id` (UUID), `quantity_cents` (integer) | Which applied discount each reduction belongs to, over which quantity; over whole units the same amount on every unit. | | `tab_purchase_discounts` | `discount_id`, `purchase_intent_id` (UUID), `scope` (text) | The payment attempt each applied discount came with. `scope` `payment`: bound to that payment (join `purchase_intent_id` to `purchases.intent_id`); `order`: given on the items before payment. A discount without a row is an item discount, such as one given by staff. | | `voucher_discounts` | `discount_id` (UUID), `voucher_reservation_id` (integer) | Discount from one redeemed Karma voucher; one discount per voucher. | | `manual_discounts` | `discount_id`, `kitchen_dispatch_item_id` (UUID), `retailer_user_id` (integer), `reason` (text) | Discount given by staff. | | `ledger_discharges` | `id`, `account_id`, `reverses_id` (UUID), `amount_cents` (integer), `created_at` | Amount paid (positive) or refunded (negative), tips excluded. | | `ledger_allocations` | `id`, `discharge_id`, `charge_id`, `reverses_id` (UUID), `amount_cents` (integer), `created_at` | Part of a discharge applied to one charge. | Existing views gain `tabs.ledger_account_id`, `purchases.discharge_id`, and `purchase_refunds.discharge_id` (UUID); all three are NULL outside the ledger. Only tabs created after the ledger is enabled get an account. When the ledger cannot record a change, the change still goes through, the tab leaves the ledger (`ledger_account_id` becomes NULL) and its account stops growing. A discount given before payment lowers the kitchen line's `price` (the ledger writes it). Every discount, given before or at payment, is in `applied_total_discount_amount_cents` of the purchase lines that pay for its items, whose `total_amount_cents` is the price before every discount. Every discount is its own applied discount with its own name, and a purchase's lines are derived from what the ledger planned for that payment. Integration-specific discount detail and the ledger's internal pricing and payment-coverage tables are not planned as customer views. Ledger rows carry no `location_id`: planned views scope them through the owning tab's `location_id` in `location_members`. Existing purchase, kitchen and tab columns keep their meaning; purchase lines say price, discount and net, as a voucher's line does. Check the [changelog](/docs/bigquery-changelog) for rollout status; these tables are not included in the counts above. # BigQuery data sharing (/docs/bigquery) Use this guide to build a **repeatable export from your own customer dataset**. It is a read-only reporting surface, not the transaction API. Start with the [field reference](/docs/bigquery-fields) for the full view inventory and [BigQuery changelog](/docs/bigquery-changelog) for changes to this surface. The [sales API guide](/docs/sales-data) describes a separate REST interface; its camel-case response fields are not BigQuery column names. ## Access and scope [#access-and-scope] Karma supplies a Google Cloud project, a customer dataset of the form `customer_location_`, and access for your approved Google identity. Use the project and dataset **for the environment you are integrating with**; development, staging, and production are separate. Your organization might not have every environment provisioned. Ask Karma for the exact identifiers and access rather than constructing them from another environment's IDs. The customer dataset contains **views**, not a second copy of the operational database. They read replicated tables and restrict rows to the customer's location tree through `location_members`. `location_members` lists locations in scope; its `id` is a location ID, not a purchase ID. Tables with no direct `location_id` are scoped by their parent purchase, tab, dispatch, item, or account. Do not query the upstream `analytics_eupublic` dataset: the customer views are the access boundary. Data may appear after the original transaction and is not an atomic cross-table snapshot. To see which views your dataset actually provides: ```sql SELECT table_name FROM `..INFORMATION_SCHEMA.VIEWS` ORDER BY table_name; ``` Replace both angle-bracketed identifiers before running these examples. BigQuery parameters cannot substitute for project, dataset, or table identifiers. Access to the customer dataset does not imply access to any upstream dataset. ## Model and joins [#model-and-joins] | Subject | Grain and join | | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `purchases` | One payment/purchase row; `id` is its stable primary key **within one environment**. A tab can have several purchases. | | `purchase_line_items` | One recorded line; `purchase_id = purchases.id`. Child/add-on lines can have `parent_line_item_id` and their own `origin_item_id`. | | `purchase_payment_method` | Payment-method detail for a purchase; join on `purchase_id`. Use a `LEFT JOIN`: enrichment can arrive later or be absent. | | `purchase_refunds` | One refund attempt/result; `purchase_id` points back to the **original** purchase. A confirmed sale remains confirmed after a refund; technical zero-refunds may instead reference failed purchases. | | `purchase_refund_line_items`, `purchase_refund_vats` | Monetary refund allocations/VAT; join to `purchase_refunds.id` via `purchase_refund_id`. An amount-only allocation does not establish returned quantity; an explicit product-quantity refund may record it in `quantity_cents`, advertised in both checked development and staging customer views on October 12, 2026. | | `tab_members` → `tabs` | `purchases.paying_tab_member_id = tab_members.uuid`, then `tab_members.tab_id = tabs.uuid`. These are separate from the purchase/receipt identity. | | `kitchen_dispatches` → `kitchen_dispatch_items` | `kitchen_dispatch_items.kitchen_dispatch_id = kitchen_dispatches.uuid`. Kitchen activity describes orders/fulfilment, not proof of payment. | | `items`, `variants` | Catalogue records and their variants; a purchase line's `origin_item_id` can resolve to `items.id`. Prices and metadata on a current catalogue item are not a historical snapshot of the sale. | | `discounts` | Current location-owned discount and voucher definitions, scoped to customer locations. Not a record that a discount was actually applied; verify this view exists in your dataset before using it. | | Tab ledger (planned) | `tabs.ledger_account_id = ledger_accounts.id`; charges, discounts, and payments hang off the account. `item_charges.kitchen_dispatch_item_id` links a charge to its kitchen line; `purchases.discharge_id` links a payment to its discharge; `tab_purchase_discounts.purchase_intent_id = purchases.intent_id` links a discount to the payment it came with (`scope = 'payment'`: in that payment's `applied_total_discount_amount_cents`). Not yet in any customer view; see the [changelog](/docs/bigquery-changelog). | **Price-cluster customer views verified October 19 in dev/test:** `price_clusters` records the owning location, name and version; `price_cluster_entries` maps catalogue items to configured prices; `price_cluster_locations` maps assigned locations; and `price_cluster_applications` tracks active location/item combinations and previous overrides. Join child views on `cluster_id = price_clusters.id`. Views restrict cluster ownership and assigned/application target locations through the customer's `location_members`. They describe current configuration, **not** historical sale prices. The checked EH scopes currently contain no matching price-cluster rows. Production availability is separate; consult the [BigQuery changelog](/docs/bigquery-changelog) and verify your own dataset. Many relationships are one-to-many. Joining purchases to payment methods, lines, refunds, and tabs all at once and then summing amounts can multiply rows. Aggregate each child table **at its own grain** before joining the aggregates. Keep `LEFT JOIN`s where a missing related record should not erase the purchase. ## Finality, types, and money [#finality-types-and-money] `purchases.state` and `purchase_refunds.state` each use `pending`, `canceled`, `confirmed`, and `failed` (one **l** in `canceled`). Only `confirmed` is the finalized sale or refund state. There are no purchase states named `settled`, `captured`, `refunded`, or `partially_refunded`. PSP settlement, cash-register registration, and downstream reporting can occur later than payment confirmation. `purchase_line_items.type` is `product`, `tip`, or `amount`. `tip` is gratuity; `amount` is an unallocated amount payment against a tab, not a catalogue product. For an **article-level goods export**, select `product`, then exclude non-goods `item_type` values such as `service_fee`, `marketplace_fee`, and `marketplace_discount`. Include `surplus` products unless your business specifically excludes that category; `is_surplus` is not a canceled-sale flag. A bag or deposit sold as an ordinary catalogue product requires your own article classification. A payment made as an `amount` line can still pay for real goods on the tab: excluding the line from an article export must not silently remove that payment from reconciliation. Allocate tab goods deliberately and avoid exporting them once per partial payment. Unlike `type`, **`purchase_line_items.item_type` is extensible text, not a closed database enum**. It is also separate from the current catalogue's inventory `itemType`. These are the current reporting classifications, not a promise that no additional value can be introduced: | Sales-line `item_type` | Goods export | | ------------------------------ | ---------------------------------------------------------------------------- | | `regular` or historical `NULL` | Include, subject to article classification. | | `surplus` | Include unless your business explicitly excludes surplus goods. | | `service_fee` | Exclude; service surcharge, not goods. | | `marketplace_fee` | Exclude; marketplace fee, not goods. | | `marketplace_discount` | Exclude; separate financial discount line, not goods. | | Unrecognised value | Alert and hold the affected export for classification; retain it for replay. | The documented non-goods deny-list is appropriate for **recognised** values. For an irreversible downstream product upload, check for unknown values first; do not silently accept a new fee as goods or discard a real product. The [BigQuery changelog](/docs/bigquery-changelog) records observed changes; no separate advance-notification SLA is established here. Amounts ending in `_cents` use **integer minor currency units**, except `quantity_cents`, which measures hundredths of one unit (`150` means 1.5); do not apply floating-point currency arithmetic. `purchases.currency_code` identifies the currency. `vat_rate_basis_points` is in basis points (`1200` means 12%). Prices include VAT. `quantity` is a whole-unit compatibility field. `final_amount_cents` is a **per-unit, post-discount** price; `vat_amount_cents` is per-unit VAT. On current finalized lines, `total_amount_cents` is the **line total before the line's discount** and `applied_total_discount_amount_cents` is that discount: the monetary line total is `total_amount_cents - applied_total_discount_amount_cents`. On tabs with a ledger account (`tabs.ledger_account_id` set) the discount is every discount on the line, given at payment or before it (at the till or by an integration's price lock), and `pre_discount_vat_amount_cents` is the VAT of `total_amount_cents`. On other tabs it is the payment-time discount (vouchers and promotions applied when paying); a discount given on the item before payment is already in `final_amount_cents` and `total_amount_cents`, so neither column shows it. Never multiply `total_amount_cents` by quantity or subtract that discount twice. Legacy/null totals, fractional quantities, modifiers, tips, and amount payments need their own reconciliation path; `purchases.amount_cents` is nullable (including on ordinary POS purchases), so it is not a universal sale-total field. A goods-line subtotal is not necessarily the payment total. `purchase_vats` provides per-rate purchase VAT totals. `applied_modifications` contains inline pricing/modification detail; it is not a separate sellable line. On tabs outside the tab ledger it can also list payment-time discounts and vouchers as negative adjustments; lines of ledger tabs (`tabs.ledger_account_id` set) list none, so take a line's discount from `applied_total_discount_amount_cents`. A separately priced catalogue add-on can instead be a child `purchase_line_items` row. Count each recorded goods line once, but do not independently add inline adjustments again. `article_no` is **not** a guaranteed partner SKU. Resolve `origin_item_id` to `items.id` and agree on a customer-specific key in `items.metadata`; JSON keys are case-sensitive. Current catalogue data can change after a purchase. For purchase-level VAT reporting, use `purchase_vats.vat_amount_cents` grouped by `vat_rate_basis_points` for a confirmed purchase. The per-unit `purchase_line_items.vat_amount_cents` and whole-line `total_vat_amount_cents` have different grains; reconstructing VAT by rounding each line can differ from the purchase-level VAT total. Reconcile line-level detail to the purchase totals and keep refund VAT in `purchase_refund_vats` separate from the original purchase VAT. **Historical discounted VAT:** September 22 staging purchase `1500000125` records 185.00 SEK gross, a 60.00 SEK discount, 19.80 SEK summed line VAT and 13.39 SEK purchase VAT. Its historical line VAT is pre-discount. The checked discounted examples from September 24 onward and the current discount writers use post-discount line VAT; this observation does not establish an exact rollout timestamp or backfill historical rows. Newer `pre_discount_vat_amount_cents` records pre-discount VAT separately and is nullable historically. Purchase `1500000130` records 48.12 SEK summed line VAT versus 48.11 SEK purchase VAT, illustrating rounding differences even without a discount. Use the purchase-level record for purchase VAT; do not rewrite stored VAT from today's catalogue settings. The line's `applied_total_discount_amount_cents` is the recorded discount: every discount on ledger tabs, the payment-time discount elsewhere. Current `discounts` definitions describe configuration (including voucher and gift-card types), not which coupon or calculation produced a particular sale's discount; do not join a current definition to a historical purchase and recompute its price. Available line metadata can describe applied modifications but the current customer views do not promise a complete discount-calculation trace. The planned tab-ledger views name each discount on a tab line with its source, including discounts given before payment; see the [changelog](/docs/bigquery-changelog) for availability. For surplus article classification, `purchase_line_items.is_surplus` is the recorded sale-line flag; `item_type` can additionally describe the catalogue category. Do not substitute current `items` catalogue values for historical sale-line flags. Refund amounts are positive magnitudes in the source; negate **confirmed** refunds only when creating a signed ledger. A partial financial refund does not prove that any products were returned. An explicit refunded product quantity can be recorded in `purchase_refund_line_items.quantity_cents` (hundredths of a unit), but amount-only and historical allocations have `NULL` there. As of October 8, 2026, it is advertised as `INT64` in the checked **development and staging** customer views; no production customer view was available to verify. Check your own view before querying it, and do not infer returned units from refund amounts. `purchase_applied_fees` are commercial/payment fees, not products; do not add them to an article export. For split/partial tab payments, a tab can have several purchases. Export each payment with its own `purchases.id` and each recorded product allocation once; do not repeat the tab's full goods on every purchase. The current amount-payment path allocates goods proportionally into `product` lines, including zero-priced add-ons. Older amount placeholders may be allocated later when the tab closes. An amount-only purchase needs reconciliation before its underlying goods can be exported; confirmation alone does not promise that every historical payment already has product allocations. A verified staging example, read on October 12, is tab `0f7a9115-11ca-4925-8eb6-fbeec596ca56`: | Purchase | Tender | Bun | Latte | Milk | Paid total | | ------------ | ---------------- | ---------------------- | ---------------- | --------------- | ---------- | | `1500000208` | Prepaid code `2` | 0.50 units / 20.50 SEK | 0.50 / 27.50 SEK | 0.50 / 0.00 SEK | 48.00 SEK | | `1500000209` | Adyen card | 0.50 units / 20.50 SEK | 0.50 / 27.50 SEK | 0.50 / 0.00 SEK | 48.00 SEK | Each allocation has `quantity_cents = 50`. Across the two purchases, export one bun, one latte and one milk, for 96.00 SEK. Partial amount-allocation quantities can be derived proportions; they are not evidence of which physical items a particular payer consumed. Split-payment add-ons can be flat product lines with `parent_line_item_id = NULL`. In the example, milk's `kitchen_dispatch_item_id` still resolves to a kitchen row whose `parent_kitchen_dispatch_item_id` identifies the latte. To recover a purchase-line parent, resolve that kitchen parent **within the same purchase**. Do not assume a unique purchase-line parent if multiple allocations share one kitchen ID; flag ambiguous matches rather than multiplying rows. A zero-price add-on can still affect food cost and must not be dropped because its price is zero. `purchase_payment_method` enrichment can be absent: nine confirmed prepaid purchases in the checked staging data had no method row. Split tender in the example is separate purchases on one tab, not two tender rows on one purchase. Use a `LEFT JOIN` and account for method-row cardinality before joining lines. `prepaid_type_code` snapshots the configured **string** code, `payment_method_id` is the Karma prepaid-type UUID, and `brand` is a label. Join configuration by `payment_method_id = prepaid_types.id`; codes are not globally unique across locations, and historical snapshots can differ from edited configuration. Agree a code-to-account mapping with the customer's finance team; paying with prepaid does not itself exclude the goods sale or create a second sale. No sale-line ordinal guarantees business order: sort by `created_at`, then `id` for stable presentation, not a contractual preparation sequence. **Third-party article mapping:** when `origin_item_id` is missing, a populated `kitchen_dispatch_item_id` can resolve through `kitchen_dispatch_items.item_id` to the catalogue. In checked staging purchase `1500000108`, that path recovers partner articles `000026`, `000050` and `000060`, even though all three purchase lines lack `origin_item_id`. The recorded monetary allocation remains 153.00 SEK on the latte and zero on the bagel/focaccia. Recovering article identity does not establish a corrected price split or historical backfill. Keep catalogue joins optional, alert on unresolved article mappings, and do not invent prices from today's catalogue or discard zero-price goods. The catalogue remains mutable. An order/tab `tab_type` or kitchen `is_takeaway` flag is not by itself a verified tax classification. `over_the_counter` is an order mode, not proof of eat-in. The checked customer `locations` view has no timezone column; obtain an explicit shop-to-IANA-timezone mapping before adding outlets outside the assumed timezone. **Internal tenders and training mode:** a purchase with no external PSP round-trip can still be a legitimate confirmed sale using prepaid, cash or gift-card settlement. Do not exclude it because card details or external provider references are absent. The checked customer schema has no dedicated terminal training-mode field providing a reliable sales exclusion. Staging fixtures do not establish production exclusion rules: for example, amount-only purchase `1500000137` is explicitly a Swish simulator fixture. Tipping is controlled by the location's `tip` setting; disabling future tip collection does not remove historical tip lines. ## Partner member identity and refund limitations [#partner-member-identity-and-refund-limitations] An integration can store an attached partner member number in `tabs.metadata`; the JSON key is partner-specific, not a universal schema field. Confirm the actual key with Karma and replace `` below with its JSON path. Reach the tab with `LEFT JOIN`s through `purchases.paying_tab_member_id = tab_members.uuid` and `tab_members.tab_id = tabs.uuid`; confirmed purchases can have no tab member. Keep member numbers as strings. ```sql SELECT p.id AS purchase_id, JSON_VALUE(t.metadata, '') AS tab_partner_member_number FROM `..purchases` AS p LEFT JOIN `..tab_members` AS tm ON tm.uuid = p.paying_tab_member_id LEFT JOIN `..tabs` AS t ON t.uuid = tm.tab_id WHERE p.state = 'confirmed'; ``` This is a **mutable tab attachment**, not an immutable member identity for every split-bill payer. Historical purchase metadata can contain provider-specific member IDs; do not assume those keys persist in new purchases. In the checked partner payment flow, the internal quote/loyalty payload is intentionally omitted from new purchase metadata, while generic payment metadata can remain. Newer verified per-payment finalize context is stored in the internal integration log, which is **not among the checked customer views**. A guaranteed per-payment member snapshot needs a separate export contract; do not substitute Karma user IDs or card-matching IDs for a partner member number. The checked refund API requires an original purchase and refunds through its tender. The customer refund view has no independent **actual refund payout method** column. Joining the original purchase retrieves the original tender; it does not prove a card sale was refunded in cash. Cross-tender refunds need an explicit product and reporting contract. A missing paper/fiscal receipt is different from an absent original sale record. A refund can reference its original purchase with nullable fiscal fields, but the current refund contract requires an original purchase ID/intent. A return with no original Karma sale is not supplied as an unlinked refund or negative purchase under that contract. Do not fabricate an original ID. Monetary line/VAT allocations alone do not guarantee physical goods were returned; explicit refunded quantities can remain unknown. ## IDs and time [#ids-and-time] Use `purchases.id` as the idempotency key for a **per-purchase** export. It remains the same when that row is updated; `purchase_refunds.purchase_id` ties refund records to it, while `purchase_refunds.id` identifies the individual refund. Namespace keys by environment if you combine environments. A recreated database/import is outside the normal purchase lifecycle. `purchases.cr_receipt_id` is a separate, nullable fiscal cash-register reference and may be populated later. It is not a universal sale key or the human-readable running receipt number. Nor is a tab UUID a per-purchase receipt number. `purchases.purchased_at` is the payment-confirmation time. The PostgreSQL source stores it as UTC in a timestamp-without-time-zone column; the BigQuery view exposes a `TIMESTAMP`. Serialize the **instant** with an explicit UTC offset, such as `Z`. Compute an outlet business date in the outlet's configured IANA timezone, not from the server timezone or a timezone-free display string. `purchase_refunds.cr_registered_at` is nullable and records later fiscal **refund** registration; it is not the original sale time. `created_at`, `updated_at`, and event times serve different purposes; do not replace a sale-time filter with a change-time filter without changing the report's meaning. ```sql SELECT id AS purchase_id, FORMAT_TIMESTAMP('%Y-%m-%dT%H:%M:%E3SZ', purchased_at, 'UTC') AS sold_at_utc, DATE(purchased_at, 'Europe/Stockholm') AS local_business_date FROM `..purchases` WHERE state = 'confirmed'; ``` `Europe/Stockholm` is an **example**, not a default for every location. Supply the outlet's configured timezone to compute its local reporting day. ## Example: extract goods and refunds [#example-extract-goods-and-refunds] This query preserves the line grain and reports a post-discount amount for rows that have a modern `total_amount_cents`. It deliberately **does not** claim that goods lines sum to a payment when tips, amount payments, adjustments, or missing historical totals are present. Before an irreversible upload, run this check over the **same sale window**. An output row means the item's classification needs review; retain the affected purchase for replay before proceeding. The non-goods deny-list in the extraction below does not itself quarantine unknown values. ```sql SELECT p.id AS purchase_id, pli.id AS line_id, pli.item_type FROM `..purchases` AS p JOIN `..purchase_line_items` AS pli ON pli.purchase_id = p.id WHERE p.state = 'confirmed' AND pli.type = 'product' AND COALESCE(pli.item_type, 'regular') NOT IN ('regular', 'surplus', 'service_fee', 'marketplace_fee', 'marketplace_discount') AND p.purchased_at >= TIMESTAMP('2026-09-01T00:00:00Z') AND p.purchased_at < TIMESTAMP('2026-10-01T00:00:00Z'); ``` ```sql SELECT p.id AS purchase_id, p.location_id, p.currency_code, p.purchased_at, pli.id AS line_id, pli.parent_line_item_id, pli.origin_item_id, pli.quantity_cents, pli.vat_rate_basis_points, pli.total_amount_cents - COALESCE(pli.applied_total_discount_amount_cents, 0) AS paid_line_cents FROM `..purchases` AS p JOIN `..purchase_line_items` AS pli ON pli.purchase_id = p.id WHERE p.state = 'confirmed' AND pli.type = 'product' AND COALESCE(pli.item_type, 'regular') NOT IN ('service_fee', 'marketplace_fee', 'marketplace_discount') AND pli.total_amount_cents IS NOT NULL AND p.purchased_at >= TIMESTAMP('2026-09-01T00:00:00Z') AND p.purchased_at < TIMESTAMP('2026-10-01T00:00:00Z'); ``` For a financial refund export, start from `purchase_refunds` filtered on `state = 'confirmed'`, join `purchases` on `purchase_id`, and key each refund by its own `id`. Prefer `psp_refunded_amount_cents` for the recorded provider result and fall back to `amount_cents` only where the former is absent; do not treat two missing amounts as zero. A confirmed *technical* zero-refund can reference a failed original purchase: check the joined purchase state and recorded amount before treating it as a negative sale. The fiscal receipt can describe a later processing step. Monetary allocations in `purchase_refund_line_items` alone are not returned-quantity evidence; where available, `quantity_cents` records explicitly selected product quantities when present. ## Keeping an export correct [#keeping-an-export-correct] 1. Backfill confirmed purchases over your desired **sale-time** window. Store by environment and `purchases.id`; store refunds independently by environment and `purchase_refunds.id`. 2. Re-read a recent window for late confirmation, payment-method enrichment, fees, fiscal details, refunds and legacy tab allocation. Thirty minutes is not an immutability guarantee. Use an overlap and upserts; do not assume cross-table writes or BigQuery replication arrive together. A source `updated_at` is useful for changes to **that table**, but does not automatically advance when a child/refund row changes. Resolve allocation exceptions before sending data to a destination that cannot correct a sale later. 3. Compare purchase totals, goods lines, tips, amount-payment lines, confirmed refunds, and fees as **different categories**, not one sum. Check expected currencies and outlet timezones. Do not count pending or failed refunds. 4. Check view metadata as well as sample data before changing a connector schema. A newly replicated column can be queryable before a customer view's stored schema / `INFORMATION_SCHEMA.COLUMNS` is refreshed. Do not infer a type from one `SELECT * LIMIT 1` row, especially when it is `NULL`. 5. Select the columns you need rather than relying on `SELECT *`. Views can contain operational `JSON` or `STRUCT` fields that a connector cannot ingest. The upstream `printer_jobs.rendered_content` is raw `BYTES`, but it was **removed from the checked development/staging customer views on October 8, 2026**; do not depend on the raw upstream payload being customer-visible. Confirm your own view's metadata before using a connector. 6. If your incremental ingestion only upserts rows it sees, it cannot by itself detect a row later physically removed from a merge-mode source. Reconcile keys against periodic full reads when deletion or void detection matters; do not assume a delete event without a separately verified signal. ```sql SELECT table_name, column_name, data_type, is_nullable FROM `..INFORMATION_SCHEMA.COLUMNS` WHERE table_name = 'purchases' ORDER BY ordinal_position; ``` The [field reference](/docs/bigquery-fields) is a dated example, not a substitute for querying your own view schema. Report a missing column, stale view schema, or type mismatch with the environment, dataset, view, column, expected/observed types, and a minimal read-only query. Changes and known limitations are tracked in the [BigQuery changelog](/docs/bigquery-changelog). # Booking Groups Grouping of related bookings ## GET /api/v1/booking-groups/{id} Get booking group Get a single booking group by ID **Permissions:** 🔵 `bookings.read` Path parameters: - `id` (string, required) — Booking group ID Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number name: string description?: string | null sortOrder: number isActive: boolean schedule: Array<{ dayOfWeek: number startTime: string endTime: string }> scheduleId?: string | null // uuid scheduleRules: Array<{ dayOfWeek?: number | null startTime: string endTime: string ruleType: "WEEKDAY" | "DATE_RANGE" | "SPECIFIC_DATE" startDate?: string | null endDate?: string | null isAvailable: boolean }> tableIds: string[] slotIntervalMinutes: number defaultDurationMinutes: number maxPartySize: number minAdvanceHours: number maxAdvanceDays: number paymentHoldMinutes: number confirmationMode: string requiredItems: Array<{ id: string // uuid inventoryItemId: number quantityPer: "booking" | "guest" itemName?: string | null itemPrice?: number | null }> createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/booking-groups/{id} Update booking group Update an existing booking group **Permissions:** ⚪ `bookings.manage` Path parameters: - `id` (string, required) — Booking group ID Request body: ```ts { name?: string description?: string | null sortOrder?: number isActive?: boolean schedule?: Array<{ dayOfWeek: number startTime: string endTime: string }> tableIds?: string[] slotIntervalMinutes?: number defaultDurationMinutes?: number maxPartySize?: number minAdvanceHours?: number maxAdvanceDays?: number paymentHoldMinutes?: number confirmationMode?: string requiredItems?: Array<{ inventoryItemId: number quantityPer: "booking" | "guest" }> } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number name: string description?: string | null sortOrder: number isActive: boolean schedule: Array<{ dayOfWeek: number startTime: string endTime: string }> scheduleId?: string | null // uuid scheduleRules: Array<{ dayOfWeek?: number | null startTime: string endTime: string ruleType: "WEEKDAY" | "DATE_RANGE" | "SPECIFIC_DATE" startDate?: string | null endDate?: string | null isAvailable: boolean }> tableIds: string[] slotIntervalMinutes: number defaultDurationMinutes: number maxPartySize: number minAdvanceHours: number maxAdvanceDays: number paymentHoldMinutes: number confirmationMode: string requiredItems: Array<{ id: string // uuid inventoryItemId: number quantityPer: "booking" | "guest" itemName?: string | null itemPrice?: number | null }> createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/booking-groups/{id} Delete booking group Soft-delete a booking group (sets is_active = false) **Permissions:** ⚪ `bookings.manage` Path parameters: - `id` (string, required) — Booking group ID Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number name: string description?: string | null sortOrder: number isActive: boolean schedule: Array<{ dayOfWeek: number startTime: string endTime: string }> scheduleId?: string | null // uuid scheduleRules: Array<{ dayOfWeek?: number | null startTime: string endTime: string ruleType: "WEEKDAY" | "DATE_RANGE" | "SPECIFIC_DATE" startDate?: string | null endDate?: string | null isAvailable: boolean }> tableIds: string[] slotIntervalMinutes: number defaultDurationMinutes: number maxPartySize: number minAdvanceHours: number maxAdvanceDays: number paymentHoldMinutes: number confirmationMode: string requiredItems: Array<{ id: string // uuid inventoryItemId: number quantityPer: "booking" | "guest" itemName?: string | null itemPrice?: number | null }> createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/locations/{locationId}/booking-groups List booking groups List booking groups for a location. Active groups only by default; pass includeInactive=true to include inactive. Query parameters: - `includeInactive` (boolean, optional) — Include inactive groups Path parameters: - `locationId` (number, required) — Location ID Response 200 — Default Response ```ts { status: "success" data: { items: Array<{ id: string // uuid locationId: number name: string description?: string | null sortOrder: number isActive: boolean schedule: unknown[] scheduleId?: string | null // uuid scheduleRules: unknown[] tableIds: unknown[] slotIntervalMinutes: number defaultDurationMinutes: number maxPartySize: number minAdvanceHours: number maxAdvanceDays: number paymentHoldMinutes: number confirmationMode: string requiredItems: unknown[] createdAt: string // date-time updatedAt: string // date-time }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/locations/{locationId}/booking-groups Create booking group Create a new booking group for a location **Permissions:** ⚪ `bookings.manage` Path parameters: - `locationId` (number, required) — Location ID Request body: ```ts { name: string description?: string | null sortOrder?: number isActive?: boolean schedule?: Array<{ dayOfWeek: number startTime: string endTime: string }> tableIds?: string[] slotIntervalMinutes?: number defaultDurationMinutes?: number maxPartySize?: number minAdvanceHours?: number maxAdvanceDays?: number paymentHoldMinutes?: number confirmationMode?: string requiredItems?: Array<{ inventoryItemId: number quantityPer: "booking" | "guest" }> } ``` Response 201 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number name: string description?: string | null sortOrder: number isActive: boolean schedule: Array<{ dayOfWeek: number startTime: string endTime: string }> scheduleId?: string | null // uuid scheduleRules: Array<{ dayOfWeek?: number | null startTime: string endTime: string ruleType: "WEEKDAY" | "DATE_RANGE" | "SPECIFIC_DATE" startDate?: string | null endDate?: string | null isAvailable: boolean }> tableIds: string[] slotIntervalMinutes: number defaultDurationMinutes: number maxPartySize: number minAdvanceHours: number maxAdvanceDays: number paymentHoldMinutes: number confirmationMode: string requiredItems: Array<{ id: string // uuid inventoryItemId: number quantityPer: "booking" | "guest" itemName?: string | null itemPrice?: number | null }> createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Bookings - Optimization AI-powered booking table optimization ## PATCH /api/v1/bookings/{id}/lock Toggle booking resource lock Lock or unlock a booking to its assigned table. Locked bookings will not be moved by the optimizer. **Permissions:** ⚪ `bookings.manage` Path parameters: - `id` (string, required) — Booking ID Request body: ```ts { locked: boolean } ``` Response 200 — Default Response ```ts { status: "success" data: { 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 } | null id: string // uuid locationId: number tableId?: string | null // uuid bookingDate: string // date startTime: string endTime: string timezone: string primaryUserId?: number | null guestCount: number occasion?: "none" | "birthday" | "anniversary" | "business" | "date" | "celebration" | "other" specialRequests?: string | null internalNotes?: string | null notes?: Array<{ uuid: string // uuid note: string sortOrder: number createdByRetailerUserId?: number | null updatedByRetailerUserId?: number | null createdAt: string // date-time updatedAt: string // date-time }> status: "pending" | "confirmed" | "checked_in" | "seated" | "completed" | "cancelled" | "no_show" paymentType?: "none" | "deposit" | "full_prepayment" depositAmountCents?: number | null purchaseId?: number | null confirmationCode: string bookingSource?: "direct" | "website" | "app" | "phone" | "walkin" | "third_party" estimatedRevenueCents?: number | null guestName?: string | null guestEmail?: string | null guestPhone?: string | null createdAt: string // date-time updatedAt: string // date-time confirmedAt?: string | null // date-time seatedAt?: string | null // date-time completedAt?: string | null // date-time cancelledAt?: string | null // date-time tabId?: string | null defaultTabStrategy: "single" | "per_resource" resources: Array<{ qrCodeId: string // uuid isPrimary: boolean position: integer }> userName?: string | null userEmail?: string | null userPhone?: string | null tableName?: string | null location?: { id?: number name?: string | null city?: string | null latitude?: number | null longitude?: number | null logoImageUrl?: string | null } | null attribution?: { firstTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } bookingTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } reportedSource?: string recordedAt: string // date-time } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/locations/{locationId}/bookings/optimize Optimize booking table assignments Run the booking optimization algorithm to improve table utilization. Use dryRun=true to preview changes without applying them. **Permissions:** ⚪ `bookings.manage` Query parameters: - `date` (string, required) — Date to optimize (YYYY-MM-DD) Path parameters: - `locationId` (number, required) — Location ID Request body: ```ts { dryRun?: boolean } ``` Response 200 — Default Response ```ts { status: "success" data: { changes: Array<{ bookingId: string // uuid guestName?: string | null partySize: number fromTableId?: string | null // uuid fromTableName?: string | null toTableId: string // uuid toTableName: string reason: string }> stats: { totalBookings: number optimizedCount: number lockedCount: number tablesFreed: number utilizationImprovement: number } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Bookings - Settings Location booking settings and configuration ## GET /api/v1/locations/{locationId}/booking-settings Get location booking settings Get booking settings for a location. Creates default settings if none exist. **Permissions:** 🔵 `bookings.read` Path parameters: - `locationId` (number, required) — Location ID Response 200 — Default Response ```ts { status: "success" data: { id: number locationId: number bufferMinutes: number defaultDurationMinutes: number autoOptimizeEnabled: boolean maxPartySize?: number minAdvanceHours?: number maxAdvanceDays?: number allowedPartySizes?: number[] operatingHours?: Record slotIntervalMinutes?: number overbookingBufferPercentage?: number confirmationMode: "instant" | "request_based" autoNoShowDelayMinutes?: number | null cancellationPolicyHours?: number | null bookingWindowDays?: number | null maxActiveBookingsPerGuest?: number | null createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/locations/{locationId}/booking-settings Update location booking settings Update booking settings for a location. **Permissions:** ⚪ `bookings.manage` Path parameters: - `locationId` (number, required) — Location ID Request body: ```ts { bufferMinutes?: number defaultDurationMinutes?: number autoOptimizeEnabled?: boolean maxPartySize?: number minAdvanceHours?: number maxAdvanceDays?: number allowedPartySizes?: number[] operatingHours?: Record slotIntervalMinutes?: 15 | 30 | 45 | 60 | 90 overbookingBufferPercentage?: number confirmationMode?: "instant" | "request_based" autoNoShowDelayMinutes?: number | null cancellationPolicyHours?: number | null bookingWindowDays?: number | null maxActiveBookingsPerGuest?: number | null } ``` Response 200 — Default Response ```ts { status: "success" data: { id: number locationId: number bufferMinutes: number defaultDurationMinutes: number autoOptimizeEnabled: boolean maxPartySize?: number minAdvanceHours?: number maxAdvanceDays?: number allowedPartySizes?: number[] operatingHours?: Record slotIntervalMinutes?: number overbookingBufferPercentage?: number confirmationMode: "instant" | "request_based" autoNoShowDelayMinutes?: number | null cancellationPolicyHours?: number | null bookingWindowDays?: number | null maxActiveBookingsPerGuest?: number | null createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Bookings - Users Guest user search and creation for bookings ## POST /api/v1/users/guests Resolve or create guest user Resolve an existing guest or create a new one. At least email or phone is required. Returns 200 when an existing guest is resolved and 201 when a new guest is created. **Permissions:** 🟢 `bookings.create` Request body: ```ts { name?: string email?: string // email phone?: string } ``` Response 200 — Default Response ```ts { status: "success" data: { id: number email?: string | null phone?: string | null name?: string | null lastSeenDate?: string | null // date-time bookingCount: number lastBookingDate?: string | null // date vip: boolean flags: Record hasLocationOverride: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` Response 201 — Default Response ```ts { status: "success" data: { id: number email?: string | null phone?: string | null name?: string | null lastSeenDate?: string | null // date-time bookingCount: number lastBookingDate?: string | null // date vip: boolean flags: Record hasLocationOverride: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/users/search Search guest users Search for guest users by email, phone, or name. Returns users with their booking history at the location. **Permissions:** 🔵 `bookings.read` Query parameters: - `q` (string, required) — Search query (email, phone, or name) - `locationId` (number, optional) — Location ID for booking history context - `limit` (number, optional) — Maximum results to return Response 200 — Default Response ```ts { status: "success" data: { users: Array<{ id: number email?: string | null phone?: string | null name?: string | null lastSeenDate?: string | null // date-time bookingCount: number lastBookingDate?: string | null // date vip: boolean flags: { … } hasLocationOverride: boolean }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Bookings - Waitlist Booking waitlist management ## GET /api/v1/booking-waitlist List waitlist List waitlist entries **Permissions:** 🔵 `booking_waitlist.read` Query parameters: - `locationId` (number, optional) — Filter by location ID - `status` ("waiting" | "notified" | "converted" | "expired" | "removed", optional) — Waitlist entry status - `requestedDate` (string, optional) — Filter by requested date - `page` (number, optional) — Page number - `limit` (number, optional) — Items per page Response 200 — Default Response ```ts { status: "success" data: { entries: Array<{ id: string // uuid locationId: number userId: number requestedDate: string // date preferredStartTime?: string | null preferredEndTime?: string | null partySize: number status: "waiting" | "notified" | "converted" | "expired" | "removed" convertedBookingId?: string | null // uuid expiresAt?: string | null // date-time notifiedAt?: string | null // date-time createdAt: string // date-time updatedAt: string // date-time userName?: string | null userEmail?: string | null userPhone?: string | null }> pagination: { page: number limit: number total: number totalPages: number } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/booking-waitlist Add to waitlist Add a guest to the waitlist **Permissions:** ⚪ `booking_waitlist.manage` Request body: ```ts { locationId: number userId: number requestedDate: string // date partySize: number preferredStartTime?: string preferredEndTime?: string } ``` Response 201 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number userId: number requestedDate: string // date preferredStartTime?: string | null preferredEndTime?: string | null partySize: number status: "waiting" | "notified" | "converted" | "expired" | "removed" convertedBookingId?: string | null // uuid expiresAt?: string | null // date-time notifiedAt?: string | null // date-time createdAt: string // date-time updatedAt: string // date-time userName?: string | null userEmail?: string | null userPhone?: string | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/booking-waitlist/{id} Remove from waitlist Remove a guest from the waitlist **Permissions:** ⚪ `booking_waitlist.manage` Path parameters: - `id` (string, required) — Waitlist entry ID Response 200 — Default Response ```ts { status?: "success" data?: { deleted?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/booking-waitlist/{id}/convert Convert waitlist to booking Convert a waitlist entry to a booking **Permissions:** ⚪ `booking_waitlist.manage` Path parameters: - `id` (string, required) — Waitlist entry ID Request body: ```ts { tableId?: string // uuid startTime: string endTime: string } ``` Response 201 — Default Response ```ts { status: "success" data: { 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 } | null id: string // uuid locationId: number tableId?: string | null // uuid bookingDate: string // date startTime: string endTime: string timezone: string primaryUserId?: number | null guestCount: number occasion?: "none" | "birthday" | "anniversary" | "business" | "date" | "celebration" | "other" specialRequests?: string | null internalNotes?: string | null notes?: Array<{ uuid: string // uuid note: string sortOrder: number createdByRetailerUserId?: number | null updatedByRetailerUserId?: number | null createdAt: string // date-time updatedAt: string // date-time }> status: "pending" | "confirmed" | "checked_in" | "seated" | "completed" | "cancelled" | "no_show" paymentType?: "none" | "deposit" | "full_prepayment" depositAmountCents?: number | null purchaseId?: number | null confirmationCode: string bookingSource?: "direct" | "website" | "app" | "phone" | "walkin" | "third_party" estimatedRevenueCents?: number | null guestName?: string | null guestEmail?: string | null guestPhone?: string | null createdAt: string // date-time updatedAt: string // date-time confirmedAt?: string | null // date-time seatedAt?: string | null // date-time completedAt?: string | null // date-time cancelledAt?: string | null // date-time tabId?: string | null defaultTabStrategy: "single" | "per_resource" resources: Array<{ qrCodeId: string // uuid isPrimary: boolean position: integer }> userName?: string | null userEmail?: string | null userPhone?: string | null tableName?: string | null location?: { id?: number name?: string | null city?: string | null latitude?: number | null longitude?: number | null logoImageUrl?: string | null } | null attribution?: { firstTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } bookingTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } reportedSource?: string recordedAt: string // date-time } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/booking-waitlist/{id}/notify Notify waitlist Notify a waitlist entry of available slot **Permissions:** ⚪ `booking_waitlist.manage` Path parameters: - `id` (string, required) — Waitlist entry ID Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number userId: number requestedDate: string // date preferredStartTime?: string | null preferredEndTime?: string | null partySize: number status: "waiting" | "notified" | "converted" | "expired" | "removed" convertedBookingId?: string | null // uuid expiresAt?: string | null // date-time notifiedAt?: string | null // date-time createdAt: string // date-time updatedAt: string // date-time userName?: string | null userEmail?: string | null userPhone?: string | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Bookings Table reservations and booking management ## GET /api/v1/bookings List bookings List bookings with optional filtering and pagination **Permissions:** 🔵 `bookings.read` Query parameters: - `locationId` (number, optional) — Filter by location ID - `tableId` (string, optional) — Filter by table ID - `status` ("pending" | "confirmed" | "checked_in" | "seated" | "completed" | "cancelled" | "no_show", optional) — Booking status - `bookingDate` (string, optional) — Filter by exact date - `startDate` (string, optional) — Filter by start date (inclusive) - `endDate` (string, optional) — Filter by end date (inclusive) - `userId` (number, optional) — Filter by user ID - `confirmationCode` (string, optional) — Filter by confirmation code - `bookingSource` ("direct" | "website" | "app" | "phone" | "walkin" | "third_party", optional) — Source of the booking - `guestEmail` (string, optional) — Filter by guest contact email (case-insensitive). Finds bookings made without a user account, which have no userId to filter on. - `guestPhone` (string, optional) — Filter by guest contact phone (exact match). Finds bookings made without a user account, which have no userId to filter on. - `page` (number, optional) — Page number - `limit` (number, optional) — Items per page Response 200 — Default Response ```ts { status: "success" data: { bookings: Array<{ customFields?: unknown[] metadata?: { … } | null id: string // uuid locationId: number tableId?: string | null // uuid bookingDate: string // date startTime: string endTime: string timezone: string primaryUserId?: number | null guestCount: number occasion?: "none" | "birthday" | "anniversary" | "business" | "date" | "celebration" | "other" specialRequests?: string | null internalNotes?: string | null notes?: unknown[] status: "pending" | "confirmed" | "checked_in" | "seated" | "completed" | "cancelled" | "no_show" paymentType?: "none" | "deposit" | "full_prepayment" depositAmountCents?: number | null purchaseId?: number | null confirmationCode: string bookingSource?: "direct" | "website" | "app" | "phone" | "walkin" | "third_party" estimatedRevenueCents?: number | null guestName?: string | null guestEmail?: string | null guestPhone?: string | null createdAt: string // date-time updatedAt: string // date-time confirmedAt?: string | null // date-time seatedAt?: string | null // date-time completedAt?: string | null // date-time cancelledAt?: string | null // date-time tabId?: string | null defaultTabStrategy: "single" | "per_resource" resources: unknown[] userName?: string | null userEmail?: string | null userPhone?: string | null tableName?: string | null location?: { … } | null attribution?: { … } | null }> pagination: { page: number limit: number total: number totalPages: number } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/bookings Create booking Create a new booking. The response always includes a linked `tab` (the booking creates a `pre_arrival` tab atomically) so integrations can begin committing orders against `data.tab.id` immediately. Pass `seatImmediately: true` in the body to atomically transition the new booking to `seated` and open its tab in a single call (recommended for walk-in flows). When `seatImmediately` is true and the booking has unpaid required items or its table already has an open tab, the booking is still created but seating is skipped and `seatWarning` is returned. **Permissions:** 🟢 `bookings.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 { guestFieldAnswers?: unknown // Guest form schema token and answers; validated against the current reservation form. customFields?: unknown | unknown[] requiredItemAnswers?: unknown // Guest form answers keyed by the booking group's required item ID. locationId: number tableId?: string | null // uuid tableIds?: string[] defaultTabStrategy?: "single" | "per_resource" bookingDate: string // date startTime: string endTime: string timezone: string primaryUserId?: number | null guestCount: number occasion?: "none" | "birthday" | "anniversary" | "business" | "date" | "celebration" | "other" specialRequests?: string internalNotes?: string paymentType?: "none" | "deposit" | "full_prepayment" depositAmountCents?: number bookingSource?: "direct" | "website" | "app" | "phone" | "walkin" | "third_party" guestName?: string guestEmail?: string guestPhone?: string seatImmediately?: boolean sendNotification?: boolean sendConfirmation?: boolean attribution?: { firstTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } bookingTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } reportedSource?: string } } ``` Response 201 — Default Response ```ts { status: "success" data: { 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 } | null id?: string // uuid locationId?: number tableId?: string | null // uuid bookingDate?: string // date startTime?: string endTime?: string timezone?: string primaryUserId?: number | null guestCount?: number occasion?: "none" | "birthday" | "anniversary" | "business" | "date" | "celebration" | "other" specialRequests?: string | null internalNotes?: string | null notes?: Array<{ uuid: string // uuid note: string sortOrder: number createdByRetailerUserId?: number | null updatedByRetailerUserId?: number | null createdAt: string // date-time updatedAt: string // date-time }> status?: "pending" | "confirmed" | "checked_in" | "seated" | "completed" | "cancelled" | "no_show" paymentType?: "none" | "deposit" | "full_prepayment" depositAmountCents?: number | null purchaseId?: number | null confirmationCode?: string bookingSource?: "direct" | "website" | "app" | "phone" | "walkin" | "third_party" estimatedRevenueCents?: number | null guestName?: string | null guestEmail?: string | null guestPhone?: string | null createdAt?: string // date-time updatedAt?: string // date-time confirmedAt?: string | null // date-time seatedAt?: string | null // date-time completedAt?: string | null // date-time cancelledAt?: string | null // date-time tabId?: string | null defaultTabStrategy?: "single" | "per_resource" resources?: Array<{ qrCodeId: string // uuid isPrimary: boolean position: integer }> userName?: string | null userEmail?: string | null userPhone?: string | null tableName?: string | null location?: { id?: number name?: string | null city?: string | null latitude?: number | null longitude?: number | null logoImageUrl?: string | null } | null tab?: { id?: string // uuid tabMemberId?: string | null // uuid } requiresPayment?: boolean totalRequiredItemsCents?: number confirmationDelivery?: { email: "sent" | "no_address" sms: "sent" | "no_number" } | null seated?: boolean seatWarning?: string attribution?: { firstTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } bookingTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } reportedSource?: string recordedAt: string // date-time } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/bookings/{id} Get booking Get a single booking by ID **Permissions:** 🔵 `bookings.read` Path parameters: - `id` (string, required) — Booking ID Response 200 — Default Response ```ts { status: "success" data: { 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 } | null id: string // uuid locationId: number tableId?: string | null // uuid bookingDate: string // date startTime: string endTime: string timezone: string primaryUserId?: number | null guestCount: number occasion?: "none" | "birthday" | "anniversary" | "business" | "date" | "celebration" | "other" specialRequests?: string | null internalNotes?: string | null notes?: Array<{ uuid: string // uuid note: string sortOrder: number createdByRetailerUserId?: number | null updatedByRetailerUserId?: number | null createdAt: string // date-time updatedAt: string // date-time }> status: "pending" | "confirmed" | "checked_in" | "seated" | "completed" | "cancelled" | "no_show" paymentType?: "none" | "deposit" | "full_prepayment" depositAmountCents?: number | null purchaseId?: number | null confirmationCode: string bookingSource?: "direct" | "website" | "app" | "phone" | "walkin" | "third_party" estimatedRevenueCents?: number | null guestName?: string | null guestEmail?: string | null guestPhone?: string | null createdAt: string // date-time updatedAt: string // date-time confirmedAt?: string | null // date-time seatedAt?: string | null // date-time completedAt?: string | null // date-time cancelledAt?: string | null // date-time tabId?: string | null defaultTabStrategy: "single" | "per_resource" resources: Array<{ qrCodeId: string // uuid isPrimary: boolean position: integer }> userName?: string | null userEmail?: string | null userPhone?: string | null tableName?: string | null location?: { id?: number name?: string | null city?: string | null latitude?: number | null longitude?: number | null logoImageUrl?: string | null } | null attribution?: { firstTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } bookingTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } reportedSource?: string recordedAt: string // date-time } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/bookings/{id} Update booking Update an existing booking **Permissions:** 🟠 `bookings.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) — Booking ID Request body: ```ts { tableId?: string | null // uuid tableIds?: string[] defaultTabStrategy?: "single" | "per_resource" confirmRemoveActiveTab?: boolean bookingDate?: string // date startTime?: string endTime?: string guestCount?: number occasion?: "none" | "birthday" | "anniversary" | "business" | "date" | "celebration" | "other" specialRequests?: string internalNotes?: string paymentType?: "none" | "deposit" | "full_prepayment" depositAmountCents?: number customFields?: unknown | unknown[] } ``` Response 200 — Default Response ```ts { status: "success" data: { 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 } | null id: string // uuid locationId: number tableId?: string | null // uuid bookingDate: string // date startTime: string endTime: string timezone: string primaryUserId?: number | null guestCount: number occasion?: "none" | "birthday" | "anniversary" | "business" | "date" | "celebration" | "other" specialRequests?: string | null internalNotes?: string | null notes?: Array<{ uuid: string // uuid note: string sortOrder: number createdByRetailerUserId?: number | null updatedByRetailerUserId?: number | null createdAt: string // date-time updatedAt: string // date-time }> status: "pending" | "confirmed" | "checked_in" | "seated" | "completed" | "cancelled" | "no_show" paymentType?: "none" | "deposit" | "full_prepayment" depositAmountCents?: number | null purchaseId?: number | null confirmationCode: string bookingSource?: "direct" | "website" | "app" | "phone" | "walkin" | "third_party" estimatedRevenueCents?: number | null guestName?: string | null guestEmail?: string | null guestPhone?: string | null createdAt: string // date-time updatedAt: string // date-time confirmedAt?: string | null // date-time seatedAt?: string | null // date-time completedAt?: string | null // date-time cancelledAt?: string | null // date-time tabId?: string | null defaultTabStrategy: "single" | "per_resource" resources: Array<{ qrCodeId: string // uuid isPrimary: boolean position: integer }> userName?: string | null userEmail?: string | null userPhone?: string | null tableName?: string | null location?: { id?: number name?: string | null city?: string | null latitude?: number | null longitude?: number | null logoImageUrl?: string | null } | null attribution?: { firstTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } bookingTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } reportedSource?: string recordedAt: string // date-time } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/bookings/{id}/cancel Cancel booking Cancel a booking. Guest email/SMS is sent by default; pass `sendNotification: false` when the caller handles guest communication. Booking webhooks and internal events are always emitted. **Permissions:** ⚪ `bookings.manage` Path parameters: - `id` (string, required) — Booking ID Request body: ```ts { reason?: string closeTab?: boolean sendNotification?: boolean } ``` Response 200 — Default Response ```ts { status: "success" data: { 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 } | null id: string // uuid locationId: number tableId?: string | null // uuid bookingDate: string // date startTime: string endTime: string timezone: string primaryUserId?: number | null guestCount: number occasion?: "none" | "birthday" | "anniversary" | "business" | "date" | "celebration" | "other" specialRequests?: string | null internalNotes?: string | null notes?: Array<{ uuid: string // uuid note: string sortOrder: number createdByRetailerUserId?: number | null updatedByRetailerUserId?: number | null createdAt: string // date-time updatedAt: string // date-time }> status: "pending" | "confirmed" | "checked_in" | "seated" | "completed" | "cancelled" | "no_show" paymentType?: "none" | "deposit" | "full_prepayment" depositAmountCents?: number | null purchaseId?: number | null confirmationCode: string bookingSource?: "direct" | "website" | "app" | "phone" | "walkin" | "third_party" estimatedRevenueCents?: number | null guestName?: string | null guestEmail?: string | null guestPhone?: string | null createdAt: string // date-time updatedAt: string // date-time confirmedAt?: string | null // date-time seatedAt?: string | null // date-time completedAt?: string | null // date-time cancelledAt?: string | null // date-time tabId?: string | null defaultTabStrategy: "single" | "per_resource" resources: Array<{ qrCodeId: string // uuid isPrimary: boolean position: integer }> userName?: string | null userEmail?: string | null userPhone?: string | null tableName?: string | null location?: { id?: number name?: string | null city?: string | null latitude?: number | null longitude?: number | null logoImageUrl?: string | null } | null attribution?: { firstTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } bookingTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } reportedSource?: string recordedAt: string // date-time } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/bookings/{id}/check-in Check in booking Mark a confirmed booking as checked in **Permissions:** ⚪ `bookings.manage` Path parameters: - `id` (string, required) — Booking ID Response 200 — Default Response ```ts { status: "success" data: { 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 } | null id: string // uuid locationId: number tableId?: string | null // uuid bookingDate: string // date startTime: string endTime: string timezone: string primaryUserId?: number | null guestCount: number occasion?: "none" | "birthday" | "anniversary" | "business" | "date" | "celebration" | "other" specialRequests?: string | null internalNotes?: string | null notes?: Array<{ uuid: string // uuid note: string sortOrder: number createdByRetailerUserId?: number | null updatedByRetailerUserId?: number | null createdAt: string // date-time updatedAt: string // date-time }> status: "pending" | "confirmed" | "checked_in" | "seated" | "completed" | "cancelled" | "no_show" paymentType?: "none" | "deposit" | "full_prepayment" depositAmountCents?: number | null purchaseId?: number | null confirmationCode: string bookingSource?: "direct" | "website" | "app" | "phone" | "walkin" | "third_party" estimatedRevenueCents?: number | null guestName?: string | null guestEmail?: string | null guestPhone?: string | null createdAt: string // date-time updatedAt: string // date-time confirmedAt?: string | null // date-time seatedAt?: string | null // date-time completedAt?: string | null // date-time cancelledAt?: string | null // date-time tabId?: string | null defaultTabStrategy: "single" | "per_resource" resources: Array<{ qrCodeId: string // uuid isPrimary: boolean position: integer }> userName?: string | null userEmail?: string | null userPhone?: string | null tableName?: string | null location?: { id?: number name?: string | null city?: string | null latitude?: number | null longitude?: number | null logoImageUrl?: string | null } | null attribution?: { firstTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } bookingTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } reportedSource?: string recordedAt: string // date-time } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/bookings/{id}/complete Complete booking Mark a booking as completed **Permissions:** ⚪ `bookings.manage` Path parameters: - `id` (string, required) — Booking ID Response 200 — Default Response ```ts { status: "success" data: { 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 } | null id: string // uuid locationId: number tableId?: string | null // uuid bookingDate: string // date startTime: string endTime: string timezone: string primaryUserId?: number | null guestCount: number occasion?: "none" | "birthday" | "anniversary" | "business" | "date" | "celebration" | "other" specialRequests?: string | null internalNotes?: string | null notes?: Array<{ uuid: string // uuid note: string sortOrder: number createdByRetailerUserId?: number | null updatedByRetailerUserId?: number | null createdAt: string // date-time updatedAt: string // date-time }> status: "pending" | "confirmed" | "checked_in" | "seated" | "completed" | "cancelled" | "no_show" paymentType?: "none" | "deposit" | "full_prepayment" depositAmountCents?: number | null purchaseId?: number | null confirmationCode: string bookingSource?: "direct" | "website" | "app" | "phone" | "walkin" | "third_party" estimatedRevenueCents?: number | null guestName?: string | null guestEmail?: string | null guestPhone?: string | null createdAt: string // date-time updatedAt: string // date-time confirmedAt?: string | null // date-time seatedAt?: string | null // date-time completedAt?: string | null // date-time cancelledAt?: string | null // date-time tabId?: string | null defaultTabStrategy: "single" | "per_resource" resources: Array<{ qrCodeId: string // uuid isPrimary: boolean position: integer }> userName?: string | null userEmail?: string | null userPhone?: string | null tableName?: string | null location?: { id?: number name?: string | null city?: string | null latitude?: number | null longitude?: number | null logoImageUrl?: string | null } | null attribution?: { firstTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } bookingTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } reportedSource?: string recordedAt: string // date-time } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/bookings/{id}/confirm Confirm booking Confirm a pending booking. Guest email/SMS is sent by default; pass `sendNotification: false` to suppress it. Booking webhooks and internal events are always emitted. **Permissions:** ⚪ `bookings.manage` Query parameters: - `sendNotification` (boolean, optional) — Whether to send the guest a confirmation by email and SMS. Defaults to true. This does not suppress booking webhooks or internal events. Path parameters: - `id` (string, required) — Booking ID Response 200 — Default Response ```ts { status: "success" data: { 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 } | null id: string // uuid locationId: number tableId?: string | null // uuid bookingDate: string // date startTime: string endTime: string timezone: string primaryUserId?: number | null guestCount: number occasion?: "none" | "birthday" | "anniversary" | "business" | "date" | "celebration" | "other" specialRequests?: string | null internalNotes?: string | null notes?: Array<{ uuid: string // uuid note: string sortOrder: number createdByRetailerUserId?: number | null updatedByRetailerUserId?: number | null createdAt: string // date-time updatedAt: string // date-time }> status: "pending" | "confirmed" | "checked_in" | "seated" | "completed" | "cancelled" | "no_show" paymentType?: "none" | "deposit" | "full_prepayment" depositAmountCents?: number | null purchaseId?: number | null confirmationCode: string bookingSource?: "direct" | "website" | "app" | "phone" | "walkin" | "third_party" estimatedRevenueCents?: number | null guestName?: string | null guestEmail?: string | null guestPhone?: string | null createdAt: string // date-time updatedAt: string // date-time confirmedAt?: string | null // date-time seatedAt?: string | null // date-time completedAt?: string | null // date-time cancelledAt?: string | null // date-time tabId?: string | null defaultTabStrategy: "single" | "per_resource" resources: Array<{ qrCodeId: string // uuid isPrimary: boolean position: integer }> userName?: string | null userEmail?: string | null userPhone?: string | null tableName?: string | null location?: { id?: number name?: string | null city?: string | null latitude?: number | null longitude?: number | null logoImageUrl?: string | null } | null attribution?: { firstTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } bookingTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } reportedSource?: string recordedAt: string // date-time } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/bookings/{id}/history Get booking history Get audit log/history for a booking **Permissions:** 🔵 `bookings.read` Path parameters: - `id` (string, required) — Booking ID Response 200 — Default Response ```ts { status: "success" data: Array<{ id: number bookingId: string // uuid eventType: string eventData: Record userId?: number | null retailerUserId?: number | null createdAt: string // date-time }> meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/bookings/{id}/members List booking members List members of a booking **Permissions:** 🔵 `bookings.read` Path parameters: - `id` (string, required) — Booking ID Response 200 — Default Response ```ts { status: "success" data: Array<{ id: string // uuid bookingId: string // uuid userId?: number | null isPrimary: boolean guestName?: string | null guestEmail?: string | null rsvpStatus?: "pending" | "accepted" | "declined" createdAt: string // date-time }> meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/bookings/{id}/members Add booking member Add a member to a booking **Permissions:** ⚪ `bookings.manage` Path parameters: - `id` (string, required) — Booking ID Request body: ```ts { userId?: number guestName?: string guestEmail?: string rsvpStatus?: "pending" | "accepted" | "declined" } ``` Response 201 — Default Response ```ts { status: "success" data: { id: string // uuid bookingId: string // uuid userId?: number | null isPrimary: boolean guestName?: string | null guestEmail?: string | null rsvpStatus?: "pending" | "accepted" | "declined" createdAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/bookings/{id}/members/{memberId} Remove booking member Remove a non-primary member from a booking **Permissions:** ⚪ `bookings.manage` Path parameters: - `id` (string, required) — Booking ID - `memberId` (string, required) — Member ID Response 200 — Default Response ```ts Record ``` ## PATCH /api/v1/bookings/{id}/members/{memberId} Update booking member Update a booking member RSVP status **Permissions:** ⚪ `bookings.manage` Path parameters: - `id` (string, required) — Booking ID - `memberId` (string, required) — Member ID Request body: ```ts { rsvpStatus: "pending" | "accepted" | "declined" } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid bookingId: string // uuid userId?: number | null isPrimary: boolean guestName?: string | null guestEmail?: string | null rsvpStatus?: "pending" | "accepted" | "declined" createdAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/bookings/{id}/no-show Mark no-show Mark a booking as no-show **Permissions:** ⚪ `bookings.manage` Path parameters: - `id` (string, required) — Booking ID Response 200 — Default Response ```ts { status: "success" data: { 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 } | null id: string // uuid locationId: number tableId?: string | null // uuid bookingDate: string // date startTime: string endTime: string timezone: string primaryUserId?: number | null guestCount: number occasion?: "none" | "birthday" | "anniversary" | "business" | "date" | "celebration" | "other" specialRequests?: string | null internalNotes?: string | null notes?: Array<{ uuid: string // uuid note: string sortOrder: number createdByRetailerUserId?: number | null updatedByRetailerUserId?: number | null createdAt: string // date-time updatedAt: string // date-time }> status: "pending" | "confirmed" | "checked_in" | "seated" | "completed" | "cancelled" | "no_show" paymentType?: "none" | "deposit" | "full_prepayment" depositAmountCents?: number | null purchaseId?: number | null confirmationCode: string bookingSource?: "direct" | "website" | "app" | "phone" | "walkin" | "third_party" estimatedRevenueCents?: number | null guestName?: string | null guestEmail?: string | null guestPhone?: string | null createdAt: string // date-time updatedAt: string // date-time confirmedAt?: string | null // date-time seatedAt?: string | null // date-time completedAt?: string | null // date-time cancelledAt?: string | null // date-time tabId?: string | null defaultTabStrategy: "single" | "per_resource" resources: Array<{ qrCodeId: string // uuid isPrimary: boolean position: integer }> userName?: string | null userEmail?: string | null userPhone?: string | null tableName?: string | null location?: { id?: number name?: string | null city?: string | null latitude?: number | null longitude?: number | null logoImageUrl?: string | null } | null attribution?: { firstTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } bookingTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } reportedSource?: string recordedAt: string // date-time } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/bookings/{id}/seat Seat booking Mark a booking as seated. Optionally create a linked tab for the booking. Retrying an already-applied seat operation returns the current booking with 200. A booking without a reserved table returns 409 NO_RESERVED_RESOURCE. **Permissions:** ⚪ `bookings.manage` Path parameters: - `id` (string, required) — Booking ID Request body: ```ts { tabId?: string // uuid createTab?: boolean } ``` Response 200 — Default Response ```ts { status: "success" data: { 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 } | null id?: string // uuid locationId?: number tableId?: string | null // uuid bookingDate?: string // date startTime?: string endTime?: string timezone?: string primaryUserId?: number | null guestCount?: number occasion?: "none" | "birthday" | "anniversary" | "business" | "date" | "celebration" | "other" specialRequests?: string | null internalNotes?: string | null notes?: Array<{ uuid: string // uuid note: string sortOrder: number createdByRetailerUserId?: number | null updatedByRetailerUserId?: number | null createdAt: string // date-time updatedAt: string // date-time }> status?: "pending" | "confirmed" | "checked_in" | "seated" | "completed" | "cancelled" | "no_show" paymentType?: "none" | "deposit" | "full_prepayment" depositAmountCents?: number | null purchaseId?: number | null confirmationCode?: string bookingSource?: "direct" | "website" | "app" | "phone" | "walkin" | "third_party" estimatedRevenueCents?: number | null guestName?: string | null guestEmail?: string | null guestPhone?: string | null createdAt?: string // date-time updatedAt?: string // date-time confirmedAt?: string | null // date-time seatedAt?: string | null // date-time completedAt?: string | null // date-time cancelledAt?: string | null // date-time tabId?: string | null defaultTabStrategy?: "single" | "per_resource" resources?: Array<{ qrCodeId: string // uuid isPrimary: boolean position: integer }> userName?: string | null userEmail?: string | null userPhone?: string | null tableName?: string | null location?: { id?: number name?: string | null city?: string | null latitude?: number | null longitude?: number | null logoImageUrl?: string | null } | null tab?: { id?: string // uuid tabMemberId?: string | null // uuid } | null attribution?: { firstTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } bookingTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } reportedSource?: string recordedAt: string // date-time } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/bookings/{id}/split Split booking into per-resource tabs Phase 40 R6 — convert a seated `single`-strategy multi-resource booking into per-resource tabs. Creates N-1 new tabs (one per non-primary resource that does not yet have its own tab). The existing primary tab and any items on it are preserved. `bookings.default_tab_strategy` is NOT mutated — it records the original choice. Idempotent: re-running only creates tabs for resources missing one. **Permissions:** ⚪ `bookings.manage` Path parameters: - `id` (string, required) — Booking ID Response 200 — Default Response ```ts { status: "success" data: { tabs: Array<{ id: string // uuid qrCodeId: string // uuid isPrimary: boolean created: boolean }> createdCount: integer } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/bookings/{id}/unseat Unseat booking Undo a seat operation. Reverts the booking to its prior status (checked_in or confirmed), puts the linked tab back to pre_arrival, and reverses all kitchen dispatches advanced by seating. Retrying after the booking is restored returns 200. Refuses if purchases exist, additional orders were placed, or any dispatch is past accepted. **Permissions:** ⚪ `bookings.manage` Path parameters: - `id` (string, required) — Booking ID Request body: ```ts { reason?: string } ``` Response 200 — Default Response ```ts { status: "success" data: { 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 } | null id: string // uuid locationId: number tableId?: string | null // uuid bookingDate: string // date startTime: string endTime: string timezone: string primaryUserId?: number | null guestCount: number occasion?: "none" | "birthday" | "anniversary" | "business" | "date" | "celebration" | "other" specialRequests?: string | null internalNotes?: string | null notes?: Array<{ uuid: string // uuid note: string sortOrder: number createdByRetailerUserId?: number | null updatedByRetailerUserId?: number | null createdAt: string // date-time updatedAt: string // date-time }> status: "pending" | "confirmed" | "checked_in" | "seated" | "completed" | "cancelled" | "no_show" paymentType?: "none" | "deposit" | "full_prepayment" depositAmountCents?: number | null purchaseId?: number | null confirmationCode: string bookingSource?: "direct" | "website" | "app" | "phone" | "walkin" | "third_party" estimatedRevenueCents?: number | null guestName?: string | null guestEmail?: string | null guestPhone?: string | null createdAt: string // date-time updatedAt: string // date-time confirmedAt?: string | null // date-time seatedAt?: string | null // date-time completedAt?: string | null // date-time cancelledAt?: string | null // date-time tabId?: string | null defaultTabStrategy: "single" | "per_resource" resources: Array<{ qrCodeId: string // uuid isPrimary: boolean position: integer }> userName?: string | null userEmail?: string | null userPhone?: string | null tableName?: string | null location?: { id?: number name?: string | null city?: string | null latitude?: number | null longitude?: number | null logoImageUrl?: string | null } | null attribution?: { firstTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } bookingTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } reportedSource?: string recordedAt: string // date-time } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/bookings/by-code/{code}/cancel Cancel booking by confirmation code Public endpoint: cancel a booking using confirmation code (no auth required) Path parameters: - `code` (string, required) — Booking confirmation code Request body: ```ts { reason?: string } ``` Response 200 — Default Response ```ts { status: "success" data: { 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 } | null id: string // uuid locationId: number tableId?: string | null // uuid bookingDate: string // date startTime: string endTime: string timezone: string primaryUserId?: number | null guestCount: number occasion?: "none" | "birthday" | "anniversary" | "business" | "date" | "celebration" | "other" specialRequests?: string | null internalNotes?: string | null notes?: Array<{ uuid: string // uuid note: string sortOrder: number createdByRetailerUserId?: number | null updatedByRetailerUserId?: number | null createdAt: string // date-time updatedAt: string // date-time }> status: "pending" | "confirmed" | "checked_in" | "seated" | "completed" | "cancelled" | "no_show" paymentType?: "none" | "deposit" | "full_prepayment" depositAmountCents?: number | null purchaseId?: number | null confirmationCode: string bookingSource?: "direct" | "website" | "app" | "phone" | "walkin" | "third_party" estimatedRevenueCents?: number | null guestName?: string | null guestEmail?: string | null guestPhone?: string | null createdAt: string // date-time updatedAt: string // date-time confirmedAt?: string | null // date-time seatedAt?: string | null // date-time completedAt?: string | null // date-time cancelledAt?: string | null // date-time tabId?: string | null defaultTabStrategy: "single" | "per_resource" resources: Array<{ qrCodeId: string // uuid isPrimary: boolean position: integer }> userName?: string | null userEmail?: string | null userPhone?: string | null tableName?: string | null location?: { id?: number name?: string | null city?: string | null latitude?: number | null longitude?: number | null logoImageUrl?: string | null } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/bookings/rsvp RSVP to booking Public endpoint: RSVP to a booking using confirmation code (no auth required) Request body: ```ts { confirmationCode: string guestName: string guestEmail?: string rsvpStatus: "pending" | "accepted" | "declined" } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid bookingId: string // uuid userId?: number | null isPrimary: boolean guestName?: string | null guestEmail?: string | null rsvpStatus?: "pending" | "accepted" | "declined" createdAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` Response 201 — Default Response ```ts { status: "success" data: { id: string // uuid bookingId: string // uuid userId?: number | null isPrimary: boolean guestName?: string | null guestEmail?: string | null rsvpStatus?: "pending" | "accepted" | "declined" createdAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/locations/{locationId}/booking-availability Get availability Get available booking slots for a date and party size **Permissions:** 🔵 `bookings.read` Query parameters: - `date` (string, required) — Date to check availability - `partySize` (number, required) — Number of guests - `bookingGroupId` (string, optional) — Optional booking group ID to filter availability by group schedule, table pool, and settings Path parameters: - `locationId` (number, required) — Location ID Response 200 — Default Response ```ts { status: "success" data: { date: string // date partySize: number slots: Array<{ id: string // uuid tableId?: string | null // uuid locationId: number slotDate: string // date startTime: string endTime: string durationMinutes: number maxCapacity: number currentBookings: number status: "available" | "partially_booked" | "fully_booked" | "blocked" predictedDemandScore?: number | null optimalPartySizeMin?: number | null optimalPartySizeMax?: number | null scheduleId?: string | null // uuid createdAt: string // date-time }> tables?: Array<{ id?: string name?: string minCapacity?: number maxCapacity?: number availableSlots?: unknown[] }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/locations/{locationId}/booking-availability-range Get availability across a date range Per-day availability status across a date range, for calendar rendering. Returns counts and a status per day rather than slot lists — fetch the single-date availability endpoint for the day the guest picks. **Permissions:** 🔵 `bookings.read` Query parameters: - `startDate` (string, required) — First date of the range (inclusive) - `endDate` (string, required) — Last date of the range (inclusive). Must not be more than 62 days after startDate. - `partySize` (number, required) — Number of guests Path parameters: - `locationId` (number, required) — Location ID Response 200 — Default Response ```ts { status: "success" data: { locationId: number partySize: number startDate: string // date endDate: string // date days: Array<{ date: string // date status: "available" | "limited" | "full" | "closed" availableSlotCount: number totalSlotCount: number firstAvailableTime: string | null }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/locations/{locationId}/bookings/by-table Get bookings by table Get bookings grouped by table for a specific date. Useful for table map display in POS. **Permissions:** 🔵 `bookings.read` Query parameters: - `date` (string, required) — Date to fetch bookings for (YYYY-MM-DD) Path parameters: - `locationId` (number, required) — Location ID Response 200 — Default Response ```ts { status: "success" data: { tables: Array<{ tableId: string // uuid tableName?: string | null bookings: unknown[] }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Capacity Settings Time slot capacity configuration and limits ## GET /api/v1/locations/{locationId}/capacity-settings List all capacity settings for a location Path parameters: - `locationId` (string, required) — Location ID Response 200 — Default Response ```ts { status?: "success" data?: { capacitySettings?: Array<{ id: number locationId: number orderCapacity: number | null itemCapacity: number | null name: string priority: number enabled: boolean timeStart: string | null timeEnd: string | null validFrom: string | null validTo: string | null validOnDays: unknown[] createdAt: string updatedAt: string }> } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/locations/{locationId}/capacity-settings Create a new capacity setting Path parameters: - `locationId` (string, required) — Location ID Request body: ```ts { orderCapacity?: number | null itemCapacity?: number | null name: string priority?: number enabled?: boolean timeStart?: string | null timeEnd?: string | null validFrom?: string | null validTo?: string | null validOnDays: number[] } ``` Response 201 — Default Response ```ts { status?: "success" data?: { capacitySetting?: { id: number locationId: number orderCapacity: number | null itemCapacity: number | null name: string priority: number enabled: boolean timeStart: string | null timeEnd: string | null validFrom: string | null validTo: string | null validOnDays: number[] createdAt: string updatedAt: string } } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/locations/{locationId}/capacity-settings/{id} Get a single capacity setting Path parameters: - `locationId` (string, required) — Location ID - `id` (string, required) — Capacity setting ID Response 200 — Default Response ```ts { status?: "success" data?: { capacitySetting?: { id: number locationId: number orderCapacity: number | null itemCapacity: number | null name: string priority: number enabled: boolean timeStart: string | null timeEnd: string | null validFrom: string | null validTo: string | null validOnDays: number[] createdAt: string updatedAt: string } } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/locations/{locationId}/capacity-settings/{id} Update an existing capacity setting Path parameters: - `locationId` (string, required) — Location ID - `id` (string, required) — Capacity setting ID Request body: ```ts { orderCapacity?: number | null itemCapacity?: number | null name?: string priority?: number enabled?: boolean timeStart?: string | null timeEnd?: string | null validFrom?: string | null validTo?: string | null validOnDays?: number[] } ``` Response 200 — Default Response ```ts { status?: "success" data?: { capacitySetting?: { id: number locationId: number orderCapacity: number | null itemCapacity: number | null name: string priority: number enabled: boolean timeStart: string | null timeEnd: string | null validFrom: string | null validTo: string | null validOnDays: number[] createdAt: string updatedAt: string } } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/locations/{locationId}/capacity-settings/{id} Delete a capacity setting Path parameters: - `locationId` (string, required) — Location ID - `id` (string, required) — Capacity setting ID Response 200 — Default Response ```ts { status?: "success" data?: { deleted?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/locations/{locationId}/capacity-settings/reorder Reorder capacity settings (update priorities) Path parameters: - `locationId` (string, required) — Location ID Request body: ```ts { orderedIds: number[] } ``` Response 200 — Default Response ```ts { status?: "success" data?: { capacitySettings?: Array<{ id: number locationId: number orderCapacity: number | null itemCapacity: number | null name: string priority: number enabled: boolean timeStart: string | null timeEnd: string | null validFrom: string | null validTo: string | null validOnDays: unknown[] createdAt: string updatedAt: string }> } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Booking cancelled Webhook event: `booking.cancelled` Fired when a booking is cancelled. Delivered payload: ```ts { id: string // uuid event: "booking.cancelled" resource: "booking" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null bookingId: string // uuid locationId: integer cancelledAt: string // date-time reason?: string } } ``` # Booking completed Webhook event: `booking.completed` Fired when a booking is marked complete. The full booking object is spread into `data`. Delivered payload: ```ts { id: string // uuid event: "booking.completed" resource: "booking" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { attribution?: { firstTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } bookingTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } reportedSource?: string recordedAt: string // date-time } | null customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null id: string // uuid locationId: integer tableId: string | null bookingDate: string startTime: string endTime: string timezone: string primaryUserId: integer | null participants?: { bookedForOther: boolean booker?: { name: string email: string phone?: string } } adultCount?: integer | null childCount?: integer | null guestCount: integer occasion: "none" | "birthday" | "anniversary" | "business" | "date" | "celebration" | "other" | null specialRequests: string | null internalNotes: string | null notes: Array<{ uuid: string // uuid note: string sortOrder: integer createdByRetailerUserId: … updatedByRetailerUserId: … createdAt: string // date-time updatedAt: string // date-time }> status: "pending" | "confirmed" | "checked_in" | "seated" | "completed" | "cancelled" | "no_show" paymentType: "none" | "deposit" | "full_prepayment" | null depositAmountCents: integer | null purchaseId: integer | null purchase?: { id: integer customFields?: { … }[] metadata?: Record | null } confirmationCode: string bookingSource: "direct" | "website" | "app" | "phone" | "walkin" | "third_party" | "waitlist" | null estimatedRevenueCents: integer | null resourceLocked: boolean defaultTabStrategy: "single" | "per_resource" resources: Record[] guestName?: string guestEmail?: string guestPhone?: string createdAt: string // date-time updatedAt: string // date-time confirmedAt: string | null seatedAt: string | null completedAt: string | null cancelledAt: string | null tabId: string | null } } ``` # Booking confirmed Webhook event: `booking.confirmed` Fired when a booking transitions to confirmed. The full updated booking is spread into `data`. Delivered payload: ```ts { id: string // uuid event: "booking.confirmed" resource: "booking" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { attribution?: { firstTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } bookingTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } reportedSource?: string recordedAt: string // date-time } | null customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null id: string // uuid locationId: integer tableId: string | null bookingDate: string startTime: string endTime: string timezone: string primaryUserId: integer | null participants?: { bookedForOther: boolean booker?: { name: string email: string phone?: string } } adultCount?: integer | null childCount?: integer | null guestCount: integer occasion: "none" | "birthday" | "anniversary" | "business" | "date" | "celebration" | "other" | null specialRequests: string | null internalNotes: string | null notes: Array<{ uuid: string // uuid note: string sortOrder: integer createdByRetailerUserId: … updatedByRetailerUserId: … createdAt: string // date-time updatedAt: string // date-time }> status: "pending" | "confirmed" | "checked_in" | "seated" | "completed" | "cancelled" | "no_show" paymentType: "none" | "deposit" | "full_prepayment" | null depositAmountCents: integer | null purchaseId: integer | null purchase?: { id: integer customFields?: { … }[] metadata?: Record | null } confirmationCode: string bookingSource: "direct" | "website" | "app" | "phone" | "walkin" | "third_party" | "waitlist" | null estimatedRevenueCents: integer | null resourceLocked: boolean defaultTabStrategy: "single" | "per_resource" resources: Record[] guestName?: string guestEmail?: string guestPhone?: string createdAt: string // date-time updatedAt: string // date-time confirmedAt: string | null seatedAt: string | null completedAt: string | null cancelledAt: string | null tabId: string | null } } ``` # Booking created Webhook event: `booking.created` Fired when a new booking is created. The full booking object is spread into `data`. Delivered payload: ```ts { id: string // uuid event: "booking.created" resource: "booking" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { attribution?: { firstTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } bookingTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } reportedSource?: string recordedAt: string // date-time } | null customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null id: string // uuid locationId: integer tableId: string | null bookingDate: string startTime: string endTime: string timezone: string primaryUserId: integer | null participants?: { bookedForOther: boolean booker?: { name: string email: string phone?: string } } adultCount?: integer | null childCount?: integer | null guestCount: integer occasion: "none" | "birthday" | "anniversary" | "business" | "date" | "celebration" | "other" | null specialRequests: string | null internalNotes: string | null notes: Array<{ uuid: string // uuid note: string sortOrder: integer createdByRetailerUserId: … updatedByRetailerUserId: … createdAt: string // date-time updatedAt: string // date-time }> status: "pending" | "confirmed" | "checked_in" | "seated" | "completed" | "cancelled" | "no_show" paymentType: "none" | "deposit" | "full_prepayment" | null depositAmountCents: integer | null purchaseId: integer | null purchase?: { id: integer customFields?: { … }[] metadata?: Record | null } confirmationCode: string bookingSource: "direct" | "website" | "app" | "phone" | "walkin" | "third_party" | "waitlist" | null estimatedRevenueCents: integer | null resourceLocked: boolean defaultTabStrategy: "single" | "per_resource" resources: Record[] guestName?: string guestEmail?: string guestPhone?: string createdAt: string // date-time updatedAt: string // date-time confirmedAt: string | null seatedAt: string | null completedAt: string | null cancelledAt: string | null tabId: string | null } } ``` # Booking marked no-show Webhook event: `booking.no_show` Fired when a booking is marked as no-show. The full booking is spread into `data`. Delivered payload: ```ts { id: string // uuid event: "booking.no_show" resource: "booking" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { attribution?: { firstTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } bookingTouch?: { capturedAt: string // date-time utmSource?: string utmMedium?: string utmCampaign?: string utmContent?: string utmTerm?: string referrer?: string landingPage?: string } reportedSource?: string recordedAt: string // date-time } | null customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null id: string // uuid locationId: integer tableId: string | null bookingDate: string startTime: string endTime: string timezone: string primaryUserId: integer | null participants?: { bookedForOther: boolean booker?: { name: string email: string phone?: string } } adultCount?: integer | null childCount?: integer | null guestCount: integer occasion: "none" | "birthday" | "anniversary" | "business" | "date" | "celebration" | "other" | null specialRequests: string | null internalNotes: string | null notes: Array<{ uuid: string // uuid note: string sortOrder: integer createdByRetailerUserId: … updatedByRetailerUserId: … createdAt: string // date-time updatedAt: string // date-time }> status: "pending" | "confirmed" | "checked_in" | "seated" | "completed" | "cancelled" | "no_show" paymentType: "none" | "deposit" | "full_prepayment" | null depositAmountCents: integer | null purchaseId: integer | null purchase?: { id: integer customFields?: { … }[] metadata?: Record | null } confirmationCode: string bookingSource: "direct" | "website" | "app" | "phone" | "walkin" | "third_party" | "waitlist" | null estimatedRevenueCents: integer | null resourceLocked: boolean defaultTabStrategy: "single" | "per_resource" resources: Record[] guestName?: string guestEmail?: string guestPhone?: string createdAt: string // date-time updatedAt: string // date-time confirmedAt: string | null seatedAt: string | null completedAt: string | null cancelledAt: string | null tabId: string | null } } ``` # Booking seated Webhook event: `booking.seated` Fired when guests are seated. Includes the full booking and an optional `tabId` if seating opened a tab. Delivered payload: ```ts { id: string // uuid event: "booking.seated" resource: "booking" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null id: string // uuid locationId: integer tabId?: string // uuid } } ``` # Booking unseated Webhook event: `booking.unseated` Fired when seating is undone. Lists all kitchen dispatches that were reversed (each will also fire `order.reversed`). Delivered payload: ```ts { id: string // uuid event: "booking.unseated" resource: "booking" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null id: string // uuid locationId: integer previousStatus: string restoredStatus: string tabId?: string // uuid reversedDispatchIds: string[] reason?: string unseatedAt: string // date-time } } ``` # Booking updated Webhook event: `booking.updated` Only the modified fields appear in `changes`. Delivered payload: ```ts { id: string // uuid event: "booking.updated" resource: "booking" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null bookingId: string // uuid locationId: integer changes: Record } } ``` # Waitlist entry converted to booking Webhook event: `waitlist.converted` Fired when a waitlist entry is converted into a booking. Links the entry to the new booking ID. Delivered payload: ```ts { id: string // uuid event: "waitlist.converted" resource: "waitlist" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null id: string // uuid locationId: integer convertedAt: string // date-time bookingId: string // uuid } } ``` # Waitlist entry joined Webhook event: `waitlist.joined` Fired when a guest joins the waitlist. The full waitlist entry is spread into `data`. Delivered payload: ```ts { id: string // uuid event: "waitlist.joined" resource: "waitlist" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null id: string // uuid locationId: integer userId: integer userName: string | null userEmail: string | null userPhone: string | null requestedDate: string preferredStartTime: string | null preferredEndTime: string | null partySize: integer status: "waiting" | "notified" | "converted" | "expired" | "removed" convertedBookingId: string | null expiresAt: string | null notifiedAt: string | null createdAt: string // date-time updatedAt: string // date-time } } ``` # Waitlist entry notified Webhook event: `waitlist.notified` Fired when a waitlist entry is notified that a table is available. Delivered payload: ```ts { id: string // uuid event: "waitlist.notified" resource: "waitlist" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null id: string // uuid locationId: integer userId: integer userName?: string | null userEmail?: string | null userPhone?: string | null requestedDate: string preferredStartTime: string | null preferredEndTime: string | null partySize: integer status: "waiting" | "notified" | "converted" | "expired" | "removed" convertedBookingId: string | null expiresAt: string | null notifiedAt: string | null createdAt: string // date-time updatedAt: string // date-time } } ``` # Waitlist entry removed Webhook event: `waitlist.removed` Fired when a waitlist entry is removed. Delivered payload: ```ts { id: string // uuid event: "waitlist.removed" resource: "waitlist" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null id: string // uuid locationId: integer removedAt: string // date-time } } ``` # Notes Notes attached to tabs and orders ## GET /api/v1/notes List notes for an entity List all notes attached to a specific entity (tab or user). Notes are returned in sort order (lowest sortOrder first). **Permissions:** 🔵 `notes.read` Query parameters: - `entityType` ("tab" | "user" | "booking", required) — Type of entity to fetch notes for - `entityId` (string, required) — ID of the entity (tab UUID or user ID) Response 200 — Default Response ```ts { status: "success" data: { notes: Array<{ uuid: string // uuid entityType: "tab" | "user" | "booking" 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 }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/notes Create a note Create a new note attached to an entity (tab or user). The note will be associated with the current location and the authenticated user (if using JWT auth). **Permissions:** 🟢 `notes.create` Request body: ```ts { entityType: "tab" | "user" | "booking" entityId: string note: string } ``` Response 201 — Default Response ```ts { status: "success" data: { uuid: string // uuid entityType: "tab" | "user" | "booking" 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 } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/notes/{uuid} Get a note Get a single note by UUID **Permissions:** 🔵 `notes.read` Path parameters: - `uuid` (string, required) — Note UUID Response 200 — Default Response ```ts { status: "success" data: { uuid: string // uuid entityType: "tab" | "user" | "booking" 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 } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/notes/{uuid} Delete a note Delete a note by UUID **Permissions:** 🔴 `notes.delete` Path parameters: - `uuid` (string, required) — Note UUID Response 200 — Default Response ```ts { status: "success" data: { deleted: true } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PATCH /api/v1/notes/{uuid} Update a note Update an existing note. The updated_by fields will be set to the authenticated user. **Permissions:** 🟠 `notes.update` Path parameters: - `uuid` (string, required) — Note UUID Request body: ```ts { note: string } ``` Response 200 — Default Response ```ts { status: "success" data: { uuid: string // uuid entityType: "tab" | "user" | "booking" 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 } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/notes/reorder Reorder notes Reorder notes for an entity. Pass an array of note UUIDs in the desired order. Each note will have its sortOrder updated to match its position in the array. **Permissions:** 🟠 `notes.update` Query parameters: - `entityType` ("tab" | "user" | "booking", required) — Type of entity to fetch notes for - `entityId` (string, required) — ID of the entity (tab UUID or user ID) Request body: ```ts { noteUuids: string[] } ``` Response 200 — Default Response ```ts { status: "success" data: { notes: Array<{ uuid: string // uuid entityType: "tab" | "user" | "booking" 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 }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Table Groups Table grouping and floor plans ## GET /api/v1/table-groups List table groups List table groups with optional filtering and sorting **Permissions:** 🔵 `table_groups.read` Query parameters: - `locationId` (number, optional) — Filter by location ID - `search` (string, optional) — Search by name or description - `sort` (string, optional) — Sort field (name, created_at) - `order` ("asc" | "desc", optional) — Sort order - `page` (number, optional) — Page number - `limit` (number, optional) — Items per page Response 200 — Default Response ```ts { status: "success" data: { tableGroups: Array<{ id: number name: string description?: string | null locationId: number tableIds: unknown[] createdAt: string // date-time updatedAt?: 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/table-groups Create table group Create a new table group **Permissions:** 🟢 `table_groups.create` Request body: ```ts { locationId: number name: string description?: string } ``` Response 201 — Default Response ```ts { status: "success" data: { id: number name: string description?: string | null locationId: number tableIds: string[] createdAt: string // date-time updatedAt?: string | null // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/table-groups/{id} Get table group Get a single table group by ID with table UUIDs **Permissions:** 🔵 `table_groups.read` Path parameters: - `id` (string, required) — Table group ID Response 200 — Default Response ```ts { status: "success" data: { id: number name: string description?: string | null locationId: number tableIds: string[] createdAt: string // date-time updatedAt?: string | null // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/table-groups/{id} Update table group Update an existing table group **Permissions:** 🟠 `table_groups.update` Path parameters: - `id` (string, required) — Table group ID Request body: ```ts { name?: string description?: string | null } ``` Response 200 — Default Response ```ts { status: "success" data: { id: number name: string description?: string | null locationId: number tableIds: string[] createdAt: string // date-time updatedAt?: string | null // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/table-groups/{id} Delete table group Delete a table group **Permissions:** 🔴 `table_groups.delete` Path parameters: - `id` (string, required) — Table group ID Response 200 — Table group successfully deleted ```ts { status?: "success" data?: { deleted?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Tables Restaurant table management ## GET /api/v1/locations/{locationId}/staff List staff for a location List retailer_users with access to a location. Powers the POS table-map Staff sidebar. Returns a minimal projection (id, name, color). **Permissions:** 🔵 `tables.read` Path parameters: - `locationId` (string, required) — Location ID Response 200 — Default Response ```ts { status: "success" data: { staff: Array<{ id: number name?: string | null color?: string | null }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/retailer-users/{retailerUserId}/color Update a staff member's color Set or clear the hex color used on the POS table-map staff badge. The target retailer_user must have access to the caller's active location. **Permissions:** 🟠 `tables.update` Path parameters: - `retailerUserId` (string, required) — Retailer user ID Request body: ```ts { color: string | null } ``` Response 200 — Default Response ```ts { status: "success" data: { id: number name?: string | null color?: string | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/tables List tables List tables with optional filtering **Permissions:** 🔵 `tables.read` Query parameters: - `locationId` (number, optional) — Filter by location ID - `status` ("live" | "paused" | "deleted", optional) — Filter by status - `tableGroupId` (number, optional) — Filter by table group ID - `search` (string, optional) — Search by name or description - `page` (number, optional) — Page number - `limit` (number, optional) — Items per page Response 200 — Default Response ```ts { status: "success" data: { tables: Array<{ customFields?: unknown[] metadata?: { … } | null id: string // uuid name: string description?: string | null contextParams: { … } locationId: number storefrontUrl?: string | null // uri tableGroupId?: number | null costCenterId?: number | null status: "live" | "paused" | "deleted" qrCodeImageUrl?: string | null // uri activeTabs: unknown[] totalOrderAmountOnActiveTables?: number totalPaidAmountOnActiveTables?: number isBookable: boolean minCapacity?: number | null maxCapacity?: number | null seatingDurationMinutes?: number | null serviceFeeOverride?: { … } | null newTabPerGuest?: boolean requireGuestCount?: boolean assignedRetailerUser?: { … } | null externalId?: string | null createdAt: string // date-time updatedAt?: 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/tables Create table Create a new table **Permissions:** 🟢 `tables.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 { name: string description?: string locationId: number tableGroupId?: number costCenterId?: number | null contextParams?: Record customFields?: unknown | unknown[] } ``` Response 201 — Default Response ```ts { status: "success" data: { 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 } | null id: string // uuid name: string description?: string | null contextParams: Record locationId: number storefrontUrl?: string | null // uri tableGroupId?: number | null costCenterId?: number | null status: "live" | "paused" | "deleted" qrCodeImageUrl?: string | null // uri activeTabs: Array<{ customFields?: unknown[] metadata?: { … } | null id: string state: string orderTotal?: number paidTotal?: number tipsTotal?: number selectedPaymentFlow?: string | null coursingOrder?: number | null memberCount?: number totalAmountCents?: number paidAmountCents?: number remainingBalanceCents?: number paymentMode?: "ITEM" | "AMOUNT" storefrontQrCodeId?: string | null // uuid createdAt?: string // date-time updatedAt?: string | null // date-time extras?: unknown[] kitchenDispatches?: unknown[] notes?: unknown[] showNotesOnMap?: boolean tableDisplayName?: string | null preCheckPrintedAt?: string | null // date-time }> totalOrderAmountOnActiveTables?: number totalPaidAmountOnActiveTables?: number isBookable: boolean minCapacity?: number | null maxCapacity?: number | null seatingDurationMinutes?: number | null serviceFeeOverride?: { enabled?: boolean title?: string | null description?: string | null type?: "fixed" | "percentage" fixedCents?: number | null percentageBps?: number | null vatId?: number | null } | null newTabPerGuest?: boolean requireGuestCount?: boolean assignedRetailerUser?: { id: number name?: string | null color?: string | null } | null externalId?: string | null createdAt: string // date-time updatedAt?: string | null // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/tables/{id} Get table details Get a single table by ID with active tabs **Permissions:** 🔵 `tables.read` Path parameters: - `id` (string, required) — Table UUID Response 200 — Default Response ```ts { status: "success" data: { 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 } | null id: string // uuid name: string description?: string | null contextParams: Record locationId: number storefrontUrl?: string | null // uri tableGroupId?: number | null costCenterId?: number | null status: "live" | "paused" | "deleted" qrCodeImageUrl?: string | null // uri activeTabs: Array<{ customFields?: unknown[] metadata?: { … } | null id: string state: string orderTotal?: number paidTotal?: number tipsTotal?: number selectedPaymentFlow?: string | null coursingOrder?: number | null memberCount?: number totalAmountCents?: number paidAmountCents?: number remainingBalanceCents?: number paymentMode?: "ITEM" | "AMOUNT" storefrontQrCodeId?: string | null // uuid createdAt?: string // date-time updatedAt?: string | null // date-time extras?: unknown[] kitchenDispatches?: unknown[] notes?: unknown[] showNotesOnMap?: boolean tableDisplayName?: string | null preCheckPrintedAt?: string | null // date-time }> totalOrderAmountOnActiveTables?: number totalPaidAmountOnActiveTables?: number isBookable: boolean minCapacity?: number | null maxCapacity?: number | null seatingDurationMinutes?: number | null serviceFeeOverride?: { enabled?: boolean title?: string | null description?: string | null type?: "fixed" | "percentage" fixedCents?: number | null percentageBps?: number | null vatId?: number | null } | null newTabPerGuest?: boolean requireGuestCount?: boolean assignedRetailerUser?: { id: number name?: string | null color?: string | null } | null externalId?: string | null createdAt: string // date-time updatedAt?: string | null // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/tables/{id} Update table Update an existing table **Permissions:** 🟠 `tables.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) — Table UUID Request body: ```ts { name?: string description?: string | null tableGroupId?: number | null costCenterId?: number | null status?: "live" | "paused" | "deleted" contextParams?: Record isBookable?: boolean minCapacity?: number | null maxCapacity?: number | null seatingDurationMinutes?: number | null serviceFeeOverride?: { enabled?: boolean title?: string | null description?: string | null type?: "fixed" | "percentage" fixedCents?: number | null percentageBps?: number | null vatId?: number | null } | null newTabPerGuest?: boolean requireGuestCount?: boolean assignedRetailerUserId?: number | null externalId?: string | null customFields?: unknown | unknown[] } ``` Response 200 — Default Response ```ts { status: "success" data: { 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 } | null id: string // uuid name: string description?: string | null contextParams: Record locationId: number storefrontUrl?: string | null // uri tableGroupId?: number | null costCenterId?: number | null status: "live" | "paused" | "deleted" qrCodeImageUrl?: string | null // uri activeTabs: Array<{ customFields?: unknown[] metadata?: { … } | null id: string state: string orderTotal?: number paidTotal?: number tipsTotal?: number selectedPaymentFlow?: string | null coursingOrder?: number | null memberCount?: number totalAmountCents?: number paidAmountCents?: number remainingBalanceCents?: number paymentMode?: "ITEM" | "AMOUNT" storefrontQrCodeId?: string | null // uuid createdAt?: string // date-time updatedAt?: string | null // date-time extras?: unknown[] kitchenDispatches?: unknown[] notes?: unknown[] showNotesOnMap?: boolean tableDisplayName?: string | null preCheckPrintedAt?: string | null // date-time }> totalOrderAmountOnActiveTables?: number totalPaidAmountOnActiveTables?: number isBookable: boolean minCapacity?: number | null maxCapacity?: number | null seatingDurationMinutes?: number | null serviceFeeOverride?: { enabled?: boolean title?: string | null description?: string | null type?: "fixed" | "percentage" fixedCents?: number | null percentageBps?: number | null vatId?: number | null } | null newTabPerGuest?: boolean requireGuestCount?: boolean assignedRetailerUser?: { id: number name?: string | null color?: string | null } | null externalId?: string | null createdAt: string // date-time updatedAt?: string | null // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/tables/{id} Delete table Delete a table (soft delete - sets status to deleted) **Permissions:** 🔴 `tables.delete` Path parameters: - `id` (string, required) — Table UUID Response 200 — Table successfully deleted ```ts { status?: "success" data?: { deleted?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/tables/{id}/tab-history Get table tab history Lists tabs currently or previously associated with a table, including move/merge lineage and the staff actor. **Permissions:** 🔵 `tables.read` Query parameters: - `page` (integer, optional) - `limit` (integer, optional) Path parameters: - `id` (string, required) Response 200 — Table tab history ```ts { status: "success" data: { tabs: Array<{ uuid: string // uuid locationId: number latestState: string selectedPaymentFlow: string | null paymentMode: string totalAmountCents: number paidAmountCents: number remainingBalanceCents: number storefrontQrCodeId: string | null createdAt: string // date-time updatedAt: string // date-time association: { … } }> pagination: { page: number limit: number total: number totalPages: number } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/tables/bulk Bulk update tables Update multiple tables in a single request. Apply the same changes to all specified tables. **Permissions:** 🟠 `tables.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). Request body: ```ts { tableIds: string[] tableGroupId?: number | null name?: string description?: string costCenterId?: number | null contextParamsToMerge?: Record contextParamsToDelete?: string[] status?: "live" | "paused" | "deleted" isBookable?: boolean minCapacity?: number | null maxCapacity?: number | null seatingDurationMinutes?: number | null serviceFeeOverride?: { enabled?: boolean title?: string | null description?: string | null type?: "fixed" | "percentage" fixedCents?: number | null percentageBps?: number | null vatId?: number | null } | null newTabPerGuest?: boolean requireGuestCount?: boolean assignedRetailerUserId?: number | null externalId?: string | null customFields?: unknown | unknown[] } ``` Response 200 — Tables updated successfully ```ts { status?: "success" data?: Array<{ customFields?: { … }[] metadata?: { customFields?: { … } } | null id: string // uuid name: string description?: string | null contextParams: Record locationId: number storefrontUrl?: string | null // uri tableGroupId?: number | null costCenterId?: number | null status: "live" | "paused" | "deleted" qrCodeImageUrl?: string | null // uri activeTabs: { … }[] totalOrderAmountOnActiveTables?: number totalPaidAmountOnActiveTables?: number isBookable: boolean minCapacity?: number | null maxCapacity?: number | null seatingDurationMinutes?: number | null serviceFeeOverride?: { enabled?: boolean title?: string | null description?: string | null type?: "fixed" | "percentage" fixedCents?: number | null percentageBps?: number | null vatId?: number | null } | null newTabPerGuest?: boolean requireGuestCount?: boolean assignedRetailerUser?: { id: number name?: string | null color?: string | null } | null externalId?: string | null createdAt: string // date-time updatedAt?: string | null // date-time }> meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/tables/bulk Bulk delete tables Soft delete multiple tables from one location in a single request. Retrying the same request is safe. **Permissions:** 🔴 `tables.delete` Request body: ```ts { tableIds: string[] } ``` Response 200 — Tables successfully deleted ```ts { status: "success" data: { ids: string[] count: number deleted: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Time Slots Settings Location configuration for order time slots ## GET /api/v1/locations/{locationId}/time-slots-settings Get time-slots settings Get the location's time-slots base settings (currently just `timeSlotInMinutes`). **Permissions:** 🔵 `settings.read` Path parameters: - `locationId` (string, required) — Location ID Response 200 — Default Response ```ts { status: "success" data: { locationId: number timeSlotInMinutes: number } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/locations/{locationId}/time-slots-settings Update time-slots settings Update the location's time-slots base settings. **Permissions:** 🟠 `settings.update` Path parameters: - `locationId` (string, required) — Location ID Request body: ```ts { timeSlotInMinutes: number } ``` Response 200 — Default Response ```ts { status: "success" data: { locationId: number timeSlotInMinutes: number } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Time Slots Available time slots for orders and bookings ## GET /api/v1/locations/{locationId}/time-slots/{date} Get time slots for a location on a specific date Generates available time slots for pickup/ordering at a location on a specific date. Time slots are generated based on: - The location's opening hours from the schedules system - The location's configured time slot duration (e.g., 10 or 15 minutes) - Any exceptions (holidays, special closures) - When `menuLayoutIds` is given, the schedules of those menu layouts: a slot is kept only if every scheduled layout is open at both its start and its end If the location is closed on the requested date (either by regular hours or exception), an empty time slots array is returned. **Note:** This endpoint requires API key authentication. Query parameters: - `menuLayoutIds` (string, optional) — Comma-separated menu layout ids (root layouts and nested groups) the cart was ordered from. Only slots inside the schedules of all of them are returned; ids that are not layout ids are ignored. Path parameters: - `locationId` (string, required) — Location ID - `date` (string, required) — Date in YYYY-MM-DD format Response 200 — Time slots retrieved successfully ```ts { status?: "success" data?: { timeSlots: Array<{ startsAt: string // date-time endsAt: string // date-time capacities?: { … } | null }> } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Booking attribution (/docs/booking-attribution) Booking attribution is an optional built-in `attribution` object, available to every venue without custom-field entitlements. It is recorded atomically when a booking is created and remains unchanged when the booking is edited, seated, cancelled or completed. Historical bookings have `attribution: null`; Karma does not reconstruct past marketing journeys. ## Channel versus marketing source [#channel-versus-marketing-source] `bookingSource` describes how the reservation was made (`website`, `app`, `phone`, `walkin`, etc.). A purchase/order channel such as `QR` or `POS` describes how an order was placed. Neither identifies the original marketing source. `attribution.firstTouch` records the earliest observed entry in the current venue-scoped session. `attribution.bookingTouch` records the most recent observed campaign or external referral before booking; it is not necessarily the booking page itself. Untagged internal navigation does not replace acquisition. `reportedSource` is an optional source explicitly reported by a guest or staff member. Missing UTMs do not prove a direct visit: a referrer can be unavailable. ## Capturing attribution [#capturing-attribution] The Karma storefront automatically captures UTM parameters and external referrers before authentication. Venue-scoped session storage preserves the first touch through login and booking steps, with a maximum age of 24 hours; blocked storage falls back to memory. The Karma app captures venue-specific campaign deep links within the app session. No tracking identifier or cross-session browsing history is created. A custom website or API integration must collect attribution itself and send it with `POST /api/v1/bookings`: ```json { "locationId": 17099, "bookingDate": "2026-11-01", "startTime": "18:00:00", "endTime": "19:30:00", "timezone": "Europe/Stockholm", "guestCount": 2, "bookingSource": "website", "attribution": { "firstTouch": { "capturedAt": "2026-10-11T12:00:00Z", "utmSource": "instagram", "utmMedium": "social", "utmCampaign": "autumn", "landingPage": "https://gp.example/menu" }, "bookingTouch": { "capturedAt": "2026-10-11T13:00:00Z", "utmSource": "google", "utmMedium": "cpc", "utmCampaign": "dinner", "referrer": "https://www.google.com/", "landingPage": "https://gp.example/book" } } } ``` Touches support `utmSource`, `utmMedium`, `utmCampaign`, `utmContent`, `utmTerm`, `referrer` and `landingPage`, plus the required ISO `capturedAt`. Campaign and reported-source strings are limited to 500 characters. Karma strips URL queries, fragments and credentials and adds its own `recordedAt` timestamp. These observations are client-reported, not independently verified evidence of an ad click. Record only values your website actually observed. The creation permission is sufficient to submit attribution; no generic metadata update or custom-field write is needed. A separate read-only analytics key can read it using normal booking-read permissions. Public confirmation-code lookups omit attribution. ## Embedding the booking flow [#embedding-the-booking-flow] Use the embed script hosted by the same Karma storefront environment as your booking URL: ```html ``` The script inserts the booking iframe after itself and passes the merchant page's session-scoped attribution to the frame. The bridge checks both message origin and sender. This matters because an iframe cannot read its cross-origin parent page's campaign parameters. A bare iframe only observes its own URL and browser-provided referrer; use this script or include UTMs explicitly in the iframe URL. To preserve discovery before the booking page, install the same script on your landing/menu pages in attribution-only mode, with the same booking URL: ```html ``` This captures the session without displaying an iframe. On the booking page, use the embed snippet without `data-karma-attribution-only`. Capture starts on pages where the script runs; Karma cannot see uninstrumented third-party pages. ## Walk-ins and phone bookings [#walk-ins-and-phone-bookings] Staff can enter “How did the guest hear about you?” when creating a booking. API integrations can send `attribution: { "reportedSource": "A friend" }`. An unknown source remains unknown. QR orders without an associated booking do not gain booking attribution automatically. ## Reading the journey and revenue [#reading-the-journey-and-revenue] Merchant booking lists show the marketing source below the booking channel. Booking details show first discovery, booking touch, reported source and expandable campaign details. Normal merchant/API booking list and detail responses include `attribution`; webhooks carrying a full booking include the same snapshot. Link tabs using their `bookingId`, then purchases using their `tabId`. Include all tabs for a multi-resource booking and confirmed payments, subtract confirmed refunds, and avoid double-counting deposits or split payments. A booking's `tabId` can be absent once its tab is closed; retain the tab-to-booking relationship rather than relying on that active-tab convenience field. Estimated revenue is not actual paid revenue. See [Reading sales data](/docs/sales-data). ## Deployment [#deployment] Requires database migration `20261011230000_booking_marketing_attribution.ts` from the central database repository before deploying the updated common API. Client capture alone does not enable storage in an environment running the older API. # Changelog (/docs/changelog) Changes that affect you as an integrator — new endpoints and events, changed payloads, deprecations, and notable documentation updates. Newest first. Additive changes (new endpoints, new optional fields, new event types) ship continuously and are listed here. Anything breaking is announced ahead of time and carries a new API version — your existing integration keeps working. BigQuery customer-view changes have a [separate changelog](/docs/bigquery-changelog) and [integration guide](/docs/bigquery). ## 2026-10-20 — Development preview: member identifiers, voucher rules and receipt names [#2026-10-20--development-preview-member-identifiers-voucher-rules-and-receipt-names] Rechecked the development branch through `ab8abdd094`, including 61 non-merge commits after the previous October 19 source audit. These are source-branch changes, not a production deployment announcement. Required migrations, location capabilities and permissions still apply. Earlier generally applicable release coverage remains in place. * **Loyalty identifiers and imports:** programs can configure identifier ordering, scan patterns and searchable/scannable values through `GET`/`PUT`/`POST /api/v1/loyalty/programs/{id}/identifiers` and `PATCH`/`DELETE` on an individual identifier. `POST /api/v1/loyalty/members/resolve` resolves configured codes. Defaults remain Karma card ID, email and phone; a unique guest text field can identify an imported card without email or phone. CSV imports map tiers and unique guest fields, reject identifiers or contacts claimed by different people, and only fill missing contact details on eligible program-owned contactless guests. Hidden programs remain outside guest discovery and self-enrolment; existing membership and staff/integration management are distinct access paths. * **Per-line voucher rates and venue eligibility:** `rule_lines` defines ordered percentage rules with `amountInBasisPoints`, optional included/excluded tags, inclusive charged-unit-price bounds, alcohol conditions and `locationIds`. The first matching rule wins; unmatched lines use the default rate. Venue conditions match the selected location and descendants. Separate `INCLUDE_LOCATION`/`EXCLUDE_LOCATION` restrictions decide whether the voucher is valid at all. Existing item restrictions and maximum discount still apply. Validation carries each line's rate through reservations, purchase confirmation and partial-payment allocation; do not flatten a mixed-rate discount into one percentage. * **Receipt-specific names:** item writes and bulk upserts accept optional `receiptTitle`; variants accept it through variant-group writes. Completed purchase lines retain their receipt text, while internal titles remain available for reports and invoices. Guest, terminal, printed and refund receipts use the receipt name where present. Blank values fall back to the normal name; omitting a variant's field preserves its existing value. This requires the source receipt-title migration and does not establish a new BigQuery customer-view field. * **Custom fields and inventory:** variant groups support custom fields in their contracts and management UI. The existing item editor adds clearer media controls and a menu/template-usage view. Individual and shared prices are distinguished, with an explicit first-shared-price creation path. * **POS and kitchen follow-ups:** product tiles can preset Ambulance or Supplement; unsent remakes begin in the immediate course. Ambulance badges/cards are pink and supplements violet. Remakes stay separate from ordinary history lines and guest receipts omit supplements. Automatic first-course sending no longer releases later waiting courses; staff get an explicit waiting-course action. Add again repeats one committed item with current menu pricing and preserved options/notes, subject to item and cafe-mode limits. Menu/template selection is searchable; Restart confirms cart/payment risk. The active remake chip uses the matching colour and icon, and the mandatory print-template block is labelled Remake band for both kinds. Printer Flow warns when direct location printing bypasses preparation-group flows. * **Billing and invoice controls:** daily AI request/token/cost views distinguish recorded costs, labelled historical estimates, allowances, prepaid tokens and estimated overage. Effective-date token rates preserve earlier usage prices and leave non-token meters on their existing path. Gross request costs are not invoice totals. Invoice settings group POS sending, delivery schedules, inheritance and sharing; timelines show sent times and new-recipient defaults do not rewrite existing recipients. * **Payment, reporting and settings fixes:** guest checkout pays the full remaining split balance after a split change; stale card refusals after a verified capture are acknowledged consistently. Standalone-tip capture fees are finalised, and payment-provider alerts carry clearer state/recovery diagnostics. Caspeco retains staff attribution for guest payments. Analytics PDFs show configured organisation numbers, and location/settings forms restore saved values and use consistent floating save controls. ## 2026-10-20 — Custom voucher providers withdrawn [#2026-10-20--custom-voucher-providers-withdrawn] * **No custom voucher provider.** A location can no longer forward voucher codes to its own HTTPS endpoints, and the custom voucher provider guide is gone. Vouchers are Karma's own; stored value is a gift card. * **Removed endpoints.** `GET` and `PUT /api/v1/locations/{locationId}/voucher-provider` and `POST /api/v1/locations/{locationId}/voucher-provider/test`. * **Vouchers hold no balance.** Creating or updating a voucher with a balance (`GIFT_CARD` that is not a template, `balanceMode: DECREMENTAL` or a `balance` type) returns 400. Gift card templates are unchanged. Validation and reservation responses no longer include `balanceDeductedCents`. ## 2026-10-19 — Upcoming release: changes on dev [#2026-10-19--upcoming-release-changes-on-dev] The changes below are on `dev` but are not yet included in the production branch, `main`, as checked on October 19. Equivalent cherry-picks already on `main` are excluded from this list. This is a release preview, not a production deployment announcement. Feature flags, permissions and required database migrations still apply. This preview groups the generally applicable changes reviewed on October 19. ### Inventory, ingredients and prices [#inventory-ingredients-and-prices] * **Inventory editor and history** — the new item editor brings Essentials, classification, composition, pricing and previews together, with clearer completeness guidance, reviewed AI enhancements and protection for unsaved drafts. Item configuration history supports listing and reading immutable revisions and restoring an earlier configuration as a new revision: `GET /api/v1/inventory/{id}/versions`, `GET /api/v1/inventory/{id}/versions/{versionId}` and `POST /api/v1/inventory/{id}/versions/{versionId}/restore`. Restore requires `expectedVersionId`; conflicting edits return `409`. History starts with the first captured baseline, excludes stock-only changes, and cannot restore inherited or integration-managed items. * **Optional ingredient management** — a capability-controlled ingredient and preparation library adds recipes, purchase-pack/yield costing, optional nutrition, canonical allergen review, CSV import and affected-dish availability controls. Management routes are under `/api/v1/locations/{locationId}/ingredients`; published allergen information is read through `GET /api/v1/locations/{locationId}/items/{itemId}/food-information`. Publishing requires a saved, reviewed recipe; unpublished information is `null`, and changed dependencies require review. Calculated costs remain incomplete when required inputs are missing. * **Reviewed stocktakes** — the optional new inventory workflow supports item and ingredient counts through `GET`/`POST /api/v1/locations/{locationId}/stocktakes`. Counts are reviewed before confirmation; stale stock or definition tokens reject the write, and retrying the same submission does not apply it twice. Ingredient management and the new inventory workflow are enabled separately. * **Classification and accounts** — category settings, classification presets and explicit item account overrides are available in the editor. Purchase cost can be calculated from the ingredient recipe. Existing item-type strings, legacy tags and the documented stock limits remain distinct contracts; the [inventory guide](/docs/inventory-items) describes the write fields and limits. * **Consistent marketplace pricing** — checkout and Wolt, Foodora and Uber Eats menu publication share the item-pricing implementation, including nearest-location overrides, shared/cluster prices, channel rules and linked-choice prices. Marketplace publication treats scheduled rules as inactive and skips items without a resolvable channel price. Explicit zero-priced choices remain free; negative choices deduct their amount. This change introduces no public price-resolution endpoint. * **Marketplace menu coverage** — Foodora and Uber Eats accept inherited menus and roots typed `menu`; Wolt no longer publishes a sibling store's channel assignment. Publication excludes inactive options while retaining required option groups hidden from normal menu navigation. API-synchronized menus can be reordered in the backoffice QR view. ### Ordering, payments and exports [#ordering-payments-and-exports] * **Pickup availability** — available times follow the actual dishes in the cart, including inherited menu placements and breaks within a pickup slot. Order creation applies the same check. Checkout recovers when a slot becomes unavailable, moves past days with no remaining valid times, and excludes digital or shipped items from pickup constraints. A failed schedule lookup returns an error rather than offering unchecked times. * **Payment recovery** — late authorizations and provider failures are reconciled against verified provider state, checkout cancellation checks are scoped to the payment, and transient Swish initiation is retried only after absence of an existing payment has been verified. Storefront wallet checkout can recover on resume. Saved-card checkout uses the merchant account's CVC capability instead of always requiring CVC. * **Historical refunds and Save fees** — eligible historical purchases can use the refund flow, and Save refunds include the refundable service-fee portion. Purchase responses retain `appliedFees` and `refunds`, including `feeType` and `serviceFeeRefundAmountCents` where applicable. These response fields do not establish new BigQuery customer-view columns. * **Caspeco refund corrections** — confirmed refunds whose original sales have been exported are sent as correction tickets dated on the refund's day, with the original receipt reference. Corrections are enabled by default; `refundCorrectionsFrom` can restrict the refund-date range or be set to `off`. Accepted exports are deduplicated, failed ones can retry, concurrent deliveries are guarded, and tip-only corrections report zero guests. Existing sales days are not overwritten. * **Invoice corrections** — corrected invoices are fully credited and replaced. Billing supports token allowances, applies metered units to usage estimates, and reconciles card-paid invoices with Fortnox. Locations can hide or temporarily mute the late-payment banner. * **Storefront ratings and confirmation content** — locations can turn off Karma post-order ratings through `storefrontRatingsEnabled`; ratings stay enabled unless explicitly disabled. The confirmation editor adds headings, bold text, lists, images and feedback links, with previews of storefront buttons and kiosk QR codes. Existing Markdown remains editable. * **Receipt voucher privacy** — printed and emailed receipts use configured public discount names or a generic discount label rather than exposing redemption codes in line labels. Voucher codes shown in receipt payment summaries are masked; this changes receipt presentation, not stored sales amounts. * **BigQuery price-cluster views** — the October 19 development/staging sync is recorded separately in the [BigQuery changelog](/docs/bigquery-changelog): the checked customer datasets expose four additional views, bringing their inventories to 449 columns across 30 views. This does not establish production customer-view availability. * **Table-group reporting** — the breakdown distinguishes tables without a group from over-the-counter sales without a table; the reporting response includes `hasTable`. ### POS, kitchen and backoffice [#pos-kitchen-and-backoffice] * **Ambulance tickets and house-paid supplements (supplemang)** — staff can mark a line as a charged remake or a house-paid supplement. The server forces supplements and their children to zero price; they stay visible to staff and are omitted from guest tabs and receipts. Remakes receive separate kitchen tickets and KDS cards. Order input uses `ambulance_kind` (`charged` or `supplement`); returned line data exposes `ambulanceKind`. A remake cannot also be a zero ticket. Printing provides separate `ambulance_ticket` and `supplement_ticket` templates with the `ambulance_banner` block. The follow-up fixes duplicate slips caused by printer-less preparation groups and removes redundant per-line banners on an already framed remake ticket. Both KDS boards highlight remakes; order history and purchase details badge them, and purchase lines expose nullable `ambulanceKind`. Both remake templates support buzzers, and printer flows can route ordinary, ambulance and supplement tickets separately through `ticket_type_filter`. This feature and the seeded buzzer-template update require their central database migrations. * **Coursing and tab lines** — reset coursing from the course picker or a menu action to hold a late guest's course. Devices can hide the next-course button on wide screens and choose tab-line ordering, with kitchen-ticket order as the default. Lines without a kitchen sort order have a configurable order. Quantity-based line splitting supports as many parts as there are units. * **Course counts and menu colors** — each unsent course header shows the summed portion quantity still to send. Menu builders can select multiple grid tiles or action-bar items and apply a level color, custom color or reset to the product color in one update/autosave. The Change color control sits in the builder palette; detached layout references are recolored on the layout itself. * **Finding tabs and navigating menus** — filter tables to those with open tabs and counter tabs by takeaway, retain counter-tab filters and My sales across reloads, and show clearer empty states and guest counts. The quick menu becomes a touch-sized dialog with clearer tiles and a restart action. Reusable templates honor return-to-root, action types have descriptions, and product text, quick-pay titles, breadcrumbs and action pickers fit their controls. * **Device settings** — station synchronization covers the POS settings exposed in Devices, including PIN on open, grid text size, counter-tab filters and the tab panel's starting view. Devices can hide prices on product tiles. The send-message dialog supports a configured station list and a searchable multi-select picker; table-map and table-list controls have larger touch targets. * **Kitchen printing** — tickets can sort items by descending quantity, show takeaway alongside table details, and show secondary preparation settings only for kitchen tickets. * **Backoffice polish** — dialog dropdowns scroll and stay interactive above the form; the new-version notification sits at the top center with larger actions and responsive wrapping, and loading uses animated KARMA letters. Surplus timelines use the venue timezone. CRM notes and activities show their full text, and shared-photo caption choices and previews are clearer. * **Staff management permissions** — account-wide edits, PIN changes, deactivation and password resets require authority over all affected locations; removing location access checks that location. Except for self-service and Karma staff rules, callers must hold all the target's permissions plus additional authority. `GET /api/v1/retailer/users/{id}` returns `canManage` so clients can reflect the server decision. * **Maintenance** — the pricing implementation moves into a shared package, deprecated request-ID logging configuration and an obsolete Dependabot schedule are removed, and regression tests cover late terminal authorization cancellation. Formatting and editor refactors are included in the behavior summaries above. ## 2026-10-12–18 — Updates on the production branch [#2026-10-1218--updates-on-the-production-branch] This roundup covers direct commits and cherry-picks recorded on `main` during October 12–18, using Europe/Stockholm dates. It includes production-only fixes that would be missed by a `dev` comparison. Branch inclusion does not by itself verify a running deployment. * **October 17 — POS touch quantities:** a touch or pen tap opening a quantity dialog no longer also presses the keypad beneath the finger. This preserves the product's default portion. The fix was cherry-picked to `main`. * **October 15 — Saved-card capability:** the common API exposes the account-specific CVC capability. The October 12 app fix restored CVC where required by live Adyen; the pending checkout change above consumes the capability to skip it only for enabled accounts. * **October 12–15 — Commercial Team Pulse and progression:** live team slides use authoritative daily data and durable events, include support/onboarding, active venues and per-person activity, and rotate the full leaderboard. Signing and won-back celebrations use verified events and support display-only replay; won-back celebrations name the owner. Revenue charts stop at the current time. Revenue XP includes verified Swish transactions checked against the canonical intent, attributes sole active contributors, deduplicates commitment awards, adjusts original sellers after pricing changes, excludes unanswered calls, and uses a Stockholm daily activity limit. Portfolio cards show transaction XP. * **October 13 — Payments and POS:** verified late-authorization cancellations are reconciled; manually applied vouchers no longer carry into the next order. The printed HOLD banner fills the ticket width. * **October 12 — Combined reporting:** combined SIE/CSV/PDF exports, Fortnox vouchers and purchase summaries use each included location's own calendar/business-day setting. The exporting location follows the caller's setting; children follow their saved setting, defaulting to calendar day. Compare combined totals with the sum of those per-location windows. * **October 12 — Refund labels, inheritance and billing:** refund receipts print translated reason labels, including Swedish “Annat”; inherited course names and nearest-parent prices resolve correctly. Inherited users are excluded from billable seats, and Workspace activation explains prerequisites and missing migrations. * **October 12 — Kaia and scheduled articles:** stock reports respect filters and resolve serving courses; scheduled MCP sessions use the common API signing key. Publishing an article with a future publication date schedules its visibility across article APIs and readers. The scheduling change was cherry-picked to `main`. * **Operational maintenance:** Adyen report-cost processing is batched and bounded, the retired payment service is excluded from production deployment waves, and developer schema tests use the configured Vitest runner. Related test and formatting changes are included without separate feature announcements. ## 2026-10-15 — Inventory write schemas list every property [#2026-10-15--inventory-write-schemas-list-every-property] * **No open-ended request bodies.** The create and update schemas for inventory items now list every property they accept, and the reference no longer shows `[key: string]: any` on them. Keys not in the schema were already ignored and still are; they are now also left out of the reference. `metadata` (your own keys) and `context` stay free-form, and the [inventory guide](/docs/inventory-items) explains both. * **`basePrice` is optional on create.** It never set the price; use `pricings` or `pricingGroupId`. * **Availability.** `isAvailable` and `available` are no longer listed on update: they never had an effect. Use `POST /api/v1/inventory/{itemId}/availability`. * **Every property described.** Pricing rule types and their `metadata`, measurement, custom fields, images and classification now have descriptions. The item response marks properties returned only by the list or the single-item endpoint, and no longer lists `basePrice` or `karmaCutPercentage`, which are never returned. ## 2026-10-13 — Inventory validation and reference [#2026-10-13--inventory-validation-and-reference] * **Stricter inventory write validation.** These requests used to fail with a 500, or in the case of `vatValue` store a VAT rate a hundred times too high. They now return a 400: * `itemType` longer than 50 characters; * a `visibility` value outside `POS`, `KIOSK`, `TAKEAWAY`, `ONLINE` and `GENERAL`, or a repeated value; * `vatValue` above 100. Writes take a percentage (`12`), while reads return basis points (`1200`). * **Inventory reference.** The item response now documents every field the API returns, including `pricings`, `variantGroupIds`, `printerGroupIds`, `accountIds`, stock, kitchen and inheritance fields. The [inventory guide](/docs/inventory-items) is rewritten: * which `itemType` values carry behaviour; * that tags are never created, unknown ones are skipped silently, and `tags` on update replaces the whole set, `CATEGORY_PRIMARY` included; * that `visibility` is stored but has no effect; * the read/write units of `vatValue`. ## 2026-10-12 — Inventory and BigQuery documentation clarification [#2026-10-12--inventory-and-bigquery-documentation-clarification] * **Inventory writes:** the [inventory guide](/docs/inventory-items) distinguishes extensible `itemType` strings from legacy item-type references and sales-line classifications. It documents empty strings, the storage limit, visibility memberships, existing-reference relationships, tag replacement and VAT precedence. The write reference now describes fields already forwarded by the handlers; no new inventory feature is announced. The current create handler's `basePrice` compatibility limitation is explicit: use `pricings` or a shared-price ID and verify the resulting price. * **BigQuery reporting:** the [guide](/docs/bigquery) now includes verified split-payment examples, modifier/article lookup fallbacks, historical discounted VAT, prepaid-code scope, EH member lookup and refund-contract limitations. See the separate [BigQuery changelog](/docs/bigquery-changelog) for the dated dev/test schema observations. ## 2026-10-07 — Production payment and receipt fixes [#2026-10-07--production-payment-and-receipt-fixes] * **Swish refunds** — corrected refund handling for Swish payments processed through Adyen, including refunds requested while capture is still pending. * **Terminal modifications** — cancellation and refund requests now use the POS credentials bound to the original terminal payment. * **Digital receipts** — restored the digital-receipt QR code on terminal receipts. ## 2026-10-06 — Production release [#2026-10-06--production-release] The latest production release brings the following integration changes to the main API. * **Shared prices and price clusters** — `GET`/`POST /api/v1/prices` and `PUT /api/v1/prices/{id}` manage reusable price definitions. Link inventory items with `pricingGroupId`; updates require the latest `version`. The `/api/v1/price-clusters` endpoints manage grouped item prices and assignments to descendant locations, including linked shared prices and location overrides. * **Inventory discovery** — inventory listing supports pricing-aware filters and sorting. Catalogue changes now reach inheriting locations through realtime updates, and catalogue version tags remain distinct after a lost version counter. * **Refund controls and quantities** — `/api/v1/refunds/settings` exposes location controls for amount, product and partial refunds, alongside configurable reasons. Product refund lines accept `quantityCents` in hundredths of a unit: `100` means one unit. An amount-only refund does not record a returned quantity; do not infer units from the refund amount. * **Receipt observability** — receipt-decision and receipt-status endpoints explain skipped receipts and expose pending, printed or failed jobs. Use the receipt request's correlation ID to look up its status. * **Reporting** — staff sales reports can attribute sales to the staff member who took payment. A parent location can apply its prepaid-account mapping to descendant locations in combined SIE exports. * **Order-confirmation settings** — `/api/v1/locations/{locationId}/order-confirmation-settings` configures recipient rules by channel and item. Its `/preview` endpoint renders example HTML and plain text without sending email. Newly enabled rules apply to orders first received after enabling. * **BigQuery source changes** — refund quantity, refund controls and price-cluster source migrations were released to production. Source rollout does not establish customer-view availability; see the [BigQuery changelog](/docs/bigquery-changelog) for the environment-specific observations and export implications. ## 2026-09-24 [#2026-09-24] **Objects and fields.** The API reference now includes the Objects & Fields API: typed, versioned custom fields on items, guests, reservations, tables, locations, tabs, orders and purchases, with a [guide](/docs/custom-fields) covering definitions, values, the projection on normal responses and search. * **Guides** — [rate limits](/docs/rate-limits), [placing an order](/docs/place-an-order), [reading sales data](/docs/sales-data), [pushing surplus from a POS](/docs/surplus-from-pos) and the custom voucher provider contract (withdrawn on 2026-10-20). * **Reference refreshed** — 467 operations, adding gift card, invoice, reporting, payout report and print job endpoints alongside the fields API. * **Environments** — the staging API is `common-api.staging.karma.life`. ## 2026-09-23 — Upcoming staging API update [#2026-09-23--upcoming-staging-api-update] These changes subsequently shipped in the [October 6 production release](#2026-10-06--production-release). The text below records the original staging announcement. The changes below are planned for staging. The API reference will show their full schemas when the release is deployed. * **Shared prices:** `GET`/`POST /api/v1/prices` and `PUT /api/v1/prices/{id}` will let you create reusable prices and link inventory items through `pricingGroupId`. Inventory responses will include the selected price and calculated prices by channel. * **Inventory discovery:** `GET /api/v1/inventory` will add filters for title, pricing name, local or inherited source, and effective price, plus pricing-aware sorting. * **Receipt jobs:** New receipt-decision and receipt-status endpoints will expose why a receipt was skipped and whether a requested receipt is still pending, printed, or failed. Terminal receipt requests will return a correlation ID for status lookup. * **Webhooks:** Existing inventory, booking, order, and tab events will be able to include API-readable custom fields in `customFields` and `metadata.customFields`. No new event type is introduced. The planned REST reference removes no existing operation. Price amounts use the location's currency units, and shared-price updates will require the latest version. ## 2026-08-12 [#2026-08-12] **API reference refreshed.** The reference now covers 513 operations, up from 477 — including retailer user management, guest profile and alias endpoints, additional location endpoints, and a new reporting endpoint. * **New sections** — Customers (guest records, profiles and aliases) and retailer user management under Staff & Access. ## 2026-08-02 [#2026-08-02] **New developer site.** This site replaces the previous API documentation. It covers the REST API and webhook events in one place, with a request playground on every endpoint. * **Webhooks reference** — all 31 outbound events now documented with their full payload schema, delivery envelope and routing, rather than a hand-maintained subset. * **Markdown for tools** — every page is also served as plain markdown at the same URL with a `.md` suffix, plus [`llms.txt`](/llms.txt) and [`llms-full.txt`](/llms-full.txt) indexes, so AI assistants and scripts can read the docs directly. * **Environment switcher** — the reference for the development and staging environments is published separately, so you can read the docs that match the environment you are integrating against. # Objects and fields (/docs/custom-fields) Merchants can extend Karma's objects with their own fields: a producer and vintage on a wine, a purchase-order reference on a tab, a delivery zone on a table. Fields are typed, inherit down the location tree, carry a version for safe concurrent edits, and appear on the object's normal API responses. Use fields when the data has a type and a meaning. For opaque integration identifiers such as your own SKU or shop ID, the free-form `metadata` object on items and locations is still the right place. For group attributes, linked-item values on variants, and customer checkout input, see [Variant custom fields](/docs/variant-custom-fields). ## Objects [#objects] Fields attach to one of ten object types. The `objectType` path segment and the record identifier are: | `objectType` | Record id | | ----------------- | ------------------------------------- | | `items` | inventory item id | | `guests` | user id, scoped to the location | | `reservations` | booking id | | `tables` | table UUID | | `locations` | the location id itself | | `tabs` | tab UUID | | `orders` | kitchen order UUID | | `purchases` | purchase id | | `retailer_users` | staff user id, scoped to the location | | `cost_centers` | cost center id | | `variant_groups` | variant group id | | `loyalty_members` | loyalty membership account id | Every route lives under `/api/v1/locations/{locationId}/object-types/{objectType}` and uses the standard envelope. Reading and writing values takes the object's own read and update permissions; managing definitions takes the settings permission. ## Definitions [#definitions] A definition has an immutable `id`, an immutable `key`, a type, a label, a group, validation config and channel policies. Keys are namespaced by owner: merchant fields are `loc.`, Karma-provided fields are `karma.*`. | Type | Value on the wire | | -------------------- | ----------------------------------------- | | `text`, `long_text` | string | | `number` | decimal as a **string**, e.g. `"12.5"` | | `currency` | `{ "amount": "80", "currency": "SEK" }` | | `boolean` | `true` / `false` | | `date` | `"2026-09-24"` | | `select`, `taxonomy` | an option **id**, never its label | | `multiselect` | array of option ids | | `formula`, `rollup` | computed, read-only | | `relation` | id of a record of `config.relationObject` | Definitions inherit: a field created at a parent location applies to every descendant. A descendant may customise the label, group and placement and add its own options through the `extensions` route, but cannot change the type, validation or access policy. Fetch the effective schema for a location and object: ```bash curl "$COMMON_API/api/v1/locations/1234/object-types/items/fields" \ -H "X-API-Key: $KARMA_API_KEY" ``` The response lists `fields` with their `config`, `channels`, `revision`, `inherited` and `canWrite`, plus `capabilities` telling you whether this location may customise fields (`custom_fields`, part of Intelligence) and use Kaia suggestions. Create a field: ```bash curl -X POST "$COMMON_API/api/v1/locations/1234/object-types/tabs/fields" \ -H "X-API-Key: $KARMA_API_KEY" -H 'Content-Type: application/json' \ -d '{ "key": "po_reference", "label": "PO reference", "group": "Accounting", "type": "text", "config": { "maxLength": 40 }, "channels": { "staff": true, "table": true, "tableDefault": false, "guestDisplay": false, "guestInput": false, "apiRead": true, "apiWrite": true, "analytics": true, "aiSource": false } }' ``` Returns 201 with a `Location` header. Updating goes through `PATCH .../fields/{id}` with the `revision` you last read; a stale revision is rejected with 409. Fields are archived, never deleted, and a key stays reserved after archival. An API key only sees fields with `apiRead` and can only write fields with `apiWrite`. A field that is staff-only is simply absent from API responses. Guest surfaces see only `guestDisplay` fields, and never schema or configuration. ## Values [#values] Read a record's values: ```bash curl "$COMMON_API/api/v1/locations/1234/object-types/tabs/records/59e4ce7d-b801-4323-af9d-ba0ca4bd757a/values" \ -H "X-API-Key: $KARMA_API_KEY" ``` ```json { "status": "success", "data": { "values": [ { "fieldId": "2c6f…", "value": "PO-42", "version": 3, "source": "api", "status": "fresh", "sourceLocationId": 1234, "updatedAt": "2026-09-24T09:12:40.000Z" } ] } } ``` `source` is who last set it: `manual`, `api`, `ai`, `formula` or `rollup`. `status` matters for computed fields: `fresh`, `stale` (an input changed and the recompute is pending), `error` (see `error`) or `frozen` (the plan no longer covers calculations, the last value is kept). Unset fields are omitted; an explicitly cleared field is present with `value: null`. Write values atomically, one call per record, up to 100 changes. Use the `expectedVersion` you last read to reject stale edits; a mismatch fails the whole request with 409 `VERSION_CONFLICT` and nothing is written. It is optional: omitting it intentionally updates the latest value. Identify each change by `fieldId` or a stable `key`. ```bash curl -X PATCH "$COMMON_API/api/v1/locations/1234/object-types/tabs/records/59e4ce7d-…/values" \ -H "X-API-Key: $KARMA_API_KEY" -H 'Content-Type: application/json' \ -d '{ "changes": [ { "fieldId": "2c6f…", "expectedVersion": 3, "value": "PO-43" }, { "fieldId": "9a10…", "expectedVersion": 0, "value": { "amount": "1250", "currency": "SEK" } } ] }' ``` A never-set field has version `0`. Send `"value": null` to clear a field, or `"reset": true` on a shared item at a child location to drop the local override and inherit the parent's value again. Validation failures return 422 `FIELD_VALIDATION` with the offending field in the message. Read many records at once with `POST .../values/batch` and up to 200 `ids`. ## Values on normal create and update calls [#values-on-normal-create-and-update-calls] Authenticated staff and API integrations can include optional `customFields` operations in the normal record request. They are saved in the same transaction as the record, so invalid values or a stale version roll back the whole edit. For example, update a booking's guest count and meal abbreviation in one call: ```http PUT /api/v1/bookings/{bookingId} Content-Type: application/json ``` ```json { "guestCount": 4, "customFields": [ { "fieldId": "", "expectedVersion": 1, "value": "LU" } ] } ``` The same array is accepted on booking creation, with `expectedVersion: 0` for each initial value. Definitions must already exist; inline operations set values, they do not create field definitions. | Object | Normal calls accepting inline operations | | -------------------- | ----------------------------------------------------------------------------------------------- | | Reservations | `POST /bookings`, `PUT /bookings/{id}` | | Items | `POST /inventory`, `PUT /inventory/{id}`; bulk-upsert item rows and the bulk update `patch` | | Guests | `POST /locations/{locationId}/customers`, `PATCH /locations/{locationId}/customers/{userId}` | | Tables | `POST /tables`, `PUT /tables/{id}`, `PUT /tables/bulk` | | Locations | `POST /locations`, `PATCH /locations/{locationId}/settings` | | Tabs | `POST /tabs/resolve`, `PATCH /tabs/{id}`, `PATCH /tabs/{id}/metadata` | | Orders | `POST /orders/create`, `POST /orders/append-to-dispatch`, `PATCH /orders/{id}` | | Purchases | `PATCH /purchases/{id}`, `POST /purchases/{purchaseIntentId}/connect-customer` | | Staff users | `PUT /retailer/users/{id}`, `PUT /retailer/users/me` | | Cost centers | `POST /cost-centers`, `PUT /cost-centers/{id}` | | Variant groups | `POST /variant-groups`, `PUT /variant-groups/{id}`; top-level `customFields` edits group values | | Variant linked items | `PATCH /variants/{id}?locationId={locationId}`, or `variants[].customFields` in group writes | All paths above start with `/api/v1`. The general PATCH calls on tabs, orders and purchases edit custom fields only; their lifecycle and payment actions keep their dedicated APIs. Staff-user creation creates an invitation, so values attach to the actual user after acceptance. Purchases are created by payment flows, rather than a general record-create endpoint. * Omit `customFields` to preserve values on updates. An empty array makes no field changes. * You do not need to include unset optional fields. On creation, writable required fields must have a value; conditional required fields apply when their condition matches. A submitted update checks the resulting required values, preserving existing values for omitted fields. * Send `value: null` to clear an optional field. Required fields cannot be cleared. Use `reset: true` without `value` to drop an inherited-item override; a required field must still have an inherited value. * Send the last-read `expectedVersion` for updates. A conflict returns 409 `VERSION_CONFLICT`; invalid values return 422 `FIELD_VALIDATION`. Permissions, API channel policies and plan requirements are identical to the values endpoint. * Guest checkout continues to use the guest form's schema token and answers, rather than staff field operations. The response `customFields` array contains labels and display values as well as raw values and versions. Do not copy that entire read projection into a write: send only `fieldId`, `expectedVersion` and `value` (or `reset`). ## Values on normal responses [#values-on-normal-responses] You do not need the fields API to read values. Native detail and list responses for supported object types carries a read-only projection of the fields your key may read: ```json { "id": 456, "title": "Chianti Classico", "customFields": [ { "fieldId": "…", "key": "loc1234.producer", "label": "Producer", "group": "Wine", "type": "text", "value": "Antinori", "displayValue": "Antinori", "version": 2, "status": "fresh" } ], "metadata": { "externalId": "sku-3732185", "customFields": { "loc1234.producer": "Antinori" } } } ``` `metadata.customFields` is the same data keyed by field key, convenient for lookups. Both are current values, not a snapshot from the time of a sale. Writing `metadata.customFields` through a generic metadata update is rejected; values change through inline write operations or the values route above. Webhook payloads carry the same projection, described in [Receiving webhooks](/docs/webhooks#custom-fields-and-metadata). ## Searching by field [#searching-by-field] `POST .../search` filters and sorts records by typed values with cursor pagination: ```bash curl -X POST "$COMMON_API/api/v1/locations/1234/object-types/items/search" \ -H "X-API-Key: $KARMA_API_KEY" -H 'Content-Type: application/json' \ -d '{ "filters": [ { "field": "7b3e…", "operator": "eq", "value": "opt_red" }, { "field": "c412…", "operator": "gte", "value": "2018" } ], "sort": { "field": "c412…", "descending": true }, "limit": 50 }' ``` Operators are `eq`, `gte`, `lte`, `contains` (multiselect membership or text) and `unset`. Up to five filters; `query` searches titles; `ids` restricts to a set. Follow `nextCursor` from the response for the next page. Filtering by generic `metadata` keys still goes through the object's own list endpoint; this route is for typed field values. ## Errors [#errors] | Status | Code | Meaning | | ------ | ------------------------------------------------- | --------------------------------------------------------------- | | 400 | `INVALID_INPUT`, `INVALID_FILTER` | Malformed body, or a filter names a field you cannot read | | 403 | `FORBIDDEN`, `PLAN_REQUIRED` | Missing permission, channel, or the `custom_fields` entitlement | | 404 | `NOT_FOUND` | Record or field does not exist at this location | | 409 | `VERSION_CONFLICT`, `CONFLICT` | Stale version or revision, or the key already exists | | 422 | `FIELD_VALIDATION`, `FIELD_LIMIT`, `SCHEMA_LIMIT` | Value rejected, or a bound reached | Bounds: 100 merchant fields per location, 500 effective fields per object type, 1,000 options per field, 200 records per batch or search page. # Customers Location-scoped guest user records, overrides, and editing ## GET /api/v1/locations/{locationId}/customers List customers (guest users) at a location Returns users who have at least one confirmed purchase at the location, ever. Sorted by name (ASC). **Permissions:** 🔵 `purchases.read` Query parameters: - `search` (string, optional) — Search on name, email, or phone - `registered` ("registered" | "guest", optional) — Filter by whether the user has logged in - `page` (integer, optional) - `limit` (integer, optional) - `metadata` (string, optional) — JSON-encoded jsonb-containment filter on `users.metadata`. Example: `?metadata={"someKey":"value"}`. The dotted shorthand `?metadata.someKey=value` is also accepted. Path parameters: - `locationId` (string, required) — Location ID Response 200 — Default Response ```ts { status: "success" data: { items: Array<{ customFields?: unknown[] metadata?: { … } | null id: number uuid?: string | null name?: string | null email?: string | null phone?: string | null isRegistered: boolean firstOrderedAt?: 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/locations/{locationId}/customers Resolve or create a guest user Resolve or create a guest user for this location by email or phone. Does not enroll in loyalty. 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: - `locationId` (string, required) — Location ID Request body: ```ts { email?: string // email phone?: string name?: string customFields?: unknown | unknown[] } ``` Response 200 — Default Response ## GET /api/v1/locations/{locationId}/customers/{userId} Get a single customer at a location Returns the user record + linked provider auth methods. Only returns the customer if they have ordered at the location at any point. **Permissions:** 🔵 `purchases.read` Path parameters: - `locationId` (string, required) — Location ID - `userId` (string, required) — User ID Response 200 — Default Response ```ts { status: "success" data: { 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 } | null id: number uuid?: string | null name?: string | null email?: string | null phone?: string | null imageUrl?: string | null isRegistered: boolean emailVerified?: boolean | null createdAt: string // date-time firstOrderedAt?: string | null // date-time providers: Array<{ provider?: string | null providerEmail?: string | null providerPhone?: string | null emailVerified?: boolean | null phoneVerified?: boolean | null linkedAt: string // date-time lastSignInAt?: string | null // date-time signInCount?: number | null }> language?: string | null lastSeenDate?: string | null // date-time visitCount: number vip: boolean flags: Record hasLocationOverride: boolean global: { name: string | null phone: string | null } recentBookings: Array<{ customFields?: unknown[] metadata?: { … } | null id: string locationId: number bookingDate: string startTime: string endTime?: string | null guestCount: number status: string tableName?: string | null syncSource?: string | null createdAt: string // date-time }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PATCH /api/v1/locations/{locationId}/customers/{userId} Update a customer (location-override first) Writes are location-override-first: name/phone/vip/flags upsert into user_location_overrides. name/phone/email additionally backfill the canonical users row ONLY when that column is currently null/empty (we never overwrite identity the guest filled in). 409 if a new email collides with another user. **Permissions:** 🟠 `customers.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: - `locationId` (string, required) — Location ID - `userId` (string, required) — User ID Request body: ```ts { name?: string | null email?: string | null phone?: string | null vip?: boolean flags?: Record customFields?: unknown | unknown[] } ``` Response 200 — Default Response ```ts { status: "success" data: { 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 } | null id: number uuid?: string | null name?: string | null email?: string | null phone?: string | null imageUrl?: string | null isRegistered: boolean emailVerified?: boolean | null createdAt: string // date-time firstOrderedAt?: string | null // date-time providers: Array<{ provider?: string | null providerEmail?: string | null providerPhone?: string | null emailVerified?: boolean | null phoneVerified?: boolean | null linkedAt: string // date-time lastSignInAt?: string | null // date-time signInCount?: number | null }> language?: string | null lastSeenDate?: string | null // date-time visitCount: number vip: boolean flags: Record hasLocationOverride: boolean global: { name: string | null phone: string | null } recentBookings: Array<{ customFields?: unknown[] metadata?: { … } | null id: string locationId: number bookingDate: string startTime: string endTime?: string | null guestCount: number status: string tableName?: string | null syncSource?: string | null createdAt: string // date-time }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Users End user management and lookup ## GET /api/v1/users/{userId}/aliases List user aliases Path parameters: - `userId` (integer, required) Response 200 — Default Response ## POST /api/v1/users/{userId}/aliases Create a user alias Path parameters: - `userId` (integer, required) Request body: ```ts { type: "email" | "phone" value: string source?: string } ``` Response 200 — Default Response ## DELETE /api/v1/users/{userId}/aliases/{aliasId} Delete a user alias Path parameters: - `userId` (integer, required) - `aliasId` (string, required) Response 200 — Default Response ## POST /api/v1/users/{userId}/aliases/{aliasId}/promote-to-primary Promote a user alias to primary Path parameters: - `userId` (integer, required) - `aliasId` (string, required) Response 200 — Default Response ## GET /api/v1/users/{userId}/locations/{locationId}/profile Get the merchant-scoped guest profile (global + override + effective) Returns the canonical Karma user fields, the per-location override (if set), and the effective merged values that merchants should display by default. The override never affects storefront/auth; only merchant-facing endpoints merge. Path parameters: - `userId` (integer, required) - `locationId` (integer, required) Response 200 — Default Response ```ts { status: "success" data: { userId: number locationId: number global: { email?: string | null phone?: string | null name?: string | null } override?: { displayName?: string | null phone?: string | null vip?: boolean flags?: Record createdByRetailerUserId?: number | null updatedByRetailerUserId?: number | null createdAt?: string // date-time updatedAt?: string // date-time } | null effective: { displayName?: string | null phone?: string | null vip?: boolean flags?: Record } } } ``` ## DELETE /api/v1/users/{userId}/locations/{locationId}/profile Remove all per-location overrides for a guest user Deletes the user_location_overrides row. Subsequent reads see only the canonical Karma user. Emits a user.location_override_deleted platform event. Path parameters: - `userId` (integer, required) - `locationId` (integer, required) Response 204 — Default Response ## PATCH /api/v1/users/{userId}/locations/{locationId}/profile Upsert per-location overrides for a guest user Writes a row to user_location_overrides. Only the fields provided in the body are changed. Pass null to clear a field. Emits a user.location_override_updated platform event. Path parameters: - `userId` (integer, required) - `locationId` (integer, required) Request body: ```ts { displayName?: string | null phone?: string | null vip?: boolean flags?: Record } ``` Response 200 — Default Response ```ts { status: "success" data: { userId: number locationId: number global: { email?: string | null phone?: string | null name?: string | null } override?: { displayName?: string | null phone?: string | null vip?: boolean flags?: Record createdByRetailerUserId?: number | null updatedByRetailerUserId?: number | null createdAt?: string // date-time updatedAt?: string // date-time } | null effective: { displayName?: string | null phone?: string | null vip?: boolean flags?: Record } } } ``` # Object fields ## GET /api/v1/locations/{locationId}/guest-fields/profile Read or replace your location guest answers Path parameters: - `locationId` (integer, required) Response 200 — Default Response ```ts { status: "success" data: { form: { schemaToken: string fields: { … }[] } values: Array<{ fieldId: string // uuid value: … }> valueToken: string readableFields: Array<{ id: string key: string label: string description: string group: string type: string required: boolean revision: number config: { … } }> } } ``` ## PUT /api/v1/locations/{locationId}/guest-fields/profile Read or replace your location guest answers Path parameters: - `locationId` (integer, required) Request body: ```ts { valueToken: string submission: { schemaToken: string answers: Array<{ fieldId: string // uuid value: … }> } } ``` Response 200 — Default Response ```ts { status: "success" data: { form: { schemaToken: string fields: { … }[] } values: Array<{ fieldId: string // uuid value: … }> valueToken: string readableFields: Array<{ id: string key: string label: string description: string group: string type: string required: boolean revision: number config: { … } }> } } ``` ## GET /api/v1/locations/{locationId}/guest-fields/reservations Read a guest reservation form Query parameters: - `guestName` (string, optional) - `bookingGroupId` (string, optional) Path parameters: - `locationId` (integer, required) Response 200 — Default Response ```ts { status: "success" data: { schemaToken: string fields: Array<{ id: string key: string label: string description: string group: string type: string required: boolean revision: number config: { … } }> } } ``` ## GET /api/v1/locations/{locationId}/object-types/{objectType}/calculation-status Inspect scoped refresh queue health Versioned object-field API. Decimal values are strings; currencies are {amount,currency}; selections use immutable option IDs. Location/object permissions and field channel policies apply. Updates require the last-read revision/version; stale writes return 409. Reads remain available after plan downgrade. Path parameters: - `locationId` (integer, required) - `objectType` ("items" | "guests" | "reservations" | "tables" | "locations" | "tabs" | "orders" | "purchases" | "retailer_users" | "cost_centers", required) - `id` (string, required) Response 200 — Default Response ## GET /api/v1/locations/{locationId}/object-types/{objectType}/fields Get effective object fields and capabilities Versioned object-field API. Decimal values are strings; currencies are {amount,currency}; selections use immutable option IDs. Location/object permissions and field channel policies apply. Updates require the last-read revision/version; stale writes return 409. Reads remain available after plan downgrade. Path parameters: - `locationId` (integer, required) - `objectType` ("items" | "guests" | "reservations" | "tables" | "locations" | "tabs" | "orders" | "purchases" | "retailer_users" | "cost_centers", required) - `id` (string, required) Response 200 — Default Response ## POST /api/v1/locations/{locationId}/object-types/{objectType}/fields Create a local or Karmeleon global field Versioned object-field API. Decimal values are strings; currencies are {amount,currency}; selections use immutable option IDs. Location/object permissions and field channel policies apply. Updates require the last-read revision/version; stale writes return 409. Reads remain available after plan downgrade. Path parameters: - `locationId` (integer, required) - `objectType` ("items" | "guests" | "reservations" | "tables" | "locations" | "tabs" | "orders" | "purchases" | "retailer_users" | "cost_centers", required) - `id` (string, required) Request body: ```ts { key: string label: string description?: string group?: string type: "text" | "long_text" | "number" | "currency" | "boolean" | "date" | "select" | "multiselect" | "taxonomy" | "formula" | "relation" | "rollup" required?: boolean requiredFeature?: string | null config?: { taxonomyDisplay?: "select" | "buttons" options?: Array<{ id: string label: string parentId?: string archived?: boolean }> currency?: string min?: number max?: number maxLength?: integer formula?: string relationObject?: "items" | "guests" | "reservations" | "tables" | "locations" | "tabs" | "orders" | "purchases" | "retailer_users" | "cost_centers" rollup?: { relationField: string valueField: string operation: "sum" | "count" | "average" | "min" | "max" } condition?: { field: string equals: string } ai?: { instruction: string sourceFields: string[] mode: "review" | "empty_only" } } channels?: { staff: boolean posFiltering?: boolean table: boolean tableDefault: boolean guestDisplay: boolean guestInput: boolean apiRead: boolean apiWrite: boolean analytics: boolean aiSource: boolean } global?: boolean } ``` Response 200 — Default Response ## PATCH /api/v1/locations/{locationId}/object-types/{objectType}/fields/{id} Update or archive an owned field with optimistic concurrency Versioned object-field API. Decimal values are strings; currencies are {amount,currency}; selections use immutable option IDs. Location/object permissions and field channel policies apply. Updates require the last-read revision/version; stale writes return 409. Reads remain available after plan downgrade. Path parameters: - `locationId` (integer, required) - `objectType` ("items" | "guests" | "reservations" | "tables" | "locations" | "tabs" | "orders" | "purchases" | "retailer_users" | "cost_centers", required) - `id` (string, required) Request body: ```ts { key: string label: string description?: string group?: string type: "text" | "long_text" | "number" | "currency" | "boolean" | "date" | "select" | "multiselect" | "taxonomy" | "formula" | "relation" | "rollup" required?: boolean requiredFeature?: string | null config?: { taxonomyDisplay?: "select" | "buttons" options?: Array<{ id: string label: string parentId?: string archived?: boolean }> currency?: string min?: number max?: number maxLength?: integer formula?: string relationObject?: "items" | "guests" | "reservations" | "tables" | "locations" | "tabs" | "orders" | "purchases" | "retailer_users" | "cost_centers" rollup?: { relationField: string valueField: string operation: "sum" | "count" | "average" | "min" | "max" } condition?: { field: string equals: string } ai?: { instruction: string sourceFields: string[] mode: "review" | "empty_only" } } channels?: { staff: boolean posFiltering?: boolean table: boolean tableDefault: boolean guestDisplay: boolean guestInput: boolean apiRead: boolean apiWrite: boolean analytics: boolean aiSource: boolean } global?: boolean revision: integer archived?: boolean } ``` Response 200 — Default Response ## PATCH /api/v1/locations/{locationId}/object-types/{objectType}/fields/{id}/extensions Customize inherited presentation and add local options Versioned object-field API. Decimal values are strings; currencies are {amount,currency}; selections use immutable option IDs. Location/object permissions and field channel policies apply. Updates require the last-read revision/version; stale writes return 409. Reads remain available after plan downgrade. Path parameters: - `locationId` (integer, required) - `objectType` ("items" | "guests" | "reservations" | "tables" | "locations" | "tabs" | "orders" | "purchases" | "retailer_users" | "cost_centers", required) - `id` (string, required) Request body: ```ts { key: string label: string description?: string group?: string type: "text" | "long_text" | "number" | "currency" | "boolean" | "date" | "select" | "multiselect" | "taxonomy" | "formula" | "relation" | "rollup" required?: boolean requiredFeature?: string | null config?: { taxonomyDisplay?: "select" | "buttons" options?: Array<{ id: string label: string parentId?: string archived?: boolean }> currency?: string min?: number max?: number maxLength?: integer formula?: string relationObject?: "items" | "guests" | "reservations" | "tables" | "locations" | "tabs" | "orders" | "purchases" | "retailer_users" | "cost_centers" rollup?: { relationField: string valueField: string operation: "sum" | "count" | "average" | "min" | "max" } condition?: { field: string equals: string } ai?: { instruction: string sourceFields: string[] mode: "review" | "empty_only" } } channels?: { staff: boolean posFiltering?: boolean table: boolean tableDefault: boolean guestDisplay: boolean guestInput: boolean apiRead: boolean apiWrite: boolean analytics: boolean aiSource: boolean } global?: boolean extensionRevision: integer } ``` Response 200 — Default Response ## GET /api/v1/locations/{locationId}/object-types/{objectType}/records/{id}/ai Read pending Kaia suggestions for this record Versioned object-field API. Decimal values are strings; currencies are {amount,currency}; selections use immutable option IDs. Location/object permissions and field channel policies apply. Updates require the last-read revision/version; stale writes return 409. Reads remain available after plan downgrade. Path parameters: - `locationId` (integer, required) - `objectType` ("items" | "guests" | "reservations" | "tables" | "locations" | "tabs" | "orders" | "purchases" | "retailer_users" | "cost_centers", required) - `id` (string, required) Response 200 — Default Response ## POST /api/v1/locations/{locationId}/object-types/{objectType}/records/{id}/ai Ask Kaia for an evidence-backed field suggestion Versioned object-field API. Decimal values are strings; currencies are {amount,currency}; selections use immutable option IDs. Location/object permissions and field channel policies apply. Updates require the last-read revision/version; stale writes return 409. Reads remain available after plan downgrade. Path parameters: - `locationId` (integer, required) - `objectType` ("items" | "guests" | "reservations" | "tables" | "locations" | "tabs" | "orders" | "purchases" | "retailer_users" | "cost_centers", required) - `id` (string, required) Request body: ```ts { fieldId: string // uuid } ``` Response 200 — Default Response ## POST /api/v1/locations/{locationId}/object-types/{objectType}/records/{id}/ai-review Approve or reject a suggestion without overwriting newer data Versioned object-field API. Decimal values are strings; currencies are {amount,currency}; selections use immutable option IDs. Location/object permissions and field channel policies apply. Updates require the last-read revision/version; stale writes return 409. Reads remain available after plan downgrade. Path parameters: - `locationId` (integer, required) - `objectType` ("items" | "guests" | "reservations" | "tables" | "locations" | "tabs" | "orders" | "purchases" | "retailer_users" | "cost_centers", required) - `id` (string, required) Request body: ```ts { proposalId: string // uuid approve: boolean } ``` Response 200 — Default Response ## POST /api/v1/locations/{locationId}/object-types/{objectType}/records/{id}/recompute Refresh stored formula and rollup results Versioned object-field API. Decimal values are strings; currencies are {amount,currency}; selections use immutable option IDs. Location/object permissions and field channel policies apply. Updates require the last-read revision/version; stale writes return 409. Reads remain available after plan downgrade. Path parameters: - `locationId` (integer, required) - `objectType` ("items" | "guests" | "reservations" | "tables" | "locations" | "tabs" | "orders" | "purchases" | "retailer_users" | "cost_centers", required) - `id` (string, required) Response 200 — Default Response ## GET /api/v1/locations/{locationId}/object-types/{objectType}/records/{id}/values Read permitted values for a record Versioned object-field API. Decimal values are strings; currencies are {amount,currency}; selections use immutable option IDs. Location/object permissions and field channel policies apply. Updates require the last-read revision/version; stale writes return 409. Reads remain available after plan downgrade. Path parameters: - `locationId` (integer, required) - `objectType` ("items" | "guests" | "reservations" | "tables" | "locations" | "tabs" | "orders" | "purchases" | "retailer_users" | "cost_centers", required) - `id` (string, required) Response 200 — Default Response ## PATCH /api/v1/locations/{locationId}/object-types/{objectType}/records/{id}/values Atomically validate and update field values Versioned object-field API. Decimal values are strings; currencies are {amount,currency}; selections use immutable option IDs. Location/object permissions and field channel policies apply. Updates require the last-read revision/version; stale writes return 409. Reads remain available after plan downgrade. Path parameters: - `locationId` (integer, required) - `objectType` ("items" | "guests" | "reservations" | "tables" | "locations" | "tabs" | "orders" | "purchases" | "retailer_users" | "cost_centers", required) - `id` (string, required) Request body: ```ts { changes: Array<{ fieldId: string // uuid expectedVersion: integer value?: string | boolean | string[] | { amount: string currency: string } | null reset?: boolean }> } ``` Response 200 — Default Response ## POST /api/v1/locations/{locationId}/object-types/{objectType}/search Filter and sort typed effective fields with cursor pagination Versioned object-field API. Decimal values are strings; currencies are {amount,currency}; selections use immutable option IDs. Location/object permissions and field channel policies apply. Updates require the last-read revision/version; stale writes return 409. Reads remain available after plan downgrade. Path parameters: - `locationId` (integer, required) - `objectType` ("items" | "guests" | "reservations" | "tables" | "locations" | "tabs" | "orders" | "purchases" | "retailer_users" | "cost_centers", required) - `id` (string, required) Request body: ```ts { filters?: Array<{ field: string // uuid operator: "eq" | "gte" | "lte" | "contains" | "unset" value?: string | boolean }> query?: string ids?: string[] sort?: { field: string // uuid descending?: boolean } cursor?: string limit?: integer } ``` Response 200 — Default Response ## POST /api/v1/locations/{locationId}/object-types/{objectType}/values/batch Read fields for up to 200 records in one query Versioned object-field API. Decimal values are strings; currencies are {amount,currency}; selections use immutable option IDs. Location/object permissions and field channel policies apply. Updates require the last-read revision/version; stale writes return 409. Reads remain available after plan downgrade. Path parameters: - `locationId` (integer, required) - `objectType` ("items" | "guests" | "reservations" | "tables" | "locations" | "tabs" | "orders" | "purchases" | "retailer_users" | "cost_centers", required) - `id` (string, required) Request body: ```ts { ids: string[] } ``` Response 200 — Default Response # Print Templates Print template management for receipts and tickets ## GET /api/v1/locations/{locationId}/print-settings Get location print settings Get the print settings a location prints with: the system defaults, overlaid field by field by the settings of its ancestors from the farthest down and then its own, the nearest set value winning (as printer-service resolves them) Path parameters: - `locationId` (number, required) Response 200 — Default Response ```ts { status?: string data?: { id?: string // uuid locationId?: number fontSize?: number | null fontFamily?: "a" | "b" lineSpacing?: number | null logoUrl?: string | null headerText?: string | null footerText?: string | null showLogo?: boolean | null printQrCode?: boolean | null printVatBreakdown?: boolean | null kitchenTicketBeep?: boolean | null splitCoursesOnHoldTicket?: boolean | null hideDishesOnFireTicket?: boolean | null language?: string | null createdAt?: string // date-time updatedAt?: string // date-time inheritedFrom?: Record } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/locations/{locationId}/print-settings Save location print settings Create or update print settings for a location Path parameters: - `locationId` (number, required) Request body: ```ts { fontSize?: number | null fontFamily?: "a" | "b" lineSpacing?: number | null logoUrl?: string | null headerText?: string | null footerText?: string | null showLogo?: boolean | null printQrCode?: boolean | null printVatBreakdown?: boolean | null kitchenTicketBeep?: boolean | null splitCoursesOnHoldTicket?: boolean | null hideDishesOnFireTicket?: boolean | null language?: string | null } ``` Response 200 — Default Response ```ts { status?: string data?: { id?: string // uuid locationId?: number fontSize?: number | null fontFamily?: "a" | "b" lineSpacing?: number | null logoUrl?: string | null headerText?: string | null footerText?: string | null showLogo?: boolean | null printQrCode?: boolean | null printVatBreakdown?: boolean | null kitchenTicketBeep?: boolean | null splitCoursesOnHoldTicket?: boolean | null hideDishesOnFireTicket?: boolean | null language?: string | null createdAt?: string // date-time updatedAt?: string // date-time } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/locations/{locationId}/print-settings Delete location print settings Delete print settings for a location (reset to defaults) Path parameters: - `locationId` (number, required) Response 200 — Default Response ```ts { status?: string data?: { success?: boolean locationId?: number } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/locations/{locationId}/template-overrides List location template overrides Get, per template type, the template override a location prints with: its own active override, else its nearest ancestor's (marked as inherited) Path parameters: - `locationId` (number, required) Response 200 — Default Response ```ts { status?: string data?: { overrides?: Array<{ id?: string // uuid locationId?: number templateType?: "kitchen_ticket" | "purchase_receipt" | "pre_check" | "order_confirmation" | "station_message" | "sales_report" | "custom" overrideScope?: "full" | "section" | "settings" customTemplateContent?: { … } | null sectionOverrides?: { … } | null settingsOverrides?: { … } | null isActive?: boolean createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean inheritedFromLocationId?: number | null inheritedFromLocationName?: string | null }> } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/locations/{locationId}/template-overrides/{templateType} Get location template override Get the template override of one type that a location prints with: its own active override, else its nearest ancestor's (marked as inherited). 404 when neither exists Path parameters: - `locationId` (number, required) - `templateType` ("kitchen_ticket" | "purchase_receipt" | "pre_check" | "order_confirmation" | "station_message" | "sales_report" | "custom", required) Response 200 — Default Response ```ts { status?: string data?: { id?: string // uuid locationId?: number templateType?: "kitchen_ticket" | "purchase_receipt" | "pre_check" | "order_confirmation" | "station_message" | "sales_report" | "custom" overrideScope?: "full" | "section" | "settings" customTemplateContent?: Record | null sectionOverrides?: Record | null settingsOverrides?: Record | null isActive?: boolean createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean inheritedFromLocationId?: number | null inheritedFromLocationName?: string | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/locations/{locationId}/template-overrides/{templateType} Save location template override Create or update the location's own template override, which is active and wins over any ancestor's. Never changes an ancestor's override Path parameters: - `locationId` (number, required) - `templateType` ("kitchen_ticket" | "purchase_receipt" | "pre_check" | "order_confirmation" | "station_message" | "sales_report" | "custom", required) Request body: ```ts { overrideScope: "full" | "section" | "settings" customTemplateContent?: Record | null sectionOverrides?: Record | null settingsOverrides?: Record | null } ``` Response 200 — Default Response ```ts { status?: string data?: { id?: string // uuid locationId?: number templateType?: "kitchen_ticket" | "purchase_receipt" | "pre_check" | "order_confirmation" | "station_message" | "sales_report" | "custom" overrideScope?: "full" | "section" | "settings" customTemplateContent?: Record | null sectionOverrides?: Record | null settingsOverrides?: Record | null isActive?: boolean createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean inheritedFromLocationId?: number | null inheritedFromLocationName?: string | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/print-templates List print templates Get a list of all print templates with optional filtering Query parameters: - `type` ("kitchen_ticket" | "purchase_receipt" | "pre_check" | "order_confirmation" | "station_message" | "sales_report" | "custom", optional) — Filter by template type - `isActive` (boolean, optional) — Filter by active status - `isSystem` (boolean, optional) — Filter by system template status - `limit` (number, optional) - `offset` (number, optional) Response 200 — Default Response ```ts { status?: string data?: { templates?: Array<{ id?: string // uuid type?: "kitchen_ticket" | "purchase_receipt" | "pre_check" | "order_confirmation" | "station_message" | "sales_report" | "custom" name?: string description?: string | null version?: number isActive?: boolean isSystem?: boolean content?: { … } createdAt?: string // date-time updatedAt?: string // date-time createdBy?: number | null }> pagination?: { page: number limit: number total: number totalPages: number } } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/print-templates Create print template Create a new custom print template Request body: ```ts { type: "kitchen_ticket" | "purchase_receipt" | "pre_check" | "order_confirmation" | "station_message" | "sales_report" | "custom" name: string description?: string content: Record } ``` Response 201 — Default Response ```ts { status?: string data?: { id?: string // uuid type?: "kitchen_ticket" | "purchase_receipt" | "pre_check" | "order_confirmation" | "station_message" | "sales_report" | "custom" name?: string description?: string | null version?: number isActive?: boolean isSystem?: boolean content?: Record createdAt?: string // date-time updatedAt?: string // date-time createdBy?: number | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/print-templates/{id} Get print template Get a single print template by ID Path parameters: - `id` (string, required) Response 200 — Default Response ```ts { status?: string data?: { id?: string // uuid type?: "kitchen_ticket" | "purchase_receipt" | "pre_check" | "order_confirmation" | "station_message" | "sales_report" | "custom" name?: string description?: string | null version?: number isActive?: boolean isSystem?: boolean content?: Record createdAt?: string // date-time updatedAt?: string // date-time createdBy?: number | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/print-templates/{id} Delete print template Delete a custom print template (system templates cannot be deleted) Path parameters: - `id` (string, required) Response 200 — Default Response ```ts { status?: string data?: { success?: boolean id?: string // uuid } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PATCH /api/v1/print-templates/{id} Update print template Update an existing custom print template (system templates cannot be modified) Path parameters: - `id` (string, required) Request body: ```ts { name?: string description?: string content?: Record isActive?: boolean } ``` Response 200 — Default Response ```ts { status?: string data?: { id?: string // uuid type?: "kitchen_ticket" | "purchase_receipt" | "pre_check" | "order_confirmation" | "station_message" | "sales_report" | "custom" name?: string description?: string | null version?: number isActive?: boolean isSystem?: boolean content?: Record createdAt?: string // date-time updatedAt?: string // date-time createdBy?: number | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/print-templates/type/{type} Get active print template by type Get the currently active print template for a specific type Path parameters: - `type` ("kitchen_ticket" | "purchase_receipt" | "pre_check" | "order_confirmation" | "station_message" | "sales_report" | "custom", required) Response 200 — Default Response ```ts { status?: string data?: { id?: string // uuid type?: "kitchen_ticket" | "purchase_receipt" | "pre_check" | "order_confirmation" | "station_message" | "sales_report" | "custom" name?: string description?: string | null version?: number isActive?: boolean isSystem?: boolean content?: Record createdAt?: string // date-time updatedAt?: string // date-time createdBy?: number | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/template-overrides/{id} Delete location template override Delete a template override; the location then prints with its nearest ancestor's again. Refused (403) when it belongs to a location above the requesting location (X-Location-Id), or to a location the caller cannot access Path parameters: - `id` (string, required) Response 200 — Default Response ```ts { status?: string data?: { success?: boolean id?: string // uuid } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PATCH /api/v1/template-overrides/{id} Update location template override Update an existing template override. Refused (403) when it belongs to a location above the requesting location (X-Location-Id), which only inherits it, or to a location the caller cannot access Path parameters: - `id` (string, required) Request body: ```ts { overrideScope?: "full" | "section" | "settings" customTemplateContent?: Record | null sectionOverrides?: Record | null settingsOverrides?: Record | null isActive?: boolean } ``` Response 200 — Default Response ```ts { status?: string data?: { id?: string // uuid locationId?: number templateType?: "kitchen_ticket" | "purchase_receipt" | "pre_check" | "order_confirmation" | "station_message" | "sales_report" | "custom" overrideScope?: "full" | "section" | "settings" customTemplateContent?: Record | null sectionOverrides?: Record | null settingsOverrides?: Record | null isActive?: boolean createdAt?: string // date-time updatedAt?: string // date-time } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Printer Flows Routing of kitchen tickets between printer groups ## GET /api/v1/locations/{locationId}/printer-flow Get location-level printer flow Returns the persisted location flow, or a synthesised default flow (`isDefault: true`) when no row exists yet. The default flow delegates every dispatch to its prep group, mirroring today's behaviour. **Permissions:** 🔵 `printers.read` Path parameters: - `locationId` (string, required) — Location ID Response 200 — Default Response ```ts { status: "success" data: { id: string | null // uuid scope: "location" | "prep_group" locationId: number prepGroupId: string | null // uuid nodes: Array<{ id: string type: "entry" | "filter" | "fanout" | "terminal" blockType: string position: { … } config: { … } }> connections: Array<{ id: string sourceNodeId: string sourceHandle: string targetNodeId: string }> createdAt: string | null // date-time updatedAt: string | null // date-time isDefault: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/locations/{locationId}/printer-flow Save location-level printer flow Upserts the location-level flow. Creates a row on first save; updates thereafter. Validates the graph (one entry, no cycles, all leaves terminate) and rejects with 400 on failure. **Permissions:** 🟠 `printers.update` Path parameters: - `locationId` (string, required) — Location ID Request body: ```ts { nodes: Array<{ id: string type: "entry" | "filter" | "fanout" | "terminal" blockType: string position: { x: number y: number } config: Record }> connections: Array<{ id: string sourceNodeId: string sourceHandle: string targetNodeId: string }> } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string | null // uuid scope: "location" | "prep_group" locationId: number prepGroupId: string | null // uuid nodes: Array<{ id: string type: "entry" | "filter" | "fanout" | "terminal" blockType: string position: { … } config: { … } }> connections: Array<{ id: string sourceNodeId: string sourceHandle: string targetNodeId: string }> createdAt: string | null // date-time updatedAt: string | null // date-time isDefault: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/locations/{locationId}/printer-groups/{prepGroupId}/flow Get prep group printer flow Returns the persisted prep group flow, or a synthesised default flow (`isDefault: true`) that fans out to all currently connected printers when no row exists yet. **Permissions:** 🔵 `printers.read` Path parameters: - `locationId` (string, required) — Location ID - `prepGroupId` (string, required) — Printer group (prep group) UUID Response 200 — Default Response ```ts { status: "success" data: { id: string | null // uuid scope: "location" | "prep_group" locationId: number prepGroupId: string | null // uuid nodes: Array<{ id: string type: "entry" | "filter" | "fanout" | "terminal" blockType: string position: { … } config: { … } }> connections: Array<{ id: string sourceNodeId: string sourceHandle: string targetNodeId: string }> createdAt: string | null // date-time updatedAt: string | null // date-time isDefault: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/locations/{locationId}/printer-groups/{prepGroupId}/flow Save prep group printer flow Upserts the prep group flow. Validates the graph (one entry, no cycles, all leaves terminate, no location-only blocks). **Permissions:** 🟠 `printers.update` Path parameters: - `locationId` (string, required) — Location ID - `prepGroupId` (string, required) — Printer group (prep group) UUID Request body: ```ts { nodes: Array<{ id: string type: "entry" | "filter" | "fanout" | "terminal" blockType: string position: { x: number y: number } config: Record }> connections: Array<{ id: string sourceNodeId: string sourceHandle: string targetNodeId: string }> } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string | null // uuid scope: "location" | "prep_group" locationId: number prepGroupId: string | null // uuid nodes: Array<{ id: string type: "entry" | "filter" | "fanout" | "terminal" blockType: string position: { … } config: { … } }> connections: Array<{ id: string sourceNodeId: string sourceHandle: string targetNodeId: string }> createdAt: string | null // date-time updatedAt: string | null // date-time isDefault: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Printers Receipt and ticket printer management with CloudPRNT URLs ## POST /api/v1/print-jobs/custom Create a custom print job with Star Micronics Markdown. This endpoint allows sending raw Star Micronics Markdown content directly to a printer. Use this for custom receipt formats, promotional prints, or testing. **Star Micronics Markdown Reference:** - `[C]` - Center alignment - `[L]` - Left alignment - `[R]` - Right alignment - `[D:1]` to `[D:4]` - Font size (1 is normal, 4 is largest) - `[CUT]` - Cut paper - `---` - Horizontal line **Example:** ``` [C][D:2]Welcome to Karma! [C][D:1]--- [L]Your custom content here [CUT] ``` Request body: ```ts { printerGroupId: string // uuid content: string } ``` Response 201 — Custom print job created successfully ```ts { status: "success" data: { correlationId: string // uuid type: "custom" printerGroupId: string // uuid } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/print-jobs/kiosk-order-confirmation Print kiosk order confirmation Print an order confirmation slip on the kiosk's printer. Guest-facing endpoint fired by the storefront confirmation page. Resolves the purchase's kitchen dispatch and prints an order confirmation (order number + QR code) to the specified printer group. **Flow:** 1. Resolves `purchaseIntentId` → kitchen dispatch ID via the purchase's order. 2. Validates the printer group belongs to the location. 3. Sends an `order_confirmation` print job to the group. Request body: ```ts { purchaseIntentId: string // uuid printerId: string // uuid } ``` Response 201 — Print job created successfully ```ts { status: "success" data: { correlationId: string // uuid type: "order_confirmation" printerGroupId: string // uuid } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/print-jobs/kiosk-receipt Print kiosk receipt Auto-print an order receipt on a kiosk's attached printer. Fired by the storefront on the confirmation page when the kiosk's QR code has a `printer` UUID configured. The storefront only knows the `purchaseIntentId`; this endpoint resolves the underlying purchase plus the printer's group and issues a targeted print job so only the kiosk's printer produces the receipt. **Flow:** 1. Resolves `purchase.id` from the given `purchaseIntentId` (scoped to the location). 2. Resolves the printer's group (scoped to the location) so the wrong kiosk can't be targeted. 3. Publishes a `purchase_receipt` print job with `targetPrinterId` set so only that physical printer picks it up. 4. Returns the correlation ID for tracking. Request body: ```ts { purchaseIntentId: string // uuid printerId: string // uuid } ``` Response 201 — Print job created successfully ```ts { status: "success" data: { correlationId: string // uuid type: "purchase_receipt" printerGroupId: string // uuid printerId: string // uuid purchaseId: string } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/print-jobs/kitchen-ticket-reprint Reprint a kitchen ticket for an existing order. This endpoint reprints kitchen tickets for a kitchen dispatch (order). Items are automatically routed to the correct printer groups based on printer group item configuration. **Important:** Unlike the original print, this is a reprint operation that bypasses idempotency checks. Each call will create new print jobs. **Flow:** 1. Fetches kitchen dispatch with items, events, and comments 2. Fetches printer groups for the location 3. Routes items to correct printer groups based on printer_group_items configuration 4. Sends print jobs for each printer group with relevant items Request body: ```ts { kitchenDispatchId: string // uuid printerGroupIds?: string[] targetPrinterId?: string // uuid } ``` Response 201 — Kitchen ticket reprint jobs created successfully ```ts { status: "success" data: { correlationId: string // uuid type: "kitchen_ticket" kitchenDispatchId: string // uuid message?: string } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/print-jobs/order-confirmation Create an order confirmation print job. This endpoint prints an order confirmation slip for a kitchen dispatch (order). Used by POS when "print order confirmation" is enabled. **Order Confirmation Contents:** - Order number (daily ordinal) - Order date/time - QR code for order tracking - Table name (if applicable) **Flow:** 1. Validates printer group belongs to location 2. Fetches kitchen dispatch data 3. Sends print job to printer service via Pub/Sub Request body: ```ts { printerGroupId: string // uuid kitchenDispatchId: string // uuid } ``` Response 201 — Order confirmation print job created successfully ```ts { status: "success" data: { correlationId: string // uuid type: "order_confirmation" printerGroupId: string // uuid kitchenDispatchId: string // uuid } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/print-jobs/pre-check Create a pre-check (pro forma bill) print job for a table. This endpoint prints a pre-check showing all items on a table's tab with totals and a payment QR code. Used when guests want to see their bill before paying. **Flow:** 1. Validates printer group belongs to location 2. Fetches tab with all items, prices, and table info 3. Generates QR code URL for payment 4. Sends complete data to printer service via Pub/Sub Request body: ```ts { printerGroupId: string // uuid tabId: string // uuid selectedKdiItemIds?: string[] } ``` Response 201 — Pre-check print job created successfully ```ts { status: "success" data: { correlationId: string // uuid type: "pre_check" printerGroupId: string // uuid tabId: string // uuid } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/print-jobs/purchase-receipt Create a purchase receipt print job. This endpoint fetches the purchase data from the database and sends a complete print job to the printer service via Pub/Sub. The printer service will render the receipt using the appropriate template. **Flow:** 1. Validates printer group belongs to location 2. Fetches purchase data with line items, VAT breakdown, and location info 3. Sends complete data to printer service via Pub/Sub 4. Returns correlation ID for tracking Request body: ```ts { receiptContext?: { correlationId: string // uuid deviceId: string // uuid mode: "automatic" | "manual" | "reprint" source: "payment_completed" | "receipt_dialog" | "purchase_history" | "retry" presetType?: "none" | "paying_terminal" | "terminal" | "printer" | "invalid" presetId?: string paymentTerminalId?: string // uuid } printerGroupId: string // uuid purchaseId: string } ``` Response 201 — Print job created successfully ```ts { status: "success" data: { correlationId: string // uuid type: "purchase_receipt" | "kitchen_ticket" | "pre_check" | "order_confirmation" | "test_print" | "custom" printerGroupId: string // uuid purchaseId?: string } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/print-jobs/receipt-decisions Record a client-reported receipt skip or request failure. Does not print. Request body: ```ts { requestStatus?: integer purchaseIntentId: string // uuid context: { correlationId: string // uuid deviceId: string // uuid mode: "automatic" | "manual" | "reprint" source: "payment_completed" | "receipt_dialog" | "purchase_history" | "retry" presetType?: "none" | "paying_terminal" | "terminal" | "printer" | "invalid" presetId?: string paymentTerminalId?: string // uuid } reason: "other_device" | "zero_amount" | "no_preset" | "invalid_preset" | "missing_terminal" | "request_failed" } ``` Response 202 — Default Response ```ts { status: "success" data: { accepted: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/print-jobs/receipt-status/{correlationId} Read the receipt job outcome for one request within the current location. Pending includes a request whose job has not been created yet. Path parameters: - `correlationId` (string, required) Response 200 — Default Response ```ts { status: "success" data: { correlationId: string // uuid status: "pending" | "printed" | "failed" | "unknown" } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/print-jobs/refund-receipt Create a refund receipt print job. This endpoint prints a refund receipt for a completed purchase refund. Used from the merchant dashboard when staff want to print a physical refund receipt. **Flow:** 1. Validates printer group belongs to location 2. Sends refund receipt print job to printer service via Pub/Sub 3. Printer service fetches refund data and renders the receipt 4. Returns correlation ID for tracking Request body: ```ts { printerGroupId: string // uuid purchaseRefundId: string } ``` Response 201 — Refund receipt print job created successfully ```ts { status: "success" data: { correlationId: string // uuid type: "refund_receipt" printerGroupId: string // uuid purchaseRefundId: string } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/print-jobs/sales-report Print one business day — or one shift of it — of sales split by payment method on a preparation group's printers. The figures are gathered server-side from confirmed purchases — the request carries only the scope, never the numbers. Tips are excluded, marketplace orders are included, and amounts are net of confirmed refunds with the refunded total printed separately. **Flow:** 1. Validates the target preparation group belongs to the caller's location 2. Resolves the window: a whole business day, or the named shift given by `shiftId` (the current occurrence of either when `date` is omitted) 3. Aggregates sales for the scope and sends the print job to printer service via Pub/Sub Request body: ```ts { printerGroupId: string // uuid date?: string shiftId?: string costCenterId?: number scopeToUser?: boolean attribution?: "rang" | "settled" includeTips?: boolean includeOpenTabs?: boolean } ``` Response 201 — Sales report sent to the printer ```ts { status: "success" data: { correlationId: string // uuid type: "sales_report" printerGroupId: string // uuid } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/print-jobs/station-message Send a station-to-station message ("bong") to a preparation group's printers. Staff at one station (say the bar) send a short message — a location preset such as "HELP ME", or free text — that prints as a bong on another station's printers (say the kitchen). The message is stored as well as printed, so it can later be surfaced on a kitchen display without changing the send path. **Flow:** 1. Validates the target preparation group belongs to the caller's location 2. Stores the message in `station_messages` 3. Sends the print job to printer service via Pub/Sub Request body: ```ts { printerGroupId: string // uuid message: string fromLabel: string tableName?: string costCenterId?: number } ``` Response 201 — Station message sent ```ts { status: "success" data: { correlationId: string // uuid type: "station_message" printerGroupId: string // uuid } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/print-jobs/terminal-receipt Print a receipt on the Adyen terminal. Called from the POS receipt dialog after payment completes. The actual printing is fire-and-forget — this returns 202 immediately. **Flow:** 1. Proxies request to payment-service 2. Payment-service publishes a receipt job for printer-service 3. Returns 202 Accepted with a correlation ID for the receipt-status endpoint Request body: ```ts { receiptContext?: { correlationId: string // uuid deviceId: string // uuid mode: "automatic" | "manual" | "reprint" source: "payment_completed" | "receipt_dialog" | "purchase_history" | "retry" presetType?: "none" | "paying_terminal" | "terminal" | "printer" | "invalid" presetId?: string paymentTerminalId?: string // uuid } purchaseIntentId: string // uuid terminalId: string // uuid } ``` Response 202 — Terminal receipt print initiated ```ts { status: "success" data: { type: "terminal_receipt" purchaseIntentId: string // uuid correlationId?: string // uuid terminalId: string // uuid } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/print-jobs/test Create a test print job. This endpoint sends a test page to the specified printer group. Use this to verify printer connectivity and configuration. **Test Page Contents:** - Service name and timestamp - Custom message (if provided) - Printer configuration info Request body: ```ts { printerGroupId: string // uuid message?: string } ``` Response 201 — Test print job created successfully ```ts { status: "success" data: { correlationId: string // uuid type: "test_print" printerGroupId: string // uuid } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/print-jobs/tab-receipt Print a tab's receipt: the whole tab as one business transaction. printer-service renders it from the tab ledger's tab projection: every line once with its discounts, every payment (and refund) listed, VAT per rate. It is not a fiscal receipt; each payment has its own. **Errors:** - 404 when the tab is not at the caller's location - 422 `tab_has_no_ledger` when the tab has no ledger account, so no tab receipt Request body: ```ts { printerGroupId: string // uuid tabId: string // uuid targetPrinterId?: string // uuid } ``` Response 201 — Tab receipt print job created successfully ```ts { status: "success" data: { correlationId: string // uuid type: "tab_receipt" printerGroupId: string // uuid tabId: string // uuid } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/printer-dashboard Get printer dashboard with summary stats and recent activity Query parameters: - `locationId` (integer, optional) — Location ID. If not provided, uses X-Location-Id header or default location. Response 200 — Successfully retrieved printer dashboard ```ts { status: "success" data: { summary: { activePrinters: integer pendingJobs: integer printingJobs: integer failedJobs24h: integer completedJobs24h: integer } recentActivity: Array<{ jobId?: string jobNumber?: string createdAt?: string // date-time completedAt?: string | null // date-time printer?: { … } | null printerGroup?: { … } currentStatus?: string statusChangedAt?: string // date-time retries?: integer duration?: integer | null contentType?: string contentPreview?: string | null errorMessage?: string | null errorCode?: string | null kitchenDispatch?: { … } | null }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/printer-groups List all printer groups (prep groups) for a location with their printers Query parameters: - `locationId` (integer, optional) — Location ID to filter printer groups. If not provided, uses X-Location-Id header or default location. Response 200 — Successfully retrieved printer groups ```ts { status: "success" data: { printerGroups: Array<{ id: string // uuid name?: string | null type?: string | null color?: string | null defaultItemState?: "pending" | "accepted" | "ready_to_deliver" | null contextPrinterGroupIds?: unknown[] | null isInherited?: boolean sourceLocationId?: integer inheritToChildren?: boolean ownerPrintersServeChildren?: boolean allowedOverrides?: unknown[] locationId: integer createdAt: string // date-time updatedAt: string // date-time metaData?: { … } | null printers: unknown[] }> total: integer } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/printer-groups Create a new printer group (prep group) Query parameters: - `locationId` (integer, optional) — Location ID. If not provided, uses X-Location-Id header or default location. Request body: ```ts { name: string type?: "bar" | "kitchen" | "all" | "none" color?: string | null defaultItemState?: "pending" | "accepted" | "ready_to_deliver" | null } ``` Response 201 — Successfully created printer group ```ts { status: "success" data: { printerGroup: { id: string // uuid name?: string | null type?: string | null color?: string | null defaultItemState?: "pending" | "accepted" | "ready_to_deliver" | null contextPrinterGroupIds?: string[] | null isInherited?: boolean sourceLocationId?: integer inheritToChildren?: boolean ownerPrintersServeChildren?: boolean allowedOverrides?: string[] locationId: integer createdAt: string // date-time updatedAt: string // date-time metaData?: { fontSize?: string | null logotypeUrl?: string | null message?: string | null displayPayQRProforma?: boolean | null displayOrderQRReciept?: boolean | null } | null printers: { … }[] } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/printer-groups/{printerGroupId} Get a printer group by ID with its printers and CloudPRNT URLs Path parameters: - `printerGroupId` (string, required) — Printer Group UUID Response 200 — Successfully retrieved printer group ```ts { status: "success" data: { printerGroup: { id: string // uuid name?: string | null type?: string | null color?: string | null defaultItemState?: "pending" | "accepted" | "ready_to_deliver" | null contextPrinterGroupIds?: string[] | null isInherited?: boolean sourceLocationId?: integer inheritToChildren?: boolean ownerPrintersServeChildren?: boolean allowedOverrides?: string[] locationId: integer createdAt: string // date-time updatedAt: string // date-time metaData?: { fontSize?: string | null logotypeUrl?: string | null message?: string | null displayPayQRProforma?: boolean | null displayOrderQRReciept?: boolean | null } | null printers: { … }[] } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/printer-groups/{printerGroupId} Update a printer group (prep group) name Path parameters: - `printerGroupId` (string, required) — Printer Group UUID Request body: ```ts { name?: string color?: string | null defaultItemState?: "pending" | "accepted" | "ready_to_deliver" | null contextPrinterGroupIds?: string[] | null ownerPrintersServeChildren?: boolean } ``` Response 200 — Successfully updated printer group ```ts { status: "success" data: { printerGroup: { id: string // uuid name?: string | null type?: string | null color?: string | null defaultItemState?: "pending" | "accepted" | "ready_to_deliver" | null contextPrinterGroupIds?: string[] | null isInherited?: boolean sourceLocationId?: integer inheritToChildren?: boolean ownerPrintersServeChildren?: boolean allowedOverrides?: string[] locationId: integer createdAt: string // date-time updatedAt: string // date-time metaData?: { fontSize?: string | null logotypeUrl?: string | null message?: string | null displayPayQRProforma?: boolean | null displayOrderQRReciept?: boolean | null } | null printers: { … }[] } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/printer-groups/{printerGroupId} Delete a printer group (prep group) and all its printers Path parameters: - `printerGroupId` (string, required) — Printer Group UUID Response 200 — Successfully deleted printer group ```ts { status: "success" data: { deleted: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/printer-groups/{printerGroupId}/events List events for a printer group Query parameters: - `limit` (integer, optional) — Number of events to return - `offset` (integer, optional) — Offset for pagination Path parameters: - `printerGroupId` (string, required) — Printer Group UUID Response 200 — Successfully retrieved printer events ```ts { status: "success" data: { events: Array<{ id?: string // uuid printerId?: string | null printerGroupId?: string // uuid printerGroupName?: string | null eventType?: string timestamp?: string // date-time metadata?: { … } | null }> totalCount: integer } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/printer-groups/{printerGroupId}/jobs List print jobs for a printer group Query parameters: - `limit` (integer, optional) — Number of jobs to return - `offset` (integer, optional) — Offset for pagination - `startDate` (string, optional) — Filter jobs created at or after this ISO 8601 datetime - `endDate` (string, optional) — Filter jobs created at or before this ISO 8601 datetime Path parameters: - `printerGroupId` (string, required) — Printer Group UUID Response 200 — Successfully retrieved print jobs ```ts { status: "success" data: { jobs: Array<{ id?: string // uuid printerGroupId?: string // uuid printerGroupName?: string | null claimedByPrinter?: string | null status?: string retries?: integer metadata?: { … } | null content?: unknown | null createdAt?: string // date-time updatedAt?: string // date-time }> totalCount: integer } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/printer-groups/{printerGroupId}/jobs/{jobId} Get a single print job by ID with its events timeline Path parameters: - `printerGroupId` (string, required) — Printer Group UUID - `jobId` (string, required) — Print Job UUID Response 200 — Successfully retrieved print job with events ```ts { status: "success" data: { job: { id?: string // uuid printerGroupId?: string // uuid printerGroupName?: string | null claimedByPrinter?: string | null status?: string retries?: integer metadata?: Record | null content?: unknown | null createdAt?: string // date-time updatedAt?: string // date-time events?: { … }[] } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/printer-groups/{printerGroupId}/printers Add a printer to an existing printer group Path parameters: - `printerGroupId` (string, required) — Printer Group UUID Request body: ```ts { type: "tsp654" | "tsp100" | "any" | "v400m" | "v400cplus" | "s1f2" | "mcprint3" terminalId?: string // uuid } ``` Response 201 — Successfully added printer to group ```ts { status: "success" data: { printer: { id: string // uuid printerGroupId?: string | null // uuid printerGroupIds?: string[] secondaryPrinterGroupIds?: string[] locationId: integer name?: string | null type?: "tsp654" | "tsp100" | "any" | "v400m" | "v400cplus" | "s1f2" | "mcprint3" status?: string | null state: "online" | "offline" idle?: boolean | null cloudPrntUrl: string // uri createdAt: string // date-time updatedAt: string // date-time } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/printers List all printers for a location with CloudPRNT URLs Query parameters: - `locationId` (integer, optional) — Location ID to filter printers. If not provided, uses X-Location-Id header or default location. Response 200 — Successfully retrieved printers ```ts { status: "success" data: { printers: Array<{ id: string // uuid printerGroupId?: string | null // uuid printerGroupIds?: unknown[] secondaryPrinterGroupIds?: unknown[] locationId: integer name?: string | null type?: "tsp654" | "tsp100" | "any" | "v400m" | "v400cplus" | "s1f2" | "mcprint3" status?: string | null state: "online" | "offline" idle?: boolean | null cloudPrntUrl: string // uri createdAt: string // date-time updatedAt: string // date-time }> total: integer } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/printers Create a new printer (optionally assign to a printer group) Query parameters: - `locationId` (integer, optional) — Location ID. If not provided, uses X-Location-Id header or default location. Request body: ```ts { printerGroupId?: string | null // uuid type: "tsp654" | "tsp100" | "any" | "v400m" | "v400cplus" | "s1f2" | "mcprint3" name?: string terminalId?: string // uuid } ``` Response 201 — Successfully created printer ```ts { status: "success" data: { printer: { id: string // uuid printerGroupId?: string | null // uuid printerGroupIds?: string[] secondaryPrinterGroupIds?: string[] locationId: integer name?: string | null type?: "tsp654" | "tsp100" | "any" | "v400m" | "v400cplus" | "s1f2" | "mcprint3" status?: string | null state: "online" | "offline" idle?: boolean | null cloudPrntUrl: string // uri createdAt: string // date-time updatedAt: string // date-time } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/printers/{printerId} Get a single printer by ID Query parameters: - `locationId` (integer, optional) — Location ID for access verification. If not provided, uses X-Location-Id header or default location. Path parameters: - `printerId` (string, required) — Printer UUID Response 200 — Successfully retrieved printer ```ts { status: "success" data: { printer: { id: string // uuid printerGroupId?: string | null // uuid printerGroupIds?: string[] secondaryPrinterGroupIds?: string[] locationId: integer name?: string | null type?: "tsp654" | "tsp100" | "any" | "v400m" | "v400cplus" | "s1f2" | "mcprint3" status?: string | null state: "online" | "offline" idle?: boolean | null cloudPrntUrl: string // uri createdAt: string // date-time updatedAt: string // date-time } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/printers/{printerId} Update a printer (e.g. reassign to a different printer group) Path parameters: - `printerId` (string, required) — Printer UUID Request body: ```ts { name?: string type?: "tsp654" | "tsp100" | "any" | "v400m" | "v400cplus" | "s1f2" | "mcprint3" printerGroupId?: string // uuid } ``` Response 200 — Successfully updated printer ```ts { status: "success" data: { printer: { id: string // uuid printerGroupId?: string | null // uuid printerGroupIds?: string[] secondaryPrinterGroupIds?: string[] locationId: integer name?: string | null type?: "tsp654" | "tsp100" | "any" | "v400m" | "v400cplus" | "s1f2" | "mcprint3" status?: string | null state: "online" | "offline" idle?: boolean | null cloudPrntUrl: string // uri createdAt: string // date-time updatedAt: string // date-time } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/printers/{printerId} Delete a printer Path parameters: - `printerId` (string, required) — Printer UUID Response 200 — Successfully deleted printer ```ts { status: "success" data: { deleted: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/printers/{printerId}/events List events for a single printer (online, offline, created, deleted) Query parameters: - `limit` (integer, optional) — Number of events to return - `offset` (integer, optional) — Offset for pagination Path parameters: - `printerId` (string, required) — Printer UUID Response 200 — Successfully retrieved printer events ```ts { status: "success" data: { events: Array<{ id?: string // uuid printerId?: string | null printerGroupId?: string // uuid printerGroupName?: string | null eventType?: string timestamp?: string // date-time metadata?: { … } | null }> totalCount: integer } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/printers/{printerId}/groups Set which printer groups a printer belongs to (replaces all memberships) Path parameters: - `printerId` (string, required) — Printer UUID Request body: ```ts { printerGroupIds: string[] secondaryPrinterGroupIds?: string[] } ``` Response 200 — Successfully set printer groups ```ts { status: "success" data: { printerId: string // uuid printerGroupIds: string[] secondaryPrinterGroupIds?: string[] } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/printers/{printerId}/groups/{printerGroupId} Remove a printer from a specific printer group Path parameters: - `printerId` (string, required) — Printer UUID - `printerGroupId` (string, required) — Printer Group UUID to remove the printer from Response 200 — Successfully removed printer from group ```ts { status: "success" data: { removed: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Introduction (/docs) Karma's API lets you read and write the data behind a restaurant's day, and subscribe to events as they happen. ## Base URLs [#base-urls] | Environment | Base URL | | ----------- | ------------------------------------------- | | Production | `https://common-api.karma.life` | | Staging | `https://common-api.staging.karma.life` | | Development | `https://common-api.development.karma.life` | ## Authenticating [#authenticating] Every request carries an API key in the `X-API-Key` header. Keys are prefixed by environment — `karma_live_*` against production, `karma_dev_*` everywhere else. ```bash curl https://common-api.karma.life/api/v1/orders \ -H "X-API-Key: karma_live_..." ``` ## APIs [#apis] ## Guides [#guides] ## Webhooks [#webhooks] Rather than polling, subscribe to events and Karma will POST them to you. [Receiving webhooks](/docs/webhooks) covers the envelope, signature verification and retries; each API's **Events** section documents its payloads. # Creating inventory items (/docs/inventory-items) Use `POST /api/v1/inventory` to create an item and `PUT /api/v1/inventory/{id}` to update it. Send camelCase property names, an `X-API-Key` with `inventory.create` or `inventory.update`, and the location you are acting for in `X-Location-Id`. The REST reference lists every property you can write and every property an item is returned with, each with a description. A returned property is writable only if the write schema lists it; any other key in a write request is ignored, not stored and not reported as an error. ## A minimal item [#a-minimal-item] This request creates a regular 51.00 SEK item with 12% VAT and nothing else. Replace the location ID with an accessible location: ```json { "locationId": 123, "title": "Example coffee", "itemType": "regular", "pricings": [{ "name": "BASE", "price": 51, "order": 0 }], "vatValue": 12 } ``` No tags, category, modifier groups or visibility are needed for an item to have a price and VAT. ## Item type [#item-type] `itemType` is a catalogue classification string of at most 50 characters. It is not a closed enum: any string is stored unchanged, including `""`. Only recognised values carry behaviour: | Value | Effect | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `regular` | An ordinary, separately sold product. Use this unless another value applies. `null` and `""` behave the same. | | `variant` | A sub-product that exists only as an option of another product (a modifier choice). It is left out of delivery-marketplace menus and can be hidden from POS search. | | `delivery` | A shipping-fee product, selectable as the fee for storefront deliveries. | | `gift_card` | Each sale issues a gift card for the line amount. Where an integration provides the location's gift cards, the sale loads the integration's card instead. | Any other string is stored and returned but changes nothing. Creating an item requires the property; it may be empty. Three similarly named fields have different contracts: | Field | Contract | | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | Inventory `itemType` / `items.item_type` | Catalogue string, described above. | | Legacy `item_meta_data.item_type_key` | Reference to the legacy item-type dictionary; not written by the inventory `itemType` input. | | BigQuery `purchase_line_items.item_type` | Recorded sales-line classification; see the [goods export rules](/docs/bigquery#finality-types-and-money). Not interchangeable with catalogue type. | ## Price [#price] Set the price with `pricings` or an existing `pricingGroupId`, then read the item back to check `calculatedPrice`. `basePrice` is accepted on create for older clients but ignored. `pricings` is a list of rules applied in `order`, each starting from the result of the one before. `price` is in **currency units**: `51` means 51.00 SEK in an SEK location. | `name` | Effect | `metadata` | | --------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `BASE` | Sets the price to `price`. Use one BASE rule for a plain price. | none | | `FIXED` | Sets the price to `price`, or adds `price` to it with `mode: "relative"`. | `mode`: `absolute` (default) or `relative` | | `PERCENT` | Changes the price by `percentage` percent. | `percentage`, and optionally `maxAdjustment`, `minPrice`, `maxPrice` in minor units | | `FORMULA` | Sets the price to the result of `formula`. | `formula`, and optionally `variables` | Any other `name` is stored but does not change the price. `scheduleId` limits a rule to a schedule and `channelTypes` to channels (for example `["POS"]`); with neither, the rule always applies. On update, `pricings` replaces the item's rules; send a rule's `id` back to keep its ID. A `pricingGroupId` links an existing [shared price](/docs/shared-prices) instead of the item's own rules; it is unrelated to `variantGroupIds`, which link modifier/option groups. ## VAT [#vat] Set VAT with `vatId` (an ID from `GET /api/v1/vat-rates`) or `vatValue`. If both are sent, `vatId` wins. `vatValue` has **different units on write and read**: | Direction | Unit | 12% is sent/returned as | | --------------------- | ----------------- | ----------------------- | | Write (`POST`, `PUT`) | percentage, 0–100 | `12` | | Read (`GET`) | basis points | `1200` | So never send back the `vatValue` you read; use the returned `vatId` instead. Writes reject values above 100. VAT is not required to save a price. When a create request has no `vatId`, `vatValue` or `accountIds`, the item gets the location country's default VAT, which is often wrong for the product (for example food versus alcohol), so set it explicitly. `takeawayVatId` is a separate rate used when the line is sold as takeaway. ## Tags [#tags] Send tags as an object keyed by tag type: ```json { "CATEGORY_PRIMARY": ["DRINK"], "ALLERGEN": ["NUTS"], "CUSTOM": ["oat-milk"] } ``` * **Tags are references, not upserts.** Tag types and tag keys must already exist. Unknown ones are skipped silently: the request still succeeds and nothing is created. List category tags with `GET /api/v1/category-tags`; create a custom tag first with `POST /api/v1/custom-tags`. Read the item back to confirm which tags were attached. * **`CUSTOM`** resolves existing custom tags in the `CUISINE` custom-tag category. * **`tags` replaces.** When an update includes `tags`, every non-custom tag type you leave out is removed, `CATEGORY_PRIMARY` included. To change allergens without losing the category, send both. Omit `CUSTOM` to keep custom tags; `CUSTOM: []` clears them. * **No tag is required for price or VAT.** `CATEGORY_PRIMARY` groups the item in reports and analytics. * The legacy string-array shape (`["DRINK"]`) passes validation but matches no tag type, so it removes every non-custom tag. Use the object shape. ## Visibility [#visibility] `visibility` (`POS`, `KIOSK`, `TAKEAWAY`, `ONLINE`, `GENERAL`) is a legacy field. It is stored but **nothing reads it**: it does not change where the item is sold, and it is not returned when you read the item. To control where an item can be ordered, use: * channel availability in the menu layouts (returned on the item as `channelAvailability`), * `POST /api/v1/inventory/{itemId}/availability` with `ACTIVATE` or `DEACTIVATE` to put the item on or take it off sale everywhere, * `allowedTabTypes` to limit the kinds of tab it can be ordered onto. `isAvailable` on the item reports the result. Sending `isAvailable` in a create or update request has no effect. ## Existing references [#existing-references] `variantGroupIds`, `printerGroupIds`, `accountIds`, `servingCenterId`, `pricingGroupId`, `vatId` and `takeawayVatId` all reference **existing** resources; none of them creates one. Create or look up the referenced resource first. On update, a supplied array replaces the item's links and `[]` removes them. `vatValue` is the one exception: it finds the VAT rate with that value, creating it if needed. ## Free-form objects [#free-form-objects] Every write property is listed in the reference, and any other key is ignored. Two properties are deliberately free-form, and the reference shows them as such: * **`metadata`** is yours. Karma stores it as you send it, returns it unchanged and reads none of its keys. Put your own identifiers here, prefixed with your company or system name; keys are case-sensitive, and keep article numbers as strings to preserve leading zeros. Never store secrets. * **`context`** holds settings for particular kinds of item. Its public key is `gift_card`, the settings of a `gift_card` item (amounts, card types, fees). Karma's own systems keep other keys in it, so treat it as read-modify-write: an update replaces the whole object. ## Updates [#updates] An update changes only the properties it includes. Relationship arrays, `pricings`, `tags`, `context` and `metadata` replace what is stored. To merge selected top-level `metadata` keys instead, use `PATCH /api/v1/inventory/{id}/metadata`. ## Reading items [#reading-items] `GET /api/v1/inventory` (list) and `GET /api/v1/inventory/{id}` (single item) return the same item, with a few properties only on one of them; the reference marks those "List endpoint only" or "Single-item GET only". `posIntegration` appears only when the location has a POS integration. Money is in currency units, except properties ending in `Cents`, which are minor units (öre). The exception is `ministockCents`: stock in hundredths of a unit, not money. # Channels Sales channels (dine-in, takeaway, delivery) ## GET /api/v1/channels List channels List channels with optional filtering Query parameters: - `locationId` (number, optional) — Filter by location ID - `entityType` ("MENU" | "ITEM" | "LOCATION" | "MODIFIER" | "CATEGORY" | "MENU_LAYOUT" | "VARIANT_GROUP", optional) — Filter by entity type - `entityId` (string, optional) — Filter by entity ID - `channelType` ("KIOSK" | "POS" | "ONLINE" | "TAKEAWAY" | "WOLT" | "FOODORA" | "UBER_EATS", optional) — Filter by specific channel type - `isActive` (boolean, optional) — Filter by active status - `page` (number, optional) — Page number - `limit` (number, optional) — Items per page Response 200 — Default Response ```ts { status: "success" data: { channels: Array<{ id: string entityType: "MENU" | "ITEM" | "LOCATION" | "MODIFIER" | "CATEGORY" | "MENU_LAYOUT" | "VARIANT_GROUP" entityId: string locationId?: number | null isActive: boolean createdAt: string // date-time updatedAt: string // date-time assignments: unknown[] }> pagination: { page: number limit: number total: number totalPages: number } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/channels Create channel Create a new channel with assignments Request body: ```ts { entityType: "MENU" | "ITEM" | "LOCATION" | "MODIFIER" | "CATEGORY" | "MENU_LAYOUT" | "VARIANT_GROUP" entityId: string locationId?: number | null isActive?: boolean assignments: Array<{ channelType: "KIOSK" | "POS" | "ONLINE" | "TAKEAWAY" | "WOLT" | "FOODORA" | "UBER_EATS" isEnabled?: boolean }> } ``` Response 201 — Default Response ```ts { status: "success" data: { id: string entityType: "MENU" | "ITEM" | "LOCATION" | "MODIFIER" | "CATEGORY" | "MENU_LAYOUT" | "VARIANT_GROUP" entityId: string locationId?: number | null isActive: boolean createdAt: string // date-time updatedAt: string // date-time assignments: Array<{ id: string channelId: string channelType: "KIOSK" | "POS" | "ONLINE" | "TAKEAWAY" | "WOLT" | "FOODORA" | "UBER_EATS" isEnabled: boolean createdAt: string // date-time }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/channels/{channelId}/assignments Add channel assignment Add a new channel type assignment to an existing channel Path parameters: - `channelId` (string, required) — Channel ID Request body: ```ts { channelType: "KIOSK" | "POS" | "ONLINE" | "TAKEAWAY" | "WOLT" | "FOODORA" | "UBER_EATS" isEnabled?: boolean } ``` Response 201 — Default Response ```ts { status: "success" data: { id: string channelId: string channelType: "KIOSK" | "POS" | "ONLINE" | "TAKEAWAY" | "WOLT" | "FOODORA" | "UBER_EATS" isEnabled: boolean createdAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/channels/{id} Get channel Get a single channel by ID with its assignments Path parameters: - `id` (string, required) — Channel ID Response 200 — Default Response ```ts { status: "success" data: { id: string entityType: "MENU" | "ITEM" | "LOCATION" | "MODIFIER" | "CATEGORY" | "MENU_LAYOUT" | "VARIANT_GROUP" entityId: string locationId?: number | null isActive: boolean createdAt: string // date-time updatedAt: string // date-time assignments: Array<{ id: string channelId: string channelType: "KIOSK" | "POS" | "ONLINE" | "TAKEAWAY" | "WOLT" | "FOODORA" | "UBER_EATS" isEnabled: boolean createdAt: string // date-time }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/channels/{id} Update channel Update an existing channel **Permissions:** 🟠 `channels.update` Path parameters: - `id` (string, required) — Channel ID Request body: ```ts { isActive?: boolean } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string entityType: "MENU" | "ITEM" | "LOCATION" | "MODIFIER" | "CATEGORY" | "MENU_LAYOUT" | "VARIANT_GROUP" entityId: string locationId?: number | null isActive: boolean createdAt: string // date-time updatedAt: string // date-time assignments: Array<{ id: string channelId: string channelType: "KIOSK" | "POS" | "ONLINE" | "TAKEAWAY" | "WOLT" | "FOODORA" | "UBER_EATS" isEnabled: boolean createdAt: string // date-time }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/channels/{id} Delete channel Delete a channel and its assignments Path parameters: - `id` (string, required) — Channel ID Response 200 — Channel successfully deleted ```ts { status?: "success" data?: { deleted?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/channels/assignments/{assignmentId} Update channel assignment Update an existing channel assignment Path parameters: - `assignmentId` (string, required) — Assignment ID Request body: ```ts { isEnabled?: boolean } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string channelId: string channelType: "KIOSK" | "POS" | "ONLINE" | "TAKEAWAY" | "WOLT" | "FOODORA" | "UBER_EATS" isEnabled: boolean createdAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/channels/assignments/{assignmentId} Delete channel assignment Delete a channel assignment **Permissions:** 🔴 `channels.delete` Path parameters: - `assignmentId` (string, required) — Assignment ID Response 200 — Assignment successfully deleted ```ts { status?: "success" data?: { deleted?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Location operating hours updated Webhook event: `location.hours.updated` Broadcast when a schedule rule for a location changes — lets storefronts and clients refresh open/closed status. Delivered payload: ```ts { id: string // uuid event: "location.hours.updated" resource: "location" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null locationId: integer reason: string timestamp: string // date-time } } ``` # Location Maps Floor plans and seating layouts ## GET /api/v1/location-maps List location maps List location maps with optional filtering **Permissions:** 🔵 `location_maps.read` Query parameters: - `locationId` (number, optional) — Filter by location ID Response 200 — Default Response ```ts { status: "success" data: { maps: Array<{ id: string // uuid locationId: number context: { … } createdAt: string // date-time updatedAt: string // date-time }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/location-maps Create location map Create a new location map **Permissions:** 🟢 `location_maps.create` Request body: ```ts { locationId: number context: Record } ``` Response 201 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number context: Record createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/location-maps/{id} Get location map Get a single location map by ID **Permissions:** 🔵 `location_maps.read` Path parameters: - `id` (string, required) — Location map UUID Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number context: Record createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/location-maps/{id} Update location map Update an existing location map **Permissions:** 🟠 `location_maps.update` Path parameters: - `id` (string, required) — Location map UUID Request body: ```ts { context: Record } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number context: Record createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/location-maps/{id} Delete location map Delete a location map **Permissions:** 🔴 `location_maps.delete` Path parameters: - `id` (string, required) — Location map UUID Response 200 — Location map successfully deleted ```ts { status?: "success" data?: { deleted?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Locations Location management and creation ## GET /api/v1/locations List locations the caller has access to Returns locations the caller has access to. Supports a jsonb-containment filter on `locations.metadata` so a caller who has previously stamped a location with their own external identifier (e.g. POS or integration ID) can look that location up directly — no need to know Karma's internal location ID. Filtering on `metadata` accepts either form: - `?metadata={"someKey":"value"}` — JSON-encoded object - `?metadata.someKey=value` — dotted shorthand, one key per query param Combine with `search` (matches on name, city, address) and standard pagination. **Permissions:** 🔵 `locations.read` Query parameters: - `search` (string, optional) — Search on name, city, or address - `page` (integer, optional) - `limit` (integer, optional) - `metadata` (string, optional) — JSON-encoded jsonb-containment filter on `locations.metadata`. Example: `?metadata={"someKey":"value"}`. The dotted shorthand `?metadata.someKey=value` is also accepted. Response 200 — Default Response ```ts { status: "success" data: { items: Array<{ customFields?: unknown[] metadata?: { … } id: number name: string companyId: number address?: string | null city?: string | null zip?: string | null country?: string | null timezone?: string | null currency?: string | null parentLocationId?: number | null locationType?: "company_hq" | "division" | "region" | "area" | "city" | "store" | "warehouse" | "kitchen" | "office" | "other" isRootCompany?: boolean | null hierarchyMetadata?: { … } | null 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 } } ``` ## POST /api/v1/locations Create a new location Create a new location for the merchant. The location will be linked to the same company as the user's current active location. **Hierarchy Options:** - **Standalone** (default): Independent location with no parent - **Parent**: Set `isRootCompany: true` to mark as a parent/root location - **Child**: Provide `parentLocationId` to create as a child of another location **Payout Account:** A payout account will be created with the specified PSP (STRIPE or ADYEN). For Adyen, `billingEmail` is required. **Required Fields:** - `name`: Location name (1-255 characters) - `address`: Street address - `city`: City name - `country`: Country code (e.g., SE, NO) - `psp`: Payment Service Provider (STRIPE or ADYEN) **Permissions:** 🟢 `locations.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 { name: string address: string city: string country: string psp: "STRIPE" | "ADYEN" parentLocationId?: number locationType?: "company_hq" | "division" | "region" | "area" | "city" | "store" | "warehouse" | "kitchen" | "office" | "other" isRootCompany?: boolean timezone?: string currency?: string zip?: string billingEmail?: string // email metadata?: Record customFields?: unknown | unknown[] } ``` Response 201 — Default Response ```ts { status: "success" data: { location: { customFields?: { … }[] metadata?: { customFields?: { … } } id: number name: string companyId: number address?: string | null city?: string | null zip?: string | null country?: string | null timezone?: string | null currency?: string | null parentLocationId?: number | null locationType?: "company_hq" | "division" | "region" | "area" | "city" | "store" | "warehouse" | "kitchen" | "office" | "other" isRootCompany?: boolean | null hierarchyMetadata?: Record | null createdAt: string // date-time updatedAt: string // date-time } payoutAccount: { id: string | null psp: "STRIPE" | "ADYEN" } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/locations/{locationId} Get a location by ID Get detailed information about a specific location, including its feature toggles. **Features:** By default, the response includes all available features and their status for this location. Set `includeFeatures=false` to exclude features from the response. **Response includes:** - Location details (name, address, timezone, currency, etc.) - Feature toggles with enabled status and optional metadata - Hierarchy information if applicable **Permissions:** 🔵 `locations.read` Query parameters: - `includeFeatures` (boolean, optional) — Include feature toggles in response (default: true) Path parameters: - `locationId` (string, required) — Location ID Response 200 — Default Response ```ts { status: "success" data: { location: { customFields?: { … }[] metadata?: { customFields?: { … } } id: number name: string companyId: number address?: string | null city?: string | null zip?: string | null country?: string | null timezone?: string | null currency?: string | null parentLocationId?: number | null locationType?: "company_hq" | "division" | "region" | "area" | "city" | "store" | "warehouse" | "kitchen" | "office" | "other" isRootCompany?: boolean | null hierarchyMetadata?: Record | null createdAt: string // date-time updatedAt: string // date-time features?: { … }[] } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/locations/{locationId}/details Get canonical merchant-facing location, legal-business, and billing details. Path parameters: - `locationId` (string, required) Response 200 — Default Response ```ts { status?: string data?: { 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 } | null name: string address?: string | null zip?: string | null city?: string | null registeredAddress?: { address: string | null zip: string | null city: string | null } | null country?: string | null organizationNumber?: string | null legalEntityType?: string | null billingEmail?: string | null } meta?: Record } ``` ## GET /api/v1/locations/{locationId}/features/{featureKey} Check if a feature is enabled for a location Quick check if a specific feature is enabled for a location. **Use cases:** - Feature gating in UI components - Conditional logic based on feature availability - Quick feature checks without fetching full location data **Permissions:** 🔵 `locations.read` Path parameters: - `locationId` (string, required) — Location ID - `featureKey` (string, required) — Feature key to check (e.g., loyalty, kds, pos) Response 200 — Default Response ```ts { status: "success" data: { featureKey: string enabled: boolean source?: "location_override" | "subscription_override" | "integration" | "plan" | "recurring_extra" | "feature_default" } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/locations/{locationId}/hierarchy Get location hierarchy tree Get the location hierarchy tree starting from a specific location. Returns the location and all its descendants in a tree structure. **Permissions:** 🔵 `locations.read` Path parameters: - `locationId` (string, required) — Root location ID to get hierarchy for Response 200 — Location hierarchy retrieved ```ts { status: "success" data: { id: number name: string type?: string | null level: number children?: Array<{ id?: number name?: string type?: string | null level?: number }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/locations/{locationId}/hours Get location opening hours Get opening hours for a location including weekly schedule, exceptions, and current open/closed status Path parameters: - `locationId` (string, required) — Location ID Response 200 — Default Response ```ts { status: "success" data: { scheduleId: string | null // uuid isOpenNow: boolean nextStatusChange: string | null // date-time weeklyHours: { monday?: { open?: string close?: string closed: boolean } tuesday?: { open?: string close?: string closed: boolean } wednesday?: { open?: string close?: string closed: boolean } thursday?: { open?: string close?: string closed: boolean } friday?: { open?: string close?: string closed: boolean } saturday?: { open?: string close?: string closed: boolean } sunday?: { open?: string close?: string closed: boolean } } exceptions: Array<{ id?: string // uuid name: string date?: string // date startDate?: string // date endDate?: string // date closed: boolean open?: string close?: string }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/locations/{locationId}/hours Update location opening hours Update or create opening hours for a location. This replaces the entire schedule. **Permissions:** 🟠 `schedules.update` Path parameters: - `locationId` (string, required) — Location ID Request body: ```ts { weeklyHours: { monday?: { open?: string close?: string closed: boolean } tuesday?: { open?: string close?: string closed: boolean } wednesday?: { open?: string close?: string closed: boolean } thursday?: { open?: string close?: string closed: boolean } friday?: { open?: string close?: string closed: boolean } saturday?: { open?: string close?: string closed: boolean } sunday?: { open?: string close?: string closed: boolean } } exceptions?: Array<{ id?: string // uuid name: string date?: string // date startDate?: string // date endDate?: string // date closed: boolean open?: string close?: string }> } ``` Response 200 — Default Response ```ts { status: "success" data: { scheduleId: string | null // uuid isOpenNow: boolean nextStatusChange: string | null // date-time weeklyHours: { monday?: { open?: string close?: string closed: boolean } tuesday?: { open?: string close?: string closed: boolean } wednesday?: { open?: string close?: string closed: boolean } thursday?: { open?: string close?: string closed: boolean } friday?: { open?: string close?: string closed: boolean } saturday?: { open?: string close?: string closed: boolean } sunday?: { open?: string close?: string closed: boolean } } exceptions: Array<{ id?: string // uuid name: string date?: string // date startDate?: string // date endDate?: string // date closed: boolean open?: string close?: string }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/locations/{locationId}/hours Delete location opening hours Remove opening hours for a location (makes it always open) **Permissions:** 🔴 `schedules.delete` Path parameters: - `locationId` (string, required) — Location ID Response 200 — Default Response ```ts { status: "success" data: { deleted: boolean message: string } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PATCH /api/v1/locations/{locationId}/metadata Merge metadata into a location Stores customer- or integration-owned identifiers on a location without requiring dedicated schema fields. The supplied keys are shallow-merged into the existing metadata object. Existing keys not present in the request are preserved; supplied keys replace values with the same name. Use provider- or company-prefixed key names to avoid collisions. Do not store credentials, access tokens, payment data, or other secrets in metadata. **Example:** ```json { "metadata": { "externalShopId": "shop-se-133", "externalCountryCode": "SE" } } ``` **Permissions:** 🟠 `locations.update` Path parameters: - `locationId` (string, required) — Karma location ID Request body: ```ts { metadata: Record } ``` Response 200 — Default Response ```ts { status: "success" data: { success: true } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/locations/{locationId}/order-confirmation-settings Read restaurant order confirmation email rules Path parameters: - `locationId` (integer, required) Response 200 — Default Response ```ts { status: "success" data: { rules: Array<{ id: string // uuid name: string enabled: boolean recipients: unknown[] language: "en" | "sv" subject: string message: string channels: unknown[] itemFilter: "all" | "specific" | "gift_cards" itemIds: unknown[] giftCardModes: unknown[] summaryFields: unknown[] enabledSince?: … }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/locations/{locationId}/order-confirmation-settings Replace restaurant order confirmation email rules Up to 20 rules, 20 recipient emails per rule. Empty channels means all channels. Channel and item filters are combined with AND; matching items use ANY semantics. Enabling applies only to orders first received afterwards. Additive v1 endpoint; safe to retry. Requires settings.update and access to this location. Path parameters: - `locationId` (integer, required) Request body: ```ts { rules: Array<{ id: string // uuid name: string enabled: boolean recipients: string[] language: "en" | "sv" subject: string message: string channels: "ONLINE" | "QR" | "KIOSK" | "POS" | "TAKEAWAY" | "WOLT" | "FOODORA" | "UBER_EATS" | "THIRD_PARTY"[] itemFilter: "all" | "specific" | "gift_cards" itemIds: integer[] giftCardModes: "ecard" | "physical"[] summaryFields: "items" | "variants" | "totals" | "customer" | "giftCard" | "delivery" | "notes"[] }> } ``` Response 200 — Default Response ```ts { status: "success" data: { rules: Array<{ id: string // uuid name: string enabled: boolean recipients: unknown[] language: "en" | "sv" subject: string message: string channels: unknown[] itemFilter: "all" | "specific" | "gift_cards" itemIds: unknown[] giftCardModes: unknown[] summaryFields: unknown[] enabledSince?: … }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/locations/{locationId}/order-confirmation-settings/preview Preview order confirmation HTML and plain text with example data; sends no email Path parameters: - `locationId` (integer, required) Request body: ```ts { id: string // uuid name: string enabled: boolean recipients: string[] language: "en" | "sv" subject: string message: string channels: "ONLINE" | "QR" | "KIOSK" | "POS" | "TAKEAWAY" | "WOLT" | "FOODORA" | "UBER_EATS" | "THIRD_PARTY"[] itemFilter: "all" | "specific" | "gift_cards" itemIds: integer[] giftCardModes: "ecard" | "physical"[] summaryFields: "items" | "variants" | "totals" | "customer" | "giftCard" | "delivery" | "notes"[] } ``` Response 200 — Default Response ```ts { status: "success" data: { subject: string html: string text: string } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/locations/{locationId}/pickup-hours Get location surplus pickup hours Get the surplus pickup schedule for a location. Returns empty weeklyHours when no pickup schedule is configured — callers should fall back to opening hours in that case. Path parameters: - `locationId` (string, required) — Location ID Response 200 — Default Response ```ts { status: "success" data: { scheduleId: string | null // uuid isOpenNow: boolean nextStatusChange: string | null // date-time weeklyHours: { monday?: { open?: string close?: string closed: boolean } tuesday?: { open?: string close?: string closed: boolean } wednesday?: { open?: string close?: string closed: boolean } thursday?: { open?: string close?: string closed: boolean } friday?: { open?: string close?: string closed: boolean } saturday?: { open?: string close?: string closed: boolean } sunday?: { open?: string close?: string closed: boolean } } exceptions: Array<{ id?: string // uuid name: string date?: string // date startDate?: string // date endDate?: string // date closed: boolean open?: string close?: string }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/locations/{locationId}/pickup-hours Update location surplus pickup hours Update or create the surplus pickup schedule for a location. When set, this overrides opening hours for the pickup display in the guest app. **Permissions:** 🟠 `schedules.update` Path parameters: - `locationId` (string, required) — Location ID Request body: ```ts { weeklyHours: { monday?: { open?: string close?: string closed: boolean } tuesday?: { open?: string close?: string closed: boolean } wednesday?: { open?: string close?: string closed: boolean } thursday?: { open?: string close?: string closed: boolean } friday?: { open?: string close?: string closed: boolean } saturday?: { open?: string close?: string closed: boolean } sunday?: { open?: string close?: string closed: boolean } } exceptions?: Array<{ id?: string // uuid name: string date?: string // date startDate?: string // date endDate?: string // date closed: boolean open?: string close?: string }> } ``` Response 200 — Default Response ```ts { status: "success" data: { scheduleId: string | null // uuid isOpenNow: boolean nextStatusChange: string | null // date-time weeklyHours: { monday?: { open?: string close?: string closed: boolean } tuesday?: { open?: string close?: string closed: boolean } wednesday?: { open?: string close?: string closed: boolean } thursday?: { open?: string close?: string closed: boolean } friday?: { open?: string close?: string closed: boolean } saturday?: { open?: string close?: string closed: boolean } sunday?: { open?: string close?: string closed: boolean } } exceptions: Array<{ id?: string // uuid name: string date?: string // date startDate?: string // date endDate?: string // date closed: boolean open?: string close?: string }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/locations/{locationId}/pickup-hours Delete location surplus pickup hours Remove the surplus pickup schedule for a location (reverts to using opening hours for pickup display). **Permissions:** 🔴 `schedules.delete` Path parameters: - `locationId` (string, required) — Location ID Response 200 — Default Response ```ts { status: "success" data: { deleted: boolean message: string } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/locations/{locationId}/pickup-settings Get pickup settings for a location Get pickup settings and exceptional sale times for a location. **Response includes:** - `pickupSettings`: Configuration for time slot selection (enabled, days in advance) - `exceptionalSaleTimes`: Special dates with modified hours or closures (holidays, events, etc.) **Use cases:** - Displaying time slot picker in checkout flow - Determining if pickup time selection is available - Showing modified hours for upcoming dates **Permissions:** 🔵 `locations.read` Path parameters: - `locationId` (string, required) — Location ID Response 200 — Default Response ```ts { status: "success" data: { exceptionalSaleTimes: Array<{ id: number name?: string | null startDate: string // date endDate: string // date closed: boolean saleStartLocal: string saleEndLocal: string pickupStartLocal: string pickupEndLocal: string }> | null pickupSettings: { pickupTimeEnabled: boolean pickupDaysInAdvanceAllowed: number takeawayEnabled: boolean defaultPreparationTimeSeconds: number autoacceptMinutesBeforePickup: number pickupInformation: string | null defaultTakeawayVatId: number | null } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/locations/{locationId}/qr-codes Get QR codes for a location Get all active QR codes (tables) for a location. **Response includes:** - Array of QR codes with uuid and name - Only active QR codes are returned - Sorted alphabetically by name **Use cases:** - Displaying table selection in storefront - Managing table orders - QR code scanning for dine-in orders **Permissions:** 🔵 `locations.read` Path parameters: - `locationId` (string, required) — Location ID Response 200 — Default Response ```ts { status: "success" data: Array<{ customFields?: { … }[] metadata?: { customFields?: { … } } | null uuid: string // uuid name: string groupId?: integer | null groupName?: string | null payment_flow?: "pay_upfront" | "open_tab" | null kiosk?: boolean | null limitation?: "view" | "order" | null joinTabLimitations?: "join_order_and_pay" | "join_pay_only" | "join_disabled" | null serviceFeeOverride?: Record | null menuLayoutIds?: string[] menuLayoutId?: string | null room?: string | null newTabPerGuest?: boolean | null terminal?: string | null printer?: string | null }> meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/locations/{locationId}/set-parent Set parent location Set or change the parent location for a location. This updates the location hierarchy and refreshes the materialized access view. **Permissions:** 🟠 `locations.update` Path parameters: - `locationId` (string, required) — Location ID to update Request body: ```ts { parentLocationId: number | null } ``` Response 200 — Location parent updated successfully ```ts { status: "success" data: { id: number name: string parentLocationId?: number | null hierarchyMetadata?: { level?: number path?: number[] is_root?: boolean } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/locations/{locationId}/settings Get location settings Returns the API-readable location settings used by merchant clients. The `context` object is intentionally limited to `salesReportEmailList` and `printerSettings`; other stored context keys are not exposed. **Permissions:** 🔵 `locations.read` Path parameters: - `locationId` (string, required) Response 200 — Default Response ```ts { status: "success" data: { context: { salesReportEmailList?: string[] printerSettings?: Record } | null salesReportEmailList: string[] phone: string | null website: string | null description: string | null isItemCommentAllowed: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PATCH /api/v1/locations/{locationId}/settings Update location settings Update settings for a specific location. Supports partial updates - only provided fields will be updated. **Supported fields:** - `phone`: Phone number (set to null to clear) - `website`: Website URL (set to null to clear, max 248 chars) - `description`: Location description (set to null to clear, max 1000 chars) - `pickupInformation`: Pickup information text - `context`: Location context JSON (features, configuration, etc.) - `isItemCommentAllowed`: Whether item comments are allowed - `orderPlacements`: Order placement configuration per channel - `paymentFlow`: Default payment flow (`open_tab` or `pay_upfront`, set to null to clear) - `landingPage`: Which page the merchant lands on in karma-merchant-web (`overview` or `surplus`) - `defaultPreparationTimeSeconds`: Default preparation time - `takeawayEnabled`: Whether takeaway is enabled - `autoacceptMinutesBeforePickup`: Minutes before pickup for auto-accept - `pickupTimeEnabled`: Whether time slot selection is enabled - `pickupDaysInAdvanceAllowed`: Days in advance for pickup time selection - `qrRelayTryUrl`: App URL the QR relay tries before the storefront (set to null to clear) **Permissions:** 🟠 `locations.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: - `locationId` (string, required) — Location ID Request body: ```ts { registeredAddress?: { address: string | null zip: string | null city: string | null } | null organizationNumber?: string | null legalEntityType?: string | null address?: string | null zip?: string | null city?: string | null phone?: string | null website?: string | null description?: string | null pickupInformation?: string | null storefrontExperience?: { takeawayOnly?: boolean giftCardLanding?: boolean nameRequired?: boolean copy?: Record } context?: Record | string | null isItemCommentAllowed?: boolean orderPlacements?: Record paymentFlow?: "open_tab" | "pay_upfront" | null landingPage?: "overview" | "surplus" defaultPreparationTimeSeconds?: number takeawayEnabled?: boolean quickUserSwitching?: boolean requirePinOnOpen?: boolean autoacceptMinutesBeforePickup?: number pickupTimeEnabled?: boolean pickupDaysInAdvanceAllowed?: number defaultTakeawayVatId?: number | null logoImageUrl?: string | null featureImageUrl?: string | null orderConfirmationMarkdown?: string | null qrRelayTryUrl?: string | null businessDayStartMinutes?: integer | null tableSelectionEnabled?: boolean metadata?: Record customFields?: unknown | unknown[] } ``` Response 200 — Default Response ```ts { status: "success" data: { success: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/locations/{locationId}/storefront Get location storefront data Get comprehensive storefront data for a location by its ID. **Response includes:** - Location details (name, address, coordinates, contact info) - Current sale status and times - Opening hours (weekly hours, exceptions, schedule ID) - Theme and branding configuration - Feature flags and settings - Loyalty program information - Extended waiting time (if set) This returns the same enriched data as the slug-based endpoint, but uses the numeric location ID. **Permissions:** 🔵 `locations.read` Path parameters: - `locationId` (string, required) — Location ID Response 200 — Default Response ```ts { status: "success" data: { location: { locationId: number name: string description?: string | null latitude: number longitude: number streetAdress?: string | null zip?: string | null city?: string | null website?: string | null phone?: string | null currencyCode: "SEK" | "GBP" | "EUR" | "DKK" | "NOK" | "ISK" | "JPY" logoImageUrl?: string | null featureImageUrl?: string | null orderConfirmationMarkdown?: string | null pickupInformation?: string | null averageRating: number | null saleStatus: "open" | "closed" | "delivering" | "paused" | "undecided" | "waiting" saleStart?: string | null // date-time saleEnd?: string | null // date-time salePickupStart?: string | null // date-time salePickupEnd?: string | null // date-time pickupHours?: { opens_at: string // date-time closes_at: string // date-time } | null nextPickupAt?: string | null // date-time defaultPreparationTimeSeconds: number timeSlotDurationInMinutes?: number | null psp: "adyen" extendedWaitingTime?: { id: number locationId: number endsAt: string // date-time delayInMinutes?: number | null } | null isItemCommentAllowed: boolean takeawayEnabled: boolean isMigratedToPurchase: boolean isDiscountsEnabled: boolean isScanToTabEnabled: boolean isSplitItemEnabled: boolean isUserSessionsEnabled?: boolean defaultTakeawayVatValue?: number | null orderPlacements: Record timezone: string context?: string | null smsNotifications?: boolean swishConfig?: { number?: number } | null themes?: { … }[] | null locationCenters?: { … }[] | null tip?: boolean externalName?: string | null externalTos?: { … }[] | null loyaltyProgram?: { programId: number name: string slug: string baseColor: string pointConversion?: { … } | null } isOpenNow?: boolean nextStatusChange?: string | null // date-time scheduleId?: string | null weeklyHours?: { monday?: { … } tuesday?: { … } wednesday?: { … } thursday?: { … } friday?: { … } saturday?: { … } sunday?: { … } } exceptions?: { … }[] features?: string[] pickupDaysInAdvanceAllowed?: number pickupTimeEnabled?: boolean surplusUserFeeBps?: number customFields?: { … }[] metadata?: { customFields?: { … } } | null } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/locations/{locationId}/storefront-address Update a location's Storefront address Claims and activates the location's public Storefront path. The path is normalized to lowercase and must be unique across active addresses and historical redirect aliases. Repeating the same request is safe. **Permissions:** 🟠 `settings.update` Path parameters: - `locationId` (string, required) — Location ID Request body: ```ts { slug: string } ``` Response 200 — Default Response ```ts { status: "success" data: { slug: string active: true } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/locations/slug/{slug} Get location by slug Get comprehensive location information by its URL slug. **Response includes:** - Location details (name, address, coordinates, contact info) - Current sale status and times - Opening hours (weekly hours, exceptions, schedule ID) - Theme and branding configuration - Feature flags and settings (discounts, scan-to-tab, split items, etc.) - Loyalty program information - Extended waiting time (if set) This is the primary endpoint for storefront applications to fetch location data. The slug is the URL-friendly identifier for the location (e.g., "joes-pizza"). **Permissions:** 🔵 `locations.read` Path parameters: - `slug` (string, required) — Location URL slug (e.g., "joes-pizza") Response 200 — Default Response ```ts { status: "success" data: { location: { locationId: number name: string description?: string | null latitude: number longitude: number streetAdress?: string | null zip?: string | null city?: string | null website?: string | null phone?: string | null currencyCode: "SEK" | "GBP" | "EUR" | "DKK" | "NOK" | "ISK" | "JPY" logoImageUrl?: string | null featureImageUrl?: string | null orderConfirmationMarkdown?: string | null pickupInformation?: string | null averageRating: number | null saleStatus: "open" | "closed" | "delivering" | "paused" | "undecided" | "waiting" saleStart?: string | null // date-time saleEnd?: string | null // date-time salePickupStart?: string | null // date-time salePickupEnd?: string | null // date-time pickupHours?: { opens_at: string // date-time closes_at: string // date-time } | null nextPickupAt?: string | null // date-time defaultPreparationTimeSeconds: number timeSlotDurationInMinutes?: number | null psp: "adyen" extendedWaitingTime?: { id: number locationId: number endsAt: string // date-time delayInMinutes?: number | null } | null isItemCommentAllowed: boolean takeawayEnabled: boolean isMigratedToPurchase: boolean isDiscountsEnabled: boolean isScanToTabEnabled: boolean isSplitItemEnabled: boolean isUserSessionsEnabled?: boolean defaultTakeawayVatValue?: number | null orderPlacements: Record timezone: string context?: string | null smsNotifications?: boolean swishConfig?: { number?: number } | null themes?: { … }[] | null locationCenters?: { … }[] | null tip?: boolean externalName?: string | null externalTos?: { … }[] | null loyaltyProgram?: { programId: number name: string slug: string baseColor: string pointConversion?: { … } | null } isOpenNow?: boolean nextStatusChange?: string | null // date-time scheduleId?: string | null weeklyHours?: { monday?: { … } tuesday?: { … } wednesday?: { … } thursday?: { … } friday?: { … } saturday?: { … } sunday?: { … } } exceptions?: { … }[] features?: string[] pickupDaysInAdvanceAllowed?: number pickupTimeEnabled?: boolean surplusUserFeeBps?: number customFields?: { … }[] metadata?: { customFields?: { … } } | null } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Schedules Operating hours and special schedules ## GET /api/v1/schedules List schedules List schedules with optional filtering Query parameters: - `locationId` (number, optional) — Filter by location ID - `entityType` ("MENU" | "ITEM" | "LOCATION" | "LOCATION_PICKUP" | "MODIFIER" | "CATEGORY" | "MENU_LAYOUT" | "VARIANT_GROUP" | "VOUCHER" | "EARNING_RULE" | "BOOKING" | "LOYALTY_REWARD" | "PRICING" | "MARKETPLACE", optional) — Filter by entity type - `entityId` (string, optional) — Filter by entity ID - `isActive` (boolean, optional) — Filter by active status - `page` (number, optional) — Page number - `limit` (number, optional) — Items per page Response 200 — Default Response ```ts { status: "success" data: { schedules: Array<{ id: string entityType: "MENU" | "ITEM" | "LOCATION" | "LOCATION_PICKUP" | "MODIFIER" | "CATEGORY" | "MENU_LAYOUT" | "VARIANT_GROUP" | "VOUCHER" | "EARNING_RULE" | "BOOKING" | "LOYALTY_REWARD" | "PRICING" | "MARKETPLACE" entityId: string locationId: number name?: string | null description?: string | null isActive: boolean priority: number createdAt: string // date-time updatedAt: string // date-time rules: unknown[] }> pagination: { page: number limit: number total: number totalPages: number } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/schedules Create schedule Create a new schedule with rules **Permissions:** 🟢 `schedules.create` Request body: ```ts { entityType: "MENU" | "ITEM" | "LOCATION" | "LOCATION_PICKUP" | "MODIFIER" | "CATEGORY" | "MENU_LAYOUT" | "VARIANT_GROUP" | "VOUCHER" | "EARNING_RULE" | "BOOKING" | "LOYALTY_REWARD" | "PRICING" | "MARKETPLACE" entityId: string locationId: number name?: string description?: string isActive?: boolean priority?: number rules: Array<{ ruleType: "WEEKDAY" | "DATE_RANGE" | "SPECIFIC_DATE" weekday?: number startTime?: string endTime?: string startDate?: string // date endDate?: string // date validFrom?: string // date validTo?: string // date isAvailable?: boolean name?: string | null }> } ``` Response 201 — Default Response ```ts { status: "success" data: { id: string entityType: "MENU" | "ITEM" | "LOCATION" | "LOCATION_PICKUP" | "MODIFIER" | "CATEGORY" | "MENU_LAYOUT" | "VARIANT_GROUP" | "VOUCHER" | "EARNING_RULE" | "BOOKING" | "LOYALTY_REWARD" | "PRICING" | "MARKETPLACE" entityId: string locationId: number name?: string | null description?: string | null isActive: boolean priority: number createdAt: string // date-time updatedAt: string // date-time rules: Array<{ id: string scheduleId: string ruleType: "WEEKDAY" | "DATE_RANGE" | "SPECIFIC_DATE" weekday?: number | null startTime?: string | null endTime?: string | null startDate?: string | null // date endDate?: string | null // date validFrom?: string | null // date validTo?: string | null // date isAvailable: boolean name?: string | null createdAt: string // date-time }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/schedules/{id} Get schedule Get a single schedule by ID with its rules Path parameters: - `id` (string, required) — Schedule ID Response 200 — Default Response ```ts { status: "success" data: { id: string entityType: "MENU" | "ITEM" | "LOCATION" | "LOCATION_PICKUP" | "MODIFIER" | "CATEGORY" | "MENU_LAYOUT" | "VARIANT_GROUP" | "VOUCHER" | "EARNING_RULE" | "BOOKING" | "LOYALTY_REWARD" | "PRICING" | "MARKETPLACE" entityId: string locationId: number name?: string | null description?: string | null isActive: boolean priority: number createdAt: string // date-time updatedAt: string // date-time rules: Array<{ id: string scheduleId: string ruleType: "WEEKDAY" | "DATE_RANGE" | "SPECIFIC_DATE" weekday?: number | null startTime?: string | null endTime?: string | null startDate?: string | null // date endDate?: string | null // date validFrom?: string | null // date validTo?: string | null // date isAvailable: boolean name?: string | null createdAt: string // date-time }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/schedules/{id} Update schedule Update an existing schedule **Permissions:** 🟠 `schedules.update` Path parameters: - `id` (string, required) — Schedule ID Request body: ```ts { name?: string description?: string | null isActive?: boolean priority?: number } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string entityType: "MENU" | "ITEM" | "LOCATION" | "LOCATION_PICKUP" | "MODIFIER" | "CATEGORY" | "MENU_LAYOUT" | "VARIANT_GROUP" | "VOUCHER" | "EARNING_RULE" | "BOOKING" | "LOYALTY_REWARD" | "PRICING" | "MARKETPLACE" entityId: string locationId: number name?: string | null description?: string | null isActive: boolean priority: number createdAt: string // date-time updatedAt: string // date-time rules: Array<{ id: string scheduleId: string ruleType: "WEEKDAY" | "DATE_RANGE" | "SPECIFIC_DATE" weekday?: number | null startTime?: string | null endTime?: string | null startDate?: string | null // date endDate?: string | null // date validFrom?: string | null // date validTo?: string | null // date isAvailable: boolean name?: string | null createdAt: string // date-time }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/schedules/{id} Delete schedule Delete a schedule and its rules **Permissions:** 🔴 `schedules.delete` Path parameters: - `id` (string, required) — Schedule ID Response 200 — Schedule successfully deleted ```ts { status?: "success" data?: { deleted?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/schedules/{scheduleId}/rules Replace schedule rules Replace all rules for a schedule in a single operation **Permissions:** 🟠 `schedules.update` Path parameters: - `scheduleId` (string, required) — Schedule ID Request body: ```ts { rules: Array<{ ruleType: "WEEKDAY" | "DATE_RANGE" | "SPECIFIC_DATE" weekday?: number startTime?: string endTime?: string startDate?: string // date endDate?: string // date validFrom?: string // date validTo?: string // date isAvailable?: boolean name?: string | null }> } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string entityType: "MENU" | "ITEM" | "LOCATION" | "LOCATION_PICKUP" | "MODIFIER" | "CATEGORY" | "MENU_LAYOUT" | "VARIANT_GROUP" | "VOUCHER" | "EARNING_RULE" | "BOOKING" | "LOYALTY_REWARD" | "PRICING" | "MARKETPLACE" entityId: string locationId: number name?: string | null description?: string | null isActive: boolean priority: number createdAt: string // date-time updatedAt: string // date-time rules: Array<{ id: string scheduleId: string ruleType: "WEEKDAY" | "DATE_RANGE" | "SPECIFIC_DATE" weekday?: number | null startTime?: string | null endTime?: string | null startDate?: string | null // date endDate?: string | null // date validFrom?: string | null // date validTo?: string | null // date isAvailable: boolean name?: string | null createdAt: string // date-time }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/schedules/{scheduleId}/rules Add schedule rule Add a new rule to an existing schedule **Permissions:** 🟢 `schedules.create` Path parameters: - `scheduleId` (string, required) — Schedule ID Request body: ```ts { ruleType: "WEEKDAY" | "DATE_RANGE" | "SPECIFIC_DATE" weekday?: number startTime?: string endTime?: string startDate?: string // date endDate?: string // date validFrom?: string // date validTo?: string // date isAvailable?: boolean name?: string | null } ``` Response 201 — Default Response ```ts { status: "success" data: { id: string scheduleId: string ruleType: "WEEKDAY" | "DATE_RANGE" | "SPECIFIC_DATE" weekday?: number | null startTime?: string | null endTime?: string | null startDate?: string | null // date endDate?: string | null // date validFrom?: string | null // date validTo?: string | null // date isAvailable: boolean name?: string | null createdAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/schedules/check-availability Check availability Check entity availability at a specific time based on schedules Query parameters: - `entityType` ("MENU" | "ITEM" | "LOCATION" | "LOCATION_PICKUP" | "MODIFIER" | "CATEGORY" | "MENU_LAYOUT" | "VARIANT_GROUP" | "VOUCHER" | "EARNING_RULE" | "BOOKING" | "LOYALTY_REWARD" | "PRICING" | "MARKETPLACE", required) — Type of entity to check - `entityId` (string, required) — ID of the entity to check - `locationId` (number, optional) — Location ID - `checkTime` (string, optional) — Time to check availability (defaults to now) Response 200 — Default Response ```ts { status: "success" data: { isAvailable: boolean matchingSchedules: Array<{ id: string entityType: "MENU" | "ITEM" | "LOCATION" | "LOCATION_PICKUP" | "MODIFIER" | "CATEGORY" | "MENU_LAYOUT" | "VARIANT_GROUP" | "VOUCHER" | "EARNING_RULE" | "BOOKING" | "LOYALTY_REWARD" | "PRICING" | "MARKETPLACE" entityId: string locationId: number name?: string | null description?: string | null isActive: boolean priority: number createdAt: string // date-time updatedAt: string // date-time rules: unknown[] }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/schedules/rules/{ruleId} Update schedule rule Update an existing schedule rule **Permissions:** 🟠 `schedules.update` Path parameters: - `ruleId` (string, required) — Rule ID Request body: ```ts { ruleType?: "WEEKDAY" | "DATE_RANGE" | "SPECIFIC_DATE" weekday?: number | null startTime?: string | null endTime?: string | null startDate?: string | null // date endDate?: string | null // date validFrom?: string | null // date validTo?: string | null // date isAvailable?: boolean name?: string | null } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string scheduleId: string ruleType: "WEEKDAY" | "DATE_RANGE" | "SPECIFIC_DATE" weekday?: number | null startTime?: string | null endTime?: string | null startDate?: string | null // date endDate?: string | null // date validFrom?: string | null // date validTo?: string | null // date isAvailable: boolean name?: string | null createdAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/schedules/rules/{ruleId} Delete schedule rule Delete a schedule rule **Permissions:** 🔴 `schedules.delete` Path parameters: - `ruleId` (string, required) — Rule ID Response 200 — Rule successfully deleted ```ts { status?: "success" data?: { deleted?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Loyalty Programs Customer loyalty programs, points, tiers, and rewards ## POST /api/v1/loyalty/{programId}/import Import members from CSV data Import multiple members into a loyalty program from parsed CSV data. Supports name, email, phone, initial points, and enrolled date. Creates users if they do not exist. **Permissions:** 🟢 `loyalty.create` Path parameters: - `programId` (string, required) — Loyalty program ID Request body: ```ts { members: Array<{ name?: string email?: string phone?: string points?: number enrolledAt?: string tier?: string }> } ``` Response 200 — Default Response ```ts { status?: "success" data?: { totalProcessed?: number enrolled?: number alreadyEnrolled?: number failed?: number results?: Array<{ row?: number success?: boolean email?: string | null phone?: string | null error?: string | null userId?: number | null pointsSet?: number | null isNew?: boolean }> } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/loyalty/{programId}/members List loyalty members Get all members of a loyalty program with pagination and search **Permissions:** 🔵 `loyalty.read` Query parameters: - `page` (number, optional) — Page number - `limit` (number, optional) — Items per page - `sort` (string, optional) — Sort field (created_at, current_balance, lifetime_earned, lifetime_redeemed, user_name, user_email, tier_name) - `order` ("asc" | "desc", optional) — Sort order - `search` (string, optional) — Search by name, email, or phone - `tierId` (string, optional) — Filter by tier ID, or "none" for members without a tier - `startDate` (string, optional) — Filter members enrolled on or after this date (ISO 8601) - `endDate` (string, optional) — Filter members enrolled on or before this date (ISO 8601) - `minBalance` (number, optional) — Minimum current balance - `maxBalance` (number, optional) — Maximum current balance - `minLifetimeEarned` (number, optional) — Minimum lifetime earned points - `maxLifetimeEarned` (number, optional) — Maximum lifetime earned points - `minLifetimeRedeemed` (number, optional) — Minimum lifetime redeemed points - `maxLifetimeRedeemed` (number, optional) — Maximum lifetime redeemed points Path parameters: - `programId` (string, required) — Loyalty program ID Response 200 — Default Response ```ts { status?: "success" data?: { members?: Record[] pagination?: { page: number limit: number total: number totalPages: number } } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/loyalty/{programId}/members/{userId} Get member details Get detailed member information including account, user info, tier, and program details Guest-accessible: staff and API-key callers act on the requested user, while a guest caller is scoped to their own account. No loyalty permission is required. Path parameters: - `programId` (string, required) — Loyalty program ID - `userId` (string, required) — User ID Response 200 — Default Response ```ts { status?: "success" data?: Record meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/loyalty/{programId}/members/{userId}/adjust-points Adjust member points Manually adjust a member's points balance (add or remove points) Guest-accessible: staff and API-key callers act on the requested user, while a guest caller is scoped to their own account. No loyalty permission is required. Path parameters: - `programId` (string, required) — Loyalty program ID - `userId` (string, required) — User ID Request body: ```ts { points: number reason: string } ``` Response 200 — Default Response ```ts { status?: "success" data?: { id?: string // uuid pointsAccountId?: number transactionType?: "EARNED_PURCHASE" | "EARNED_ITEM" | "EARNED_VISIT" | "EARNED_ACTION" | "REDEEMED" | "EXPIRED" | "ADJUSTED" | "REVOKED" points?: number balanceAfter?: number purchaseId?: number | null earningRuleId?: number | null rewardRedemptionId?: number | null description?: string | null metadata?: Record | null expiresAt?: string | null // date createdAt?: string // date-time updatedAt?: string // date-time user?: { email?: string | null name?: string | null phone?: string | null } | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/loyalty/{programId}/members/{userId}/redemptions Get member redemptions Get a member's reward redemption history. Guest-accessible: staff and API-key callers act on the requested user, while a guest caller is scoped to their own account. No loyalty permission is required. Path parameters: - `programId` (string, required) — Loyalty program ID - `userId` (string, required) — User ID Response 200 — Default Response ```ts { status?: "success" data?: { redemptions?: Array<{ id?: number pointsAccountId?: number rewardId?: number pointsSpent?: number generatedDiscountId?: number | null generatedCode?: string | null status?: "active" | "used" | "expired" | "revoked" usedAt?: string | null // date-time usedInPurchaseId?: number | null expiresAt?: string | null // date-time createdAt?: string // date-time updatedAt?: string // date-time }> } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/loyalty/{programId}/members/{userId}/tier Assign tier to member Manually assign a tier override to a loyalty program member. Assignment type is inferred from auth context (manual for staff JWT, campaign for API key). Optional expiration date. **Permissions:** 🟠 `loyalty.update` Path parameters: - `programId` (string, required) — Loyalty program ID - `userId` (string, required) — User ID Request body: ```ts { tierId: number expiresAt?: string | null // date-time } ``` Response 200 — Default Response ```ts { status?: "success" data?: { id?: number loyaltyProgramId?: number userId?: number currentBalance?: number lifetimeEarned?: number lifetimeRedeemed?: number lifetimeExpired?: number currentTierId?: number | null tierAchievedAt?: string | null // date-time createdAt?: string // date-time updatedAt?: string // date-time } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/loyalty/{programId}/members/{userId}/tier Remove tier override from member Remove a manual/campaign tier override from a loyalty program member, reverting to automatic point-calculated tier. **Permissions:** 🟠 `loyalty.update` Path parameters: - `programId` (string, required) — Loyalty program ID - `userId` (string, required) — User ID Response 200 — Default Response ```ts { status?: "success" data?: { id?: number loyaltyProgramId?: number userId?: number currentBalance?: number lifetimeEarned?: number lifetimeRedeemed?: number lifetimeExpired?: number currentTierId?: number | null tierAchievedAt?: string | null // date-time createdAt?: string // date-time updatedAt?: string // date-time } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/loyalty/{programId}/members/{userId}/wallet-pass Generate wallet pass Generate Apple or Google Wallet pass for loyalty member Query parameters: - `platform` ("apple" | "google", optional) — apple (default) returns a .pkpass binary; google returns JSON { saveUrl } Path parameters: - `programId` (string, required) — Loyalty program ID - `userId` (string, required) — User ID Response 200 — Apple Wallet Pass file (.pkpass) ```ts string ``` ## POST /api/v1/loyalty/{programId}/redeem/{rewardId} Redeem a reward Redeem a reward for a user, deducting points and generating a voucher code Guest-accessible: staff and API-key callers act on the requested user, while a guest caller is scoped to their own account. No loyalty permission is required. Path parameters: - `programId` (string, required) — Loyalty program ID - `rewardId` (string, required) — Reward catalog item ID Request body: ```ts { userId: number } ``` Response 200 — Default Response ```ts { status?: "success" data?: { id?: number pointsAccountId?: number rewardId?: number pointsSpent?: number generatedDiscountId?: number | null generatedCode?: string | null status?: "active" | "used" | "expired" | "revoked" usedAt?: string | null // date-time usedInPurchaseId?: number | null expiresAt?: string | null // date-time createdAt?: string // date-time updatedAt?: string // date-time } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/loyalty/earning-rules Create earning rule Create a new earning rule for a loyalty program **Permissions:** 🟢 `loyalty.create` Request body: ```ts { loyaltyProgramId: number name: string description?: string ruleType: "SPEND_BASED" | "ITEM_BASED" | "VISIT_BASED" | "ACTION_BASED" isActive?: boolean minTierId?: number pointsPerCurrencyUnit?: number minPurchaseAmount?: number itemId?: number pointsMode?: "FIXED" | "MULTIPLIER" pointsPerItem?: number pointsMultiplier?: number pointsPerVisit?: number minPurchaseForVisit?: number actionType?: string pointsPerAction?: number maxActionsPerPeriod?: number actionPeriod?: string validFrom?: string | null // date validUntil?: string | null // date priority?: number } ``` Response 201 — Default Response ```ts { status?: "success" data?: { id?: number loyaltyProgramId?: number name?: string description?: string | null ruleType?: "SPEND_BASED" | "ITEM_BASED" | "VISIT_BASED" | "ACTION_BASED" isActive?: boolean minTierId?: number | null pointsPerCurrencyUnit?: number | null minPurchaseAmount?: number | null itemId?: number | null pointsMode?: "FIXED" | "MULTIPLIER" pointsPerItem?: number | null pointsMultiplier?: number | null pointsPerVisit?: number | null minPurchaseForVisit?: number | null actionType?: string | null pointsPerAction?: number | null maxActionsPerPeriod?: number | null actionPeriod?: string | null validFrom?: string | null // date validUntil?: string | null // date priority?: number createdAt?: string // date-time updatedAt?: string // date-time } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/loyalty/earning-rules/{id} Update earning rule Update an existing earning rule **Permissions:** 🟠 `loyalty.update` Path parameters: - `id` (string, required) — Earning rule ID Request body: ```ts { name?: string description?: string ruleType?: "SPEND_BASED" | "ITEM_BASED" | "VISIT_BASED" | "ACTION_BASED" isActive?: boolean minTierId?: number pointsPerCurrencyUnit?: number minPurchaseAmount?: number itemId?: number pointsMode?: "FIXED" | "MULTIPLIER" pointsPerItem?: number pointsMultiplier?: number pointsPerVisit?: number minPurchaseForVisit?: number actionType?: string pointsPerAction?: number maxActionsPerPeriod?: number actionPeriod?: string validFrom?: string | null // date validUntil?: string | null // date priority?: number } ``` Response 200 — Default Response ```ts { status?: "success" data?: { id?: number loyaltyProgramId?: number name?: string description?: string | null ruleType?: "SPEND_BASED" | "ITEM_BASED" | "VISIT_BASED" | "ACTION_BASED" isActive?: boolean minTierId?: number | null pointsPerCurrencyUnit?: number | null minPurchaseAmount?: number | null itemId?: number | null pointsMode?: "FIXED" | "MULTIPLIER" pointsPerItem?: number | null pointsMultiplier?: number | null pointsPerVisit?: number | null minPurchaseForVisit?: number | null actionType?: string | null pointsPerAction?: number | null maxActionsPerPeriod?: number | null actionPeriod?: string | null validFrom?: string | null // date validUntil?: string | null // date priority?: number createdAt?: string // date-time updatedAt?: string // date-time } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/loyalty/earning-rules/{id} Delete earning rule Delete an earning rule **Permissions:** 🔴 `loyalty.delete` Path parameters: - `id` (string, required) — Earning rule ID Response 200 — Default Response ```ts { status?: "success" data?: { message?: string id?: number } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/loyalty/enroll Enroll user in program Enroll a single user in a loyalty program. Supports userId, email, or phone identification. Guest-accessible: staff and API-key callers act on the requested user, while a guest caller is scoped to their own account. No loyalty permission is required. Request body: ```ts { programId: number userId?: number email?: string // email phone?: string tierId?: number } ``` Response 200 — Default Response ```ts { status?: "success" data?: { id?: number loyaltyProgramId?: number userId?: number currentBalance?: number lifetimeEarned?: number lifetimeRedeemed?: number lifetimeExpired?: number currentTierId?: number | null tierAchievedAt?: string | null // date-time createdAt?: string // date-time updatedAt?: string // date-time } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/loyalty/enroll/bulk Bulk enroll users Enroll multiple users in a loyalty program at once. When tierId is provided, the tier is applied to ALL users (new and already-enrolled). **Permissions:** 🟢 `loyalty.create` Request body: ```ts { programId: number userIds: number[] tierId?: number } ``` Response 200 — Default Response ```ts { status?: "success" data?: { enrolled?: number alreadyEnrolled?: number accounts?: Array<{ id?: number loyaltyProgramId?: number userId?: number currentBalance?: number lifetimeEarned?: number lifetimeRedeemed?: number lifetimeExpired?: number currentTierId?: number | null tierAchievedAt?: string | null // date-time createdAt?: string // date-time updatedAt?: string // date-time }> } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/loyalty/members/search Search loyalty members Free-text search across the members of the location's active loyalty program. Matches name (substring, per-word and fuzzy), email, phone and loyalty card id. Query parameters: - `query` (string, required) — Name, email, phone or loyalty card id to search for - `limit` (number, optional) — Maximum number of matches to return Response 200 — Matching loyalty members ```ts { status: "success" data: { hasLoyaltyProgram: boolean programId: number | null programName: string | null members: Array<{ userId: number | null email: string | null phone: string | null cardId: string | null isEnrolled: boolean currentBalance: number | null lifetimeEarned: number | null lifetimeRedeemed: number | null currentTier: string | null accountId: number | null name: string | null }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/loyalty/points/award Award points Award points to a user manually. Used for promotional points, customer service adjustments, or manual corrections. Requires the Idempotency-Key header; capped at 10000 points per award by default (programs can override via the max_manual_points_per_mutation setting) and 5 manual awards per member per day. **Permissions:** 🟠 `loyalty.update` Header parameters: - `idempotency-key` (string, required) — Required. Unique key per logical operation (use a UUID). Retries with the same key and body replay the original result. Request body: ```ts { userId: number loyaltyProgramId: number points: number description: string metadata?: Record } ``` Response 201 — Points awarded successfully ```ts { status: "success" data: { transactionId: string points: number description: string createdAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/loyalty/points/claim-purchase Claim loyalty points for a past purchase Retroactively claim loyalty points for a purchase made within the last 30 days. Request body: ```ts { userId: number loyaltyProgramId: number purchaseIntentId: string } ``` Response 200 — Points claimed or no points to award ```ts { transactionId?: string | null pointsAwarded?: number message?: string | null createdAt?: string | null } ``` ## POST /api/v1/loyalty/points/deduct Deduct points Deduct points from a user. Used for manual adjustments or corrections. For redemptions, use the redemption endpoints. Requires the Idempotency-Key header; capped at 10000 points per deduction by default (programs can override via the max_manual_points_per_mutation setting). **Permissions:** 🟠 `loyalty.update` Header parameters: - `idempotency-key` (string, required) — Required. Unique key per logical operation (use a UUID). Retries with the same key and body replay the original result. Request body: ```ts { userId: number loyaltyProgramId: number points: number description: string metadata?: Record } ``` Response 200 — Points deducted successfully ```ts { status: "success" data: { transactionId: string points: number description: string createdAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/loyalty/points/estimate Estimate points for purchase Estimate points that would be earned from a purchase. Used to display earning preview at checkout before the purchase is committed. No authentication required - called from storefront. Request body: ```ts { loyaltyProgramId: number purchaseAmountCents: number itemIds?: number[] userTierId?: number | null } ``` Response 200 — Points estimate calculated ```ts { status: "success" data: { estimatedPoints: number basePoints: number appliedRules: Array<{ id?: number name?: string ruleType?: "SPEND_BASED" | "ITEM_BASED" pointsAwarded?: number }> activeMultiplier?: number | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/loyalty/points/process-purchase Process purchase points Process points from a purchase. Called after a purchase is completed to calculate and award earned points based on earning rules. **Permissions:** 🟠 `loyalty.update` Request body: ```ts { userId: number loyaltyProgramId: number purchaseId: number purchaseAmountCents: number itemIds?: number[] } ``` Response 200 — Purchase processed (no points earned) ```ts { status: "success" data: { pointsAwarded?: number message?: string } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` Response 201 — Points awarded for purchase ```ts { status: "success" data: { transactionId?: string pointsAwarded?: number createdAt?: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/loyalty/programs List loyalty programs List loyalty programs with optional filtering **Permissions:** 🔵 `loyalty.read` Query parameters: - `locationId` (number, optional) — Filter by location ID - `isActive` (boolean, optional) — Filter by active status - `page` (number, optional) — Page number - `limit` (number, optional) — Items per page Response 200 — Default Response ```ts { status: "success" data: { programs: Array<{ id?: number locationId?: number name?: string description?: string | null currency?: string isActive?: boolean startDate?: string | null // date endDate?: string | null // date createdAt?: string // date-time updatedAt?: string // date-time loyaltyCardImage?: string | null loyaltyColor?: string | null isHidden?: boolean isInherited?: boolean isJoined?: boolean }> pagination: { page: number limit: number total: number totalPages: number } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/loyalty/programs Create loyalty program Create a new loyalty program **Permissions:** 🟢 `loyalty.create` Request body: ```ts { name: string description?: string | null currency?: string isActive?: boolean startDate?: string | null // date endDate?: string | null // date loyaltyCardImage?: string | null loyaltyColor?: string | null } ``` Response 201 — Default Response ```ts { status?: "success" data?: { id?: number locationId?: number name?: string description?: string | null currency?: string isActive?: boolean startDate?: string | null // date endDate?: string | null // date createdAt?: string // date-time updatedAt?: string // date-time loyaltyCardImage?: string | null loyaltyColor?: string | null isHidden?: boolean isInherited?: boolean isJoined?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/loyalty/programs/{id} Get loyalty program Get a single loyalty program by ID **Permissions:** 🔵 `loyalty.read` Path parameters: - `id` (string, required) — Loyalty program ID Response 200 — Default Response ```ts { status?: "success" data?: { id?: number locationId?: number name?: string description?: string | null currency?: string isActive?: boolean startDate?: string | null // date endDate?: string | null // date createdAt?: string // date-time updatedAt?: string // date-time loyaltyCardImage?: string | null loyaltyColor?: string | null isHidden?: boolean isInherited?: boolean isJoined?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/loyalty/programs/{id} Update loyalty program Update an existing loyalty program **Permissions:** 🟠 `loyalty.update` Path parameters: - `id` (string, required) — Loyalty program ID Request body: ```ts { name?: string description?: string | null currency?: string isActive?: boolean startDate?: string | null // date endDate?: string | null // date loyaltyCardImage?: string | null loyaltyColor?: string | null } ``` Response 200 — Default Response ```ts { status?: "success" data?: { id?: number locationId?: number name?: string description?: string | null currency?: string isActive?: boolean startDate?: string | null // date endDate?: string | null // date createdAt?: string // date-time updatedAt?: string // date-time loyaltyCardImage?: string | null loyaltyColor?: string | null isHidden?: boolean isInherited?: boolean isJoined?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/loyalty/programs/{id} Delete loyalty program Soft-delete a loyalty program (sets deleted_at, preserves data). Requires loyalty.delete permission. Guest-accessible: staff and API-key callers act on the requested user, while a guest caller is scoped to their own account. No loyalty permission is required. Path parameters: - `id` (string, required) — Loyalty program ID Response 204 — Program soft-deleted successfully ## POST /api/v1/loyalty/programs/{id}/join Join an ancestor location loyalty program Join the loyalty program of an ancestor location (e.g. the parent venue group program) so guests earn and redeem points at this location too. The program must belong to an ancestor of the caller location, and the caller location must not have a program of its own. Idempotent — joining an already-joined program is a no-op. Requires loyalty.update permission. **Permissions:** 🟠 `loyalty.update` Path parameters: - `id` (string, required) — Loyalty program ID Response 200 — Default Response ```ts { status?: "success" data?: { joined: boolean programId: number locationId: number } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/loyalty/programs/{id}/join Leave a joined loyalty program Leave a previously joined ancestor loyalty program. Guests stop earning points at this location; existing points accounts and history are untouched. Idempotent — leaving a program that was not joined is a no-op. Requires loyalty.update permission. **Permissions:** 🟠 `loyalty.update` Path parameters: - `id` (string, required) — Loyalty program ID Response 200 — Default Response ```ts { status?: "success" data?: { left: boolean programId: number locationId: number } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/loyalty/programs/{programId}/accounts List points accounts Get all points accounts for a loyalty program with pagination **Permissions:** 🔵 `loyalty.read` Query parameters: - `page` (number, optional) — Page number - `limit` (number, optional) — Items per page Path parameters: - `programId` (string, required) — Loyalty program ID Response 200 — Default Response ```ts { status?: "success" data?: { accounts: Array<{ id?: number loyaltyProgramId?: number userId?: number currentBalance?: number lifetimeEarned?: number lifetimeRedeemed?: number lifetimeExpired?: number currentTierId?: number | null tierAchievedAt?: string | null // date-time 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/loyalty/programs/{programId}/earning-rules List earning rules Get all earning rules for a loyalty program Guest-accessible: staff and API-key callers act on the requested user, while a guest caller is scoped to their own account. No loyalty permission is required. Path parameters: - `programId` (string, required) — Loyalty program ID Response 200 — Default Response ```ts { status?: "success" data?: { earningRules?: Array<{ id?: number loyaltyProgramId?: number name?: string description?: string | null ruleType?: "SPEND_BASED" | "ITEM_BASED" | "VISIT_BASED" | "ACTION_BASED" isActive?: boolean minTierId?: number | null pointsPerCurrencyUnit?: number | null minPurchaseAmount?: number | null itemId?: number | null pointsMode?: "FIXED" | "MULTIPLIER" pointsPerItem?: number | null pointsMultiplier?: number | null pointsPerVisit?: number | null minPurchaseForVisit?: number | null actionType?: string | null pointsPerAction?: number | null maxActionsPerPeriod?: number | null actionPeriod?: string | null validFrom?: string | null // date validUntil?: string | null // date priority?: number createdAt?: string // date-time updatedAt?: string // date-time }> } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/loyalty/programs/{programId}/rewards List rewards catalog Get the rewards catalog for a loyalty program Guest-accessible: staff and API-key callers act on the requested user, while a guest caller is scoped to their own account. No loyalty permission is required. Query parameters: - `userId` (string, optional) — Optional user ID. When provided, rewards the user has already hit their max_redemptions_per_user for are filtered out. Path parameters: - `programId` (string, required) — Loyalty program ID Response 200 — Default Response ```ts { status?: "success" data?: { rewards?: Array<{ id?: number loyaltyProgramId?: number name?: string description?: string | null pointsCost?: number minTierId?: number | null discountId?: number isActive?: boolean stockQuantity?: number | null maxRedemptionsPerUser?: number | null availableFrom?: string | null // date availableUntil?: string | null // date displayOrder?: number expirationDays?: number | null createdAt?: string // date-time updatedAt?: string // date-time displayTitle?: string | null displayDescription?: string | null userTimesRedeemed?: number | null }> } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/loyalty/programs/{programId}/tiers List loyalty tiers Get all tiers for a loyalty program Guest-accessible: staff and API-key callers act on the requested user, while a guest caller is scoped to their own account. No loyalty permission is required. Path parameters: - `programId` (string, required) — Loyalty program ID Response 200 — Default Response ```ts { status?: "success" data?: { tiers: Array<{ id?: number loyaltyProgramId?: number name?: string description?: string | null pointsThreshold?: number thresholdType?: "LIFETIME_POINTS" | "ROLLING_PERIOD" | "MANUAL" rollingPeriodMonths?: number | null order?: number createdAt?: string // date-time updatedAt?: string // date-time }> } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/loyalty/programs/{programId}/users/{userId}/account Get user points account Get or create a points account for a specific user in a loyalty program Guest-accessible: staff and API-key callers act on the requested user, while a guest caller is scoped to their own account. No loyalty permission is required. Path parameters: - `programId` (string, required) — Loyalty program ID - `userId` (string, required) — User ID Response 200 — Default Response ```ts { status?: "success" data?: { id?: number loyaltyProgramId?: number userId?: number currentBalance?: number lifetimeEarned?: number lifetimeRedeemed?: number lifetimeExpired?: number currentTierId?: number | null tierAchievedAt?: string | null // date-time createdAt?: string // date-time updatedAt?: string // date-time } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/loyalty/programs/{programId}/users/{userId}/redemptions Get user redemptions Get reward redemption history for a user in a loyalty program Guest-accessible: staff and API-key callers act on the requested user, while a guest caller is scoped to their own account. No loyalty permission is required. Path parameters: - `programId` (string, required) — Loyalty program ID - `userId` (string, required) — User ID Response 200 — Default Response ```ts { status?: "success" data?: { redemptions?: Array<{ id?: number pointsAccountId?: number rewardId?: number pointsSpent?: number generatedDiscountId?: number | null generatedCode?: string | null status?: "active" | "used" | "expired" | "revoked" usedAt?: string | null // date-time usedInPurchaseId?: number | null expiresAt?: string | null // date-time createdAt?: string // date-time updatedAt?: string // date-time }> } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/loyalty/rewards Create reward Create a new reward in the catalog **Permissions:** 🟢 `loyalty.create` Request body: ```ts { loyaltyProgramId: number name: string description?: string pointsCost: number minTierId?: number discountId: number isActive?: boolean stockQuantity?: number maxRedemptionsPerUser?: number availableFrom?: string | null // date availableUntil?: string | null // date displayOrder?: number expirationDays?: number } ``` Response 201 — Default Response ```ts { status?: "success" data?: { id?: number loyaltyProgramId?: number name?: string description?: string | null pointsCost?: number minTierId?: number | null discountId?: number isActive?: boolean stockQuantity?: number | null maxRedemptionsPerUser?: number | null availableFrom?: string | null // date availableUntil?: string | null // date displayOrder?: number expirationDays?: number | null createdAt?: string // date-time updatedAt?: string // date-time displayTitle?: string | null displayDescription?: string | null userTimesRedeemed?: number | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/loyalty/rewards/{id} Update reward Update an existing reward in the catalog **Permissions:** 🟠 `loyalty.update` Path parameters: - `id` (string, required) — Reward ID Request body: ```ts { name?: string description?: string pointsCost?: number minTierId?: number discountId?: number isActive?: boolean stockQuantity?: number maxRedemptionsPerUser?: number availableFrom?: string | null // date availableUntil?: string | null // date displayOrder?: number expirationDays?: number } ``` Response 200 — Default Response ```ts { status?: "success" data?: { id?: number loyaltyProgramId?: number name?: string description?: string | null pointsCost?: number minTierId?: number | null discountId?: number isActive?: boolean stockQuantity?: number | null maxRedemptionsPerUser?: number | null availableFrom?: string | null // date availableUntil?: string | null // date displayOrder?: number expirationDays?: number | null createdAt?: string // date-time updatedAt?: string // date-time displayTitle?: string | null displayDescription?: string | null userTimesRedeemed?: number | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/loyalty/rewards/{id} Delete reward Delete a reward from the catalog **Permissions:** 🔴 `loyalty.delete` Path parameters: - `id` (string, required) — Reward ID Response 200 — Default Response ```ts { status?: "success" data?: { message?: string id?: number } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/loyalty/tiers Create loyalty tier Create a new tier for a loyalty program **Permissions:** 🟢 `loyalty.create` Request body: ```ts { loyaltyProgramId: number name: string description?: string | null pointsThreshold?: number thresholdType?: "LIFETIME_POINTS" | "ROLLING_PERIOD" | "MANUAL" rollingPeriodMonths?: number | null tierCardImageUrl?: string | null tierColor?: string | null order?: number } ``` Response 201 — Default Response ```ts { status?: "success" data?: { id?: number loyaltyProgramId?: number name?: string description?: string | null pointsThreshold?: number thresholdType?: "LIFETIME_POINTS" | "ROLLING_PERIOD" | "MANUAL" rollingPeriodMonths?: number | null order?: number createdAt?: string // date-time updatedAt?: string // date-time } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/loyalty/tiers/{id} Update loyalty tier Update an existing loyalty tier **Permissions:** 🟠 `loyalty.update` Path parameters: - `id` (string, required) — Tier ID Request body: ```ts { name?: string description?: string | null pointsThreshold?: number thresholdType?: "LIFETIME_POINTS" | "ROLLING_PERIOD" | "MANUAL" rollingPeriodMonths?: number | null tierCardImageUrl?: string | null tierColor?: string | null order?: number } ``` Response 200 — Default Response ```ts { status?: "success" data?: { id?: number loyaltyProgramId?: number name?: string description?: string | null pointsThreshold?: number thresholdType?: "LIFETIME_POINTS" | "ROLLING_PERIOD" | "MANUAL" rollingPeriodMonths?: number | null order?: number createdAt?: string // date-time updatedAt?: string // date-time } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/loyalty/tiers/{id} Delete loyalty tier Delete a loyalty tier **Permissions:** 🔴 `loyalty.delete` Path parameters: - `id` (string, required) — Tier ID Response 200 — Default Response ```ts { status?: "success" data?: { message?: string id?: number } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/loyalty/transactions List points transactions Get points transactions with optional filters (account, user, type, date range) Guest-accessible: staff and API-key callers act on the requested user, while a guest caller is scoped to their own account. No loyalty permission is required. Query parameters: - `pointsAccountId` (number, optional) — Filter by points account ID - `programId` (number, optional) — Filter by loyalty program ID - `userId` (number, optional) — Filter by user ID - `transactionType` (string, optional) — Filter by transaction type - `startDate` (string, optional) — Filter by start date - `endDate` (string, optional) — Filter by end date - `page` (number, optional) - `limit` (number, optional) Response 200 — Default Response ```ts { status?: "success" data?: { transactions?: Array<{ id?: string // uuid pointsAccountId?: number transactionType?: "EARNED_PURCHASE" | "EARNED_ITEM" | "EARNED_VISIT" | "EARNED_ACTION" | "REDEEMED" | "EXPIRED" | "ADJUSTED" | "REVOKED" points?: number balanceAfter?: number purchaseId?: number | null earningRuleId?: number | null rewardRedemptionId?: number | null description?: string | null metadata?: { … } | null expiresAt?: string | null // date createdAt?: string // date-time updatedAt?: string // date-time user?: { … } | null }> pagination?: { page: number limit: number total: number totalPages: number } } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/loyalty/users/{userId}/programs Get user enrolled programs Get all loyalty programs a user is enrolled in with account details and current tier. Guest-accessible: staff and API-key callers act on the requested user, while a guest caller is scoped to their own account. No loyalty permission is required. Path parameters: - `userId` (string, required) — User ID Response 200 — Default Response ```ts { status: "success" data: { programs: Array<{ id: number locationId: number locationSlug?: string | null name: string description?: string | null currency: string isActive: boolean startDate?: string | null // date endDate?: string | null // date loyaltyCardImage?: string | null loyaltyColor?: string | null hasApplePass: boolean hasGoogleWallet: boolean createdAt: string // date-time updatedAt: string // date-time enrollment: { … } currentTier: … }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/loyalty/validate Validate loyalty members Validate loyalty membership for multiple users by userId, email, phone, or cardId Request body: ```ts { users: Array<{ userId?: number email?: string phone?: string cardId?: string }> } ``` Response 200 — Loyalty validation results ```ts { status: "success" data: { hasLoyaltyProgram: boolean programId: number | null programName: string | null users: Array<{ userId: number | null email: string | null phone: string | null cardId: string | null isEnrolled: boolean currentBalance: number | null lifetimeEarned: number | null lifetimeRedeemed: number | null currentTier: string | null accountId: number | null }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Loyalty Self-Service ## GET /api/v1/loyalty/enrollment-token-preview Preview enrollment token Verify an enrollment JWT token and return card details without consuming (marking as used) the token. Query parameters: - `token` (string, required) — JWT enrollment token from QR code Response 200 — Default Response ```ts { status?: "success" | "error" data?: { valid?: boolean reason?: string | null purchaseIntentId?: string | null locationId?: number | null last4?: string | null brand?: string | null pointsAlreadyAwarded?: boolean | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/loyalty/self-service-enroll Self-service card enrollment Link a payment card to a user account via self-service. Supports two modes: (1) QR flow with enrollment token, (2) confirmation page flow with purchaseIntentId + userId. Request body: ```ts { token?: string purchaseIntentId?: string userId: number } ``` Response 200 — Default Response ```ts { status?: "success" data?: { cardLinked?: boolean enrolled?: boolean pointsAwarded?: number pointsAlreadyAwarded?: boolean | null newBalance?: number | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Loyalty Wallet Passes ## POST /api/v1/loyalty/{programId}/wallet-pass/broadcast-marketing Send a marketing push (headline + body) to every saved Apple/Google Wallet card for this loyalty program Path parameters: - `programId` (string, required) — Loyalty program ID Request body: ```ts { headline: string body: string url?: string // uri } ``` Response 200 — Default Response ## POST /api/v1/loyalty/{programId}/wallet-pass/broadcast-push Broadcast wallet pass push notification to all registered devices for a loyalty program Path parameters: - `programId` (string, required) — Loyalty program ID Response 200 — Default Response ```ts { status?: string data?: { notified?: number serialNumbers?: string[] } } ``` ## GET /api/v1/loyalty/{programId}/wallet-pass/broadcasts List marketing broadcast history for a loyalty program Path parameters: - `programId` (string, required) — Loyalty program ID Response 200 — Default Response ## GET /api/v1/loyalty/{programId}/wallet-pass/stats Wallet adoption stats (member/device count) for a loyalty program Path parameters: - `programId` (string, required) — Loyalty program ID Response 200 — Default Response # Loyalty ## POST /api/v1/loyalty/adyen-card-session Create Adyen zero-value auth session for self-service card linking Creates an Adyen checkout session with amount=0 and storePaymentMethodMode=enabled. The client initialises Adyen Drop-in with the returned `sessionId` and `sessionData`. After the user enters their card details, Adyen tokenises the card and issues a webhook. The resulting token is stored in `user_payment_methods` for future card-linked loyalty recognition. **Requires authenticated user session.** **Permissions:** 🟢 `payment-methods.create` Request body: ```ts { returnUrl: string channel?: "Web" | "iOS" | "Android" } ``` Response 200 — Default Response ```ts { status: "success" data: { sessionId: string sessionData: string clientKey?: string | null environment: string } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/loyalty/finalize-card-session Finalize Adyen zero-auth session and store card token Verifies the Adyen session result and stores the tokenized card in user_payment_methods. Call this after Adyen Drop-in's onPaymentCompleted fires with resultCode 'Authorised'. The sessionResult token (from the Drop-in callback) is verified server-side against Adyen's Sessions API — the card is never stored based solely on client-supplied data. If this is the user's first card, it is automatically set as their default. **Requires authenticated user session.** **Permissions:** 🟢 `payment-methods.create` Request body: ```ts { sessionId: string sessionResult: string } ``` Response 200 — Default Response ```ts { status: "success" data: { card: { id: string brand?: string | null last4?: string | null expiryMonth?: integer | null expiryYear?: integer | null isDefault: boolean isTokenized: boolean nickname?: string | null } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/loyalty/link-payment-method Link payment card to user account Links a payment card (from a purchase) to a user account. This endpoint: 1. Extracts card data from the purchase 2. Finds or creates a user by email/phone 3. Links the card to the user for future recognition 4. Optionally enrolls the user in the loyalty program 5. Awards retroactive points for the triggering purchase **Use case**: After a guest makes a purchase and enters their email/phone, their card is linked so future purchases automatically earn loyalty points. **Permissions:** 🟢 `loyalty.create` Request body: ```ts { purchaseIntentId: string email?: string // email phone?: string enrollInProgram?: boolean locationId?: number } ``` Response 201 — Default Response ```ts { status: "success" data: { user: { id: number isNewUser: boolean } paymentMethod: { linked: boolean id: string | null // uuid brand: string | null last4: string | null } enrollment: { enrolled?: boolean pointsAwarded?: number | null newBalance?: number | null } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/loyalty/staff-link-payment-method Staff-assisted card link with verbal consent Links a payment card to a user account using verbal consent captured by a staff member. This endpoint: 1. Extracts card data from the purchase 2. Links the card to the specified user with verbal consent 3. Records a consent event with the staff member's retailerUserId 4. Auto-enrolls the user in the location's loyalty program if not already enrolled 5. Awards retroactive points for the triggering purchase **Use case**: When a guest doesn't scan the QR code, staff can look up the user and link their card manually after verbal confirmation. **Permissions:** 🟢 `loyalty.create` Request body: ```ts { purchaseIntentId: string userId: number locationId: number consentMethod: "verbal" retailerUserId: number } ``` Response 200 — Default Response ```ts { status: "success" data: { cardLinked: boolean enrolled: boolean pointsAwarded: number } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/loyalty/user-search Search users by name, email, or phone (staff) Search for users matching a query string. Used by staff during the fallback enrollment flow. Query parameters: - **q**: Search term (minimum 2 characters) — matched against email, name, and phone - **purchaseIntentId**: Optional — if provided, the tab member for that purchase is auto-suggested Results are sorted by relevance. Maximum 10 results returned. **Permissions:** 🔵 `users.read` Query parameters: - `q` (string, required) — Search term (name, email, or phone) - `purchaseIntentId` (string, optional) — Optional purchase intent ID for tab member auto-suggest Response 200 — Default Response ```ts { status: "success" data: { users: Array<{ id: number email?: string | null firstName?: string | null lastName?: string | null phone?: string | null }> autoSuggested: { userId?: number firstName?: string | null lastName?: string | null email?: string | null autoSuggested?: true } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/purchases/{purchaseIntentId}/loyalty-status Get loyalty status for a purchase Returns the loyalty scenario after a purchase completes. Used by the frontend to determine what modal/action to show: - **POINTS_EARNED**: User is enrolled, points were awarded automatically - **ENROLLMENT_AVAILABLE**: Card is linked to user, but not enrolled at this location - **CARD_LINK_AVAILABLE**: Card is not linked to any user, prompt for email/phone - **NO_LOYALTY_PROGRAM**: Location has no loyalty program - **PROCESSING**: Still processing (retry later) **Permissions:** 🔵 `loyalty.read` Path parameters: - `purchaseIntentId` (string, required) — Purchase intent ID Response 200 — Default Response ```ts { status: "success" data: { loyaltyScenario: "POINTS_EARNED" | "ENROLLMENT_AVAILABLE" | "CARD_LINK_AVAILABLE" | "NO_LOYALTY_PROGRAM" | "PROCESSING" pointsEarned: { points?: number newBalance?: number } | null enrollmentOffer: { programId?: number programName?: string potentialPoints?: number } | null cardLinkOffer: { programId?: number programName?: string potentialPoints?: number brand?: string | null last4?: string | null } | null user: { id?: number email?: string | null } | null location: { id: number name: string hasLoyaltyProgram: boolean } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Vouchers Discount vouchers and promotions ## GET /api/v1/vouchers List vouchers List all vouchers with optional filters and pagination **Permissions:** 🔵 `vouchers.read` Query parameters: - `location_id` (number, optional) — Filter by location ID - `voucher_type` ("DISCOUNT_CODE" | "GIFT_CARD" | "PROMOTION" | "LOYALTY_REWARD" | "CAMPAIGN" | "REFERRAL", optional) — Filter by voucher type - `enabled` (boolean, optional) — Filter by enabled status - `is_template` (boolean, optional) — Filter by template status - `code` (string, optional) — Exact code match - `search` (string, optional) — Search in name or code - `include_restrictions` (boolean, optional) — Include restrictions in response - `page` (number, optional) - `limit` (number, optional) - `sort` ("id" | "location_id" | "company_id" | "name" | "code" | "enabled" | "voucher_type" | "balance_mode" | "type_specification" | "max_uses" | "times_used" | "per_user_uses" | "min_order_amount_cents" | "max_discount_amount_cents" | "initial_balance_cents" | "remaining_balance_cents" | "can_combine" | "auto_apply" | "always_visible" | "valid_from" | "valid_until" | "display_title" | "display_description" | "display_image_url" | "is_template" | "template_voucher_id" | "created_at" | "updated_at" | "deleted_at", optional) - `order` ("ASC" | "DESC", optional) Response 200 — List of vouchers ```ts { status?: "success" data?: { vouchers?: Array<{ id?: number locationId?: number | null companyId?: number | null name?: string code?: string enabled?: boolean voucherType?: "DISCOUNT_CODE" | "GIFT_CARD" | "PROMOTION" | "LOYALTY_REWARD" | "CAMPAIGN" | "REFERRAL" balanceMode?: "NONE" | "DECREMENTAL" typeSpecification?: { … } maxUses?: number | null timesUsed?: number perUserUses?: number | null minOrderAmountCents?: number | null maxDiscountAmountCents?: number | null initialBalanceCents?: number | null remainingBalanceCents?: number | null canCombine?: boolean autoApply?: boolean validFrom?: string // date-time validUntil?: string | null // date-time displayTitle?: string | null displayDescription?: string | null displayImageUrl?: string | null createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean restrictions?: unknown[] userRestrictions?: unknown[] }> pagination?: { page?: number limit?: number total?: number totalPages?: number } } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## POST /api/v1/vouchers Create voucher Create a new voucher with optional restrictions **Permissions:** 🟢 `vouchers.create` Request body: ```ts { locationId?: number name: string code: string enabled?: boolean voucherType: "DISCOUNT_CODE" | "GIFT_CARD" | "PROMOTION" | "LOYALTY_REWARD" | "CAMPAIGN" | "REFERRAL" balanceMode?: "NONE" | "DECREMENTAL" typeSpecification: { type: "percentage" | "fixed" | "balance" | "free_item" | "fixed_price" | "formula" amountInBasisPoints?: number amountInCents?: number validityDurationDays?: number freeItemIds?: number[] freeItemQuantity?: number freeItemId?: number targetItemIds?: number[] fixedPriceCents?: number formula?: { type?: "buy_x_get_y" | "tiered" | "spend_threshold" | "bundle" | "combo_groups" rules?: Record } } maxUses?: number perUserUses?: number minOrderAmountCents?: number maxDiscountAmountCents?: number initialBalanceCents?: number canCombine?: boolean autoApply?: boolean validFrom?: string | null // date-time validUntil?: string | null // date-time displayTitle?: string displayDescription?: string displayImageUrl?: string alwaysVisible?: boolean isTemplate?: boolean templateVoucherId?: number restrictions?: Array<{ restrictionType: "INCLUDE_ITEM" | "EXCLUDE_ITEM" | "INCLUDE_TAG" | "EXCLUDE_TAG" | "INCLUDE_LAYOUT" | "EXCLUDE_LAYOUT" | "INCLUDE_VARIANT" | "EXCLUDE_VARIANT" | "REQUIRE_ITEM" | "REQUIRE_ITEM_WITH_VARIANTS" | "REQUIRE_TAG" | "MIN_ITEM_COUNT" entityId: number quantity?: number variantIds?: number[] }> userRestrictions?: Array<{ restrictionType: "EMAIL" | "PHONE" | "USER_ID" | "SEGMENT_ID" value: string }> } ``` Response 201 — Voucher created successfully ```ts { status?: "success" data?: { id?: number locationId?: number | null companyId?: number | null name?: string code?: string enabled?: boolean voucherType?: "DISCOUNT_CODE" | "GIFT_CARD" | "PROMOTION" | "LOYALTY_REWARD" | "CAMPAIGN" | "REFERRAL" balanceMode?: "NONE" | "DECREMENTAL" typeSpecification?: { type: "percentage" | "fixed" | "balance" | "free_item" | "fixed_price" | "formula" amountInBasisPoints?: number amountInCents?: number validityDurationDays?: number freeItemIds?: number[] freeItemQuantity?: number freeItemId?: number targetItemIds?: number[] fixedPriceCents?: number formula?: { type?: "buy_x_get_y" | "tiered" | "spend_threshold" | "bundle" | "combo_groups" rules?: { … } } } maxUses?: number | null timesUsed?: number perUserUses?: number | null minOrderAmountCents?: number | null maxDiscountAmountCents?: number | null initialBalanceCents?: number | null remainingBalanceCents?: number | null canCombine?: boolean autoApply?: boolean validFrom?: string // date-time validUntil?: string | null // date-time displayTitle?: string | null displayDescription?: string | null displayImageUrl?: string | null createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean restrictions?: Array<{ id?: number discountId?: number restrictionType?: "INCLUDE_ITEM" | "EXCLUDE_ITEM" | "INCLUDE_TAG" | "EXCLUDE_TAG" | "INCLUDE_LAYOUT" | "EXCLUDE_LAYOUT" | "INCLUDE_VARIANT" | "EXCLUDE_VARIANT" | "REQUIRE_ITEM" | "REQUIRE_ITEM_WITH_VARIANTS" | "REQUIRE_TAG" | "MIN_ITEM_COUNT" entityId?: number quantity?: number | null variantIds?: unknown[] | null createdAt?: string // date-time }> userRestrictions?: Array<{ id?: number discountId?: number restrictionType?: "EMAIL" | "PHONE" | "USER_ID" | "SEGMENT_ID" value?: string createdAt?: string // date-time }> } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## GET /api/v1/vouchers/{id} Get voucher Get single voucher with full details including restrictions and usage statistics **Permissions:** 🔵 `vouchers.read` Path parameters: - `id` (string, required) — Voucher ID Response 200 — Voucher details ```ts { status?: "success" data?: { id?: number locationId?: number | null companyId?: number | null name?: string code?: string enabled?: boolean voucherType?: "DISCOUNT_CODE" | "GIFT_CARD" | "PROMOTION" | "LOYALTY_REWARD" | "CAMPAIGN" | "REFERRAL" balanceMode?: "NONE" | "DECREMENTAL" typeSpecification?: { type: "percentage" | "fixed" | "balance" | "free_item" | "fixed_price" | "formula" amountInBasisPoints?: number amountInCents?: number validityDurationDays?: number freeItemIds?: number[] freeItemQuantity?: number freeItemId?: number targetItemIds?: number[] fixedPriceCents?: number formula?: { type?: "buy_x_get_y" | "tiered" | "spend_threshold" | "bundle" | "combo_groups" rules?: { … } } } maxUses?: number | null timesUsed?: number perUserUses?: number | null minOrderAmountCents?: number | null maxDiscountAmountCents?: number | null initialBalanceCents?: number | null remainingBalanceCents?: number | null canCombine?: boolean autoApply?: boolean validFrom?: string // date-time validUntil?: string | null // date-time displayTitle?: string | null displayDescription?: string | null displayImageUrl?: string | null createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean restrictions?: Array<{ id?: number discountId?: number restrictionType?: "INCLUDE_ITEM" | "EXCLUDE_ITEM" | "INCLUDE_TAG" | "EXCLUDE_TAG" | "INCLUDE_LAYOUT" | "EXCLUDE_LAYOUT" | "INCLUDE_VARIANT" | "EXCLUDE_VARIANT" | "REQUIRE_ITEM" | "REQUIRE_ITEM_WITH_VARIANTS" | "REQUIRE_TAG" | "MIN_ITEM_COUNT" entityId?: number quantity?: number | null variantIds?: unknown[] | null createdAt?: string // date-time }> userRestrictions?: Array<{ id?: number discountId?: number restrictionType?: "EMAIL" | "PHONE" | "USER_ID" | "SEGMENT_ID" value?: string createdAt?: string // date-time }> usageStats?: { totalUses?: number uniqueUsers?: number totalDiscountedCents?: number averageDiscountCents?: number } } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## PUT /api/v1/vouchers/{id} Update voucher Update an existing voucher **Permissions:** 🟠 `vouchers.update` Path parameters: - `id` (string, required) — Voucher ID Request body: ```ts { name?: string code?: string enabled?: boolean voucherType?: "DISCOUNT_CODE" | "GIFT_CARD" | "PROMOTION" | "LOYALTY_REWARD" | "CAMPAIGN" | "REFERRAL" typeSpecification?: { type: "percentage" | "fixed" | "balance" | "free_item" | "fixed_price" | "formula" amountInBasisPoints?: number amountInCents?: number validityDurationDays?: number freeItemIds?: number[] freeItemQuantity?: number freeItemId?: number targetItemIds?: number[] fixedPriceCents?: number formula?: { type?: "buy_x_get_y" | "tiered" | "spend_threshold" | "bundle" | "combo_groups" rules?: Record } } maxUses?: number perUserUses?: number minOrderAmountCents?: number maxDiscountAmountCents?: number canCombine?: boolean autoApply?: boolean displayTitle?: string displayDescription?: string displayImageUrl?: string alwaysVisible?: boolean isTemplate?: boolean templateVoucherId?: number initialBalanceCents?: number remainingBalanceCents?: number balanceMode?: "NONE" | "DECREMENTAL" validFrom?: string | null // date-time validUntil?: string | null // date-time restrictions?: Array<{ restrictionType?: "INCLUDE_ITEM" | "EXCLUDE_ITEM" | "INCLUDE_TAG" | "EXCLUDE_TAG" | "INCLUDE_LAYOUT" | "EXCLUDE_LAYOUT" | "INCLUDE_VARIANT" | "EXCLUDE_VARIANT" | "REQUIRE_ITEM" | "REQUIRE_ITEM_WITH_VARIANTS" | "REQUIRE_TAG" | "MIN_ITEM_COUNT" entityId?: number quantity?: number variantIds?: number[] }> userRestrictions?: Array<{ restrictionType?: "EMAIL" | "PHONE" | "USER_ID" | "SEGMENT_ID" value?: string }> } ``` Response 200 — Voucher updated successfully ```ts { status?: "success" data?: { id?: number locationId?: number | null companyId?: number | null name?: string code?: string enabled?: boolean voucherType?: "DISCOUNT_CODE" | "GIFT_CARD" | "PROMOTION" | "LOYALTY_REWARD" | "CAMPAIGN" | "REFERRAL" balanceMode?: "NONE" | "DECREMENTAL" typeSpecification?: { type: "percentage" | "fixed" | "balance" | "free_item" | "fixed_price" | "formula" amountInBasisPoints?: number amountInCents?: number validityDurationDays?: number freeItemIds?: number[] freeItemQuantity?: number freeItemId?: number targetItemIds?: number[] fixedPriceCents?: number formula?: { type?: "buy_x_get_y" | "tiered" | "spend_threshold" | "bundle" | "combo_groups" rules?: { … } } } maxUses?: number | null timesUsed?: number perUserUses?: number | null minOrderAmountCents?: number | null maxDiscountAmountCents?: number | null initialBalanceCents?: number | null remainingBalanceCents?: number | null canCombine?: boolean autoApply?: boolean validFrom?: string // date-time validUntil?: string | null // date-time displayTitle?: string | null displayDescription?: string | null displayImageUrl?: string | null createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean restrictions?: Array<{ id?: number discountId?: number restrictionType?: "INCLUDE_ITEM" | "EXCLUDE_ITEM" | "INCLUDE_TAG" | "EXCLUDE_TAG" | "INCLUDE_LAYOUT" | "EXCLUDE_LAYOUT" | "INCLUDE_VARIANT" | "EXCLUDE_VARIANT" | "REQUIRE_ITEM" | "REQUIRE_ITEM_WITH_VARIANTS" | "REQUIRE_TAG" | "MIN_ITEM_COUNT" entityId?: number quantity?: number | null variantIds?: unknown[] | null createdAt?: string // date-time }> userRestrictions?: Array<{ id?: number discountId?: number restrictionType?: "EMAIL" | "PHONE" | "USER_ID" | "SEGMENT_ID" value?: string createdAt?: string // date-time }> } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## DELETE /api/v1/vouchers/{id} Delete voucher Soft delete (disable) a voucher **Permissions:** 🔴 `vouchers.delete` Path parameters: - `id` (string, required) — Voucher ID Response 200 — Voucher deleted successfully ```ts { status?: "success" data?: { message?: string id?: number } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## POST /api/v1/vouchers/{id}/send-email Send voucher email Send voucher code and QR code via email to a recipient **Permissions:** 🟠 `vouchers.update` Path parameters: - `id` (string, required) — Voucher ID Request body: ```ts { email: string // email personal_message?: string } ``` Response 200 — Email sent successfully ```ts { status?: "success" data?: { success?: boolean } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## DELETE /api/v1/vouchers/applied/{id} Remove applied voucher Remove an uncommitted applied voucher from a tab Path parameters: - `id` (string, required) — Applied voucher (tab_voucher) ID Response 200 — Voucher removed successfully ```ts { status?: "success" data?: { message?: string id?: string } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## POST /api/v1/vouchers/apply Apply voucher Apply a validated voucher to a tab Request body: ```ts { codes: string[] locationId: number tabId?: string kitchenDispatchId?: string items?: Array<{ inventoryItemId?: number quantity?: number priceCents?: number variantIds?: number[] tagIds?: number[] }> userId?: number email?: string phone?: string } ``` Response 200 — Voucher applied successfully ```ts { status?: "success" data?: { applied?: Array<{ code?: string success?: boolean reservationId?: string tabVoucherId?: number voucherId?: number discountAmountCents?: number }> failed?: Array<{ code?: string success?: boolean error?: string message?: string }> summary?: { totalRequested?: number successCount?: number failureCount?: number } } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## POST /api/v1/vouchers/consume Consume vouchers Consume (commit) previously reserved voucher reservations Request body: ```ts { reservationIds?: string[] tabId?: string } ``` Response 200 — Vouchers consumed successfully ```ts { status?: "success" data?: Record meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## POST /api/v1/vouchers/eligible Get eligible vouchers Get vouchers eligible for a location/tab. Returns promotions and user-restricted vouchers. **Permissions:** 🔵 `vouchers.read` Request body: ```ts { locationId: number tabId?: string userIds?: number[] items?: Array<{ item_id: number quantity: number price_cents: number base_price_cents?: number tags?: number[] variant_ids?: number[] layout_id?: number }> totalAmountCents?: number layoutId?: string } ``` Response 200 — List of eligible vouchers ```ts { status?: "success" data?: { vouchers?: Array<{ id?: number code?: string name?: string voucherType?: "DISCOUNT_CODE" | "GIFT_CARD" | "PROMOTION" | "LOYALTY_REWARD" | "CAMPAIGN" | "REFERRAL" isPromotion?: boolean isUserVoucher?: boolean isLoyaltyRestricted?: boolean alwaysVisible?: boolean autoApply?: boolean canCombine?: boolean typeSpecification?: { … } | null displayTitle?: string | null displayDescription?: string | null displayImageUrl?: string | null maxDiscountAmountCents?: number | null remainingBalanceCents?: number | null maxUses?: number | null timesUsed?: number hasUsesLeft?: boolean restrictions?: unknown[] }> } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## POST /api/v1/vouchers/release Release vouchers Release (cancel) previously reserved voucher reservations Request body: ```ts { reservationIds?: string[] tabId?: string } ``` Response 200 — Vouchers released successfully ```ts { status?: "success" data?: Record meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## GET /api/v1/vouchers/reservations Get voucher reservations Get active voucher reservations for a tab or kitchen dispatch Query parameters: - `tabId` (string, optional) — Tab UUID - `kitchenDispatchId` (string, optional) — Kitchen dispatch UUID Response 200 — List of reservations ```ts { status?: "success" data?: { reservations?: Record[] } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## GET /api/v1/vouchers/search Search vouchers Search vouchers by code or name for POS lookup Query parameters: - `q` (string, required) — Search query (code or name) - `location_id` (number, optional) — Filter by location ID - `limit` (number, optional) — Max results to return Response 200 — Matching vouchers ```ts { status?: "success" data?: { vouchers?: Array<{ id?: number locationId?: number | null companyId?: number | null name?: string code?: string enabled?: boolean voucherType?: "DISCOUNT_CODE" | "GIFT_CARD" | "PROMOTION" | "LOYALTY_REWARD" | "CAMPAIGN" | "REFERRAL" balanceMode?: "NONE" | "DECREMENTAL" typeSpecification?: { … } maxUses?: number | null timesUsed?: number perUserUses?: number | null minOrderAmountCents?: number | null maxDiscountAmountCents?: number | null initialBalanceCents?: number | null remainingBalanceCents?: number | null canCombine?: boolean autoApply?: boolean validFrom?: string // date-time validUntil?: string | null // date-time displayTitle?: string | null displayDescription?: string | null displayImageUrl?: string | null createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean }> } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## POST /api/v1/vouchers/validate Validate voucher(s) Validate one or more voucher codes against a tab or specific kitchen dispatch items. Request body: ```ts { codes: string[] locationId: number includePromotions?: boolean context?: { tabId?: string kitchenDispatchItems?: string[] totalAmountCents?: number priorLineDiscounts?: Array<{ lineId?: string kitchenDispatchItemId?: string discountCents: number }> items?: Array<{ inventoryItemId: number lineId?: string kitchenDispatchItemId?: string quantity: number priceCents: number variantIds?: unknown[] tagIds?: unknown[] }> } } ``` Response 200 — Validation results for all voucher codes ```ts { status?: "success" data?: { validations?: Array<{ code?: string valid?: boolean error?: string | null message?: string | null selectionOptions?: unknown[] | null voucher?: { … } | null discount?: { … } | null newTotalCents?: number | null orderIndex?: number }> } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` # Category Tags Category tag catalog for classifying inventory items ## GET /api/v1/category-tags List category tags Get all global category tags. These are system-defined categories used when creating inventory items. For user-created tags, see the Custom Tags endpoints. **Available types:** - `CATEGORY_PRIMARY` — Top-level categories (e.g. Food, Drinks) - `CATEGORY_SECONDARY` — Sub-categories (e.g. Starters, Mains, Beer) - `CATEGORY_TERTIARY` — Third-level categories for finer grouping Tags are hierarchical: secondary tags have a `parentId` pointing to a primary tag, and tertiary tags point to a secondary tag. **Permissions:** 🔵 `inventory.read` Query parameters: - `type` ("CATEGORY_PRIMARY" | "CATEGORY_SECONDARY" | "CATEGORY_TERTIARY", optional) — Filter by tag type. If omitted, returns all types. Response 200 — Default Response ```ts { status: "success" data: { categoryTags: Array<{ id: number key: string typeId: number typeKey: "CATEGORY_PRIMARY" | "CATEGORY_SECONDARY" | "CATEGORY_TERTIARY" parentId: number | null sortOrder: number }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Custom Tags Labels and categorization ## POST /api/v1/custom-tags Create custom tag Create a new custom tag and link it to an item **Permissions:** 🟢 `custom_tags.create` Request body: ```ts { itemId: number categoryHandle: string key: string } ``` Response 201 — Default Response ```ts { status: "success" data: { id: number key: string categoryHandle: string categoryId: number languageId: number } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/custom-tags/link Link custom tag Link an existing custom tag to an item **Permissions:** 🟠 `custom_tags.update` Request body: ```ts { itemId: number customTagId: number } ``` Response 200 — Default Response ```ts { status: "success" data: { success?: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/custom-tags/search Search custom tags Search for custom tags by query string **Permissions:** 🔵 `custom_tags.read` Query parameters: - `query` (string, required) — Search string (minimum 3 characters) Response 200 — Default Response ```ts { status: "success" data: Array<{ id: number key: string categoryHandle: string categoryId: number languageId: number }> meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/custom-tags/unlink Unlink custom tag Unlink a custom tag from an item **Permissions:** 🟠 `custom_tags.update` Request body: ```ts { itemId: number customTagId: number } ``` Response 200 — Default Response ```ts { status: "success" data: { success?: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Inventory item created Webhook event: `inventory.created` Fired when a new inventory item is created (POST /api/v1/inventory). The full item object is included in `data` along with `locationId`. Delivered payload: ```ts { id: string // uuid event: "inventory.created" resource: "inventory" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null id: integer locationId: integer title: string calculatedPrice: number deleted: boolean madeToOrder: boolean createdDate: string // date-time ingredients?: string | null imageUrl?: string | null externalImageUrl?: string | null integrationId?: number | null integrationItemId?: string | null vatValue?: number | null articleNumber?: string | null itemType?: string | null ministock?: integer | null visibility?: string[] | null tags?: Record | null } } ``` # Inventory item soft-deleted Webhook event: `inventory.deleted` Fired when an inventory item is soft-deleted (DELETE /api/v1/inventory/:id). The row remains in the database with `deleted: true`. Delivered payload: ```ts { id: string // uuid event: "inventory.deleted" resource: "inventory" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null itemId: integer locationId: integer deletedAt: string // date-time } } ``` # Inventory item updated Webhook event: `inventory.updated` Fired when an inventory item changes (PUT /api/v1/inventory/:id), from channel-assignment changes affecting the item (`changes.channels_updated: true`), or on scheduled-pricing transitions (reason-only shape without itemId). Delivered payload: ```ts { id: string // uuid event: "inventory.updated" resource: "inventory" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null itemId: integer changes: Record locationId: integer updatedAt: string // date-time } | { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null locationId: integer reason: "pricing_schedule_transition" | "grouped_pricing_updated" transitionAt: string // date-time } } ``` # Inventory Menu items, products, and modifiers ## GET /api/v1/inventory List inventory items Retrieve a paginated list of inventory items with filtering and search capabilities **Permissions:** 🔵 `inventory.read` Query parameters: - `titleFilter` (string, optional) - `titleMatch` ("contains" | "eq", optional) - `pricingName` (string, optional) - `pricingMatch` ("contains" | "eq", optional) - `source` ("local" | "inherited", optional) - `minPrice` (number, optional) — Minimum effective price in currency units for the requested channel - `maxPrice` (number, optional) — Maximum effective price in currency units for the requested channel - `locationId` (number, optional) — Filter by location ID - `page` (number, optional) — Page number - `limit` (number, optional) — Items per page - `sort` (string, optional) — Field to sort by (title, calculatedPrice, pricing, source, basePrice, createdAt) - `order` ("asc" | "desc", optional) — Sort order - `deleted` (boolean, optional) — Include deleted items - `search` (string, optional) — Search term for title/description - `ids` (integer[], optional) — Return only these inventory item IDs. Repeat the query parameter for multiple IDs. - `tags` (string[], optional) — Filter by tags - `itemType` (string, optional) — Filter by item type - `excludeUnmapped` (boolean, optional) — When true and the location has an active POS integration, drop items that lack an integration_resource_mappings row. Used by storefront-web to hide items that would otherwise produce empty kitchen tickets. - `metadata` (string, optional) — JSON-encoded jsonb-containment filter on `items.metadata`. Example: `?metadata={"someKey":"value"}`. The dotted shorthand `?metadata.someKey=value` is also accepted (all dotted values arrive as strings — use the JSON form for non-string values). - `channel` (string, optional) — Channel context for pricing. Defaults to ONLINE. Use POS, KIOSK, TAKEAWAY, or any custom channel name (e.g. FOODORA) to get channel-specific calculatedPrice. Response 200 — Default Response ```ts { status: "success" data: { items: Array<{ customFields?: unknown[] metadata: { … } pricingGroupId?: string | null // uuid pricingGroupName?: string | null measurement?: { … } | null measuredStockAtoms?: integer | null guestInput?: { … } id: number locationId: number title: string description?: string | null calculatedPricesByChannel?: { … } calculatedPrice?: number itemType?: string | null vatId?: integer | null vatValue?: number | null takeawayVatId?: integer | null takeawayVatValue?: number | null takeawayAccountId?: string | null manualIsAvailable?: boolean ingredientPaused?: boolean isAvailable?: boolean allowedTabTypes?: unknown[] | null ean?: string | null deleted: boolean madeToOrder: boolean tags?: { … } customTagDetails?: unknown[] createdDate: string // date-time updatedAt: string // date-time ingredients?: string | null imageUrl?: string | null externalImageUrl?: string | null images?: unknown[] | null integrationId?: number | null integrationItemId?: string | null context?: { … } | null pricings?: unknown[] purchaseCostCents?: number | null ministock?: number | null ministockCents?: number | null isAlcohol?: boolean variablePricing?: boolean | null minPriceCents?: number | null maxPriceCents?: number | null variableQuantity?: boolean tagIds?: unknown[] accountIds?: unknown[] variantGroupIds?: unknown[] printerGroupIds?: unknown[] servingCenterId?: number | null titlePrep?: string | null prepTimeMinutes?: number | null coursingOrder?: number | null preparation?: string | null printPreparation?: boolean color?: string | null kitchenSortOrder?: integer | null scheduleRules?: unknown[] | null isInherited?: boolean inheritToChildren?: boolean parentItemId?: number | null sourceLocationId?: number | null overriddenFields?: unknown[] | null allowedOverrides?: unknown[] | null inheritanceUpdatedAt?: string | null // date-time isPosSynced?: boolean posOverriddenFields?: unknown[] channelAvailability?: { … } posIntegration?: { … } }> pagination: { page: number limit: number total: number totalPages: number } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/inventory Create inventory item Add a new menu item or product to the inventory **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 { ingredients?: string | null imageUrl?: string | null externalImageUrl?: string | null integrationId?: number | null integrationItemId?: string | null context?: { gift_card?: { template_id?: integer preset_amounts_cents?: integer[] allow_digital?: boolean allow_physical?: boolean allow_pickup?: boolean allow_delivery?: boolean delivery_fee_cents?: integer physical_card_fee_cents?: integer recipient_instructions?: string | null } } | string | null images?: Array<{ url?: string name?: string size?: number type?: string }> | null customFields?: unknown | unknown[] classification?: { primary: string secondary?: string | null tertiary?: string | null } | null purchaseCostCents?: number | null deleted?: boolean madeToOrder?: boolean articleNumber?: string | null pricings?: Array<{ id?: string price?: number name?: string order?: number metadata?: { mode?: "absolute" | "relative" percentage?: number maxAdjustment?: number minPrice?: number maxPrice?: number formula?: string variables?: { … } } | null scheduleId?: string | null channelTypes?: string[] | null }> | null takeawayVatId?: integer | null takeawayAccountId?: string | null ministock?: number | null ministockCents?: number | null servingCenterId?: number | null titlePrep?: string | null prepTimeMinutes?: number | null coursingOrder?: number | null preparation?: string | null printPreparation?: boolean color?: string | null kitchenSortOrder?: integer | null isAlcohol?: boolean variablePricing?: boolean | null variableQuantity?: boolean minPriceCents?: number | null maxPriceCents?: number | null locationId: number pricingGroupId?: string | null // uuid measurement?: { unit: "mg" | "g" | "hg" | "kg" | "ml" | "cl" | "dl" | "l" | "mm" | "cm" | "m" priceQuantityMicros: integer defaultAmountMicros: integer storefrontInput: boolean } | null measuredStockAtoms?: integer | null title: string description?: string | null basePrice?: number itemType: string tags?: string[] | Record vatId?: integer | null vatValue?: number | null accountIds?: string[] printerGroupIds?: string[] variantGroupIds?: number[] visibility?: "POS" | "KIOSK" | "TAKEAWAY" | "ONLINE" | "GENERAL"[] allowedTabTypes?: "table" | "over_the_counter" | "takeaway"[] | null ean?: string | null metadata?: Record } ``` Response 201 — Default Response ```ts { status: "success" data: { 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 } pricingGroupId?: string | null // uuid pricingGroupName?: string | null measurement?: { unit: "mg" | "g" | "hg" | "kg" | "ml" | "cl" | "dl" | "l" | "mm" | "cm" | "m" priceQuantityMicros: integer defaultAmountMicros: integer storefrontInput: boolean } | null measuredStockAtoms?: integer | null guestInput?: { schemaToken: string fields: { … }[] } id: number locationId: number title: string description?: string | null calculatedPricesByChannel?: Record calculatedPrice?: number itemType?: string | null vatId?: integer | null vatValue?: number | null takeawayVatId?: integer | null takeawayVatValue?: number | null takeawayAccountId?: string | null manualIsAvailable?: boolean ingredientPaused?: boolean isAvailable?: boolean allowedTabTypes?: "table" | "over_the_counter" | "takeaway"[] | null ean?: string | null deleted: boolean madeToOrder: boolean tags?: Record customTagDetails?: Array<{ id: number key: string categoryHandle: string }> createdDate: string // date-time updatedAt: string // date-time ingredients?: string | null imageUrl?: string | null externalImageUrl?: string | null images?: Array<{ url?: string name?: string size?: number type?: string }> | null integrationId?: number | null integrationItemId?: string | null context?: { gift_card?: { template_id?: integer preset_amounts_cents?: unknown[] allow_digital?: boolean allow_physical?: boolean allow_pickup?: boolean allow_delivery?: boolean delivery_fee_cents?: integer physical_card_fee_cents?: integer recipient_instructions?: string | null } } | null pricings?: Array<{ id?: string // uuid price?: number name?: string order?: number metadata?: { … } | null validFrom?: string | null validTo?: string | null scheduleId?: string | null channelTypes?: unknown[] | null scheduleActive?: boolean }> purchaseCostCents?: number | null ministock?: number | null ministockCents?: number | null isAlcohol?: boolean variablePricing?: boolean | null minPriceCents?: number | null maxPriceCents?: number | null variableQuantity?: boolean tagIds?: number[] accountIds?: string[] variantGroupIds?: number[] printerGroupIds?: string[] servingCenterId?: number | null titlePrep?: string | null prepTimeMinutes?: number | null coursingOrder?: number | null preparation?: string | null printPreparation?: boolean color?: string | null kitchenSortOrder?: integer | null scheduleRules?: Array<{ id?: string dayOfWeek?: integer startTime?: string endTime?: string active?: boolean }> | null isInherited?: boolean inheritToChildren?: boolean parentItemId?: number | null sourceLocationId?: number | null overriddenFields?: string[] | null allowedOverrides?: string[] | null inheritanceUpdatedAt?: string | null // date-time isPosSynced?: boolean posOverriddenFields?: string[] channelAvailability?: { byChannel?: { KIOSK?: boolean POS?: boolean ONLINE?: boolean TAKEAWAY?: boolean WOLT?: boolean FOODORA?: boolean UBER_EATS?: boolean } online?: boolean qr?: boolean kiosk?: boolean pos?: boolean wolt?: boolean foodora?: boolean uberEats?: boolean } posIntegration?: { integrationConfigId: number provider: string externalItemId: string | null isMapped: boolean } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/inventory/{id} Get inventory item Retrieve detailed information about a specific inventory item by its ID **Permissions:** 🔵 `inventory.read` Path parameters: - `id` (string, required) — Inventory item ID Response 200 — Default Response ```ts { status: "success" data: { 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 } pricingGroupId?: string | null // uuid pricingGroupName?: string | null measurement?: { unit: "mg" | "g" | "hg" | "kg" | "ml" | "cl" | "dl" | "l" | "mm" | "cm" | "m" priceQuantityMicros: integer defaultAmountMicros: integer storefrontInput: boolean } | null measuredStockAtoms?: integer | null guestInput?: { schemaToken: string fields: { … }[] } id: number locationId: number title: string description?: string | null calculatedPricesByChannel?: Record calculatedPrice?: number itemType?: string | null vatId?: integer | null vatValue?: number | null takeawayVatId?: integer | null takeawayVatValue?: number | null takeawayAccountId?: string | null manualIsAvailable?: boolean ingredientPaused?: boolean isAvailable?: boolean allowedTabTypes?: "table" | "over_the_counter" | "takeaway"[] | null ean?: string | null deleted: boolean madeToOrder: boolean tags?: Record customTagDetails?: Array<{ id: number key: string categoryHandle: string }> createdDate: string // date-time updatedAt: string // date-time ingredients?: string | null imageUrl?: string | null externalImageUrl?: string | null images?: Array<{ url?: string name?: string size?: number type?: string }> | null integrationId?: number | null integrationItemId?: string | null context?: { gift_card?: { template_id?: integer preset_amounts_cents?: unknown[] allow_digital?: boolean allow_physical?: boolean allow_pickup?: boolean allow_delivery?: boolean delivery_fee_cents?: integer physical_card_fee_cents?: integer recipient_instructions?: string | null } } | null pricings?: Array<{ id?: string // uuid price?: number name?: string order?: number metadata?: { … } | null validFrom?: string | null validTo?: string | null scheduleId?: string | null channelTypes?: unknown[] | null scheduleActive?: boolean }> purchaseCostCents?: number | null ministock?: number | null ministockCents?: number | null isAlcohol?: boolean variablePricing?: boolean | null minPriceCents?: number | null maxPriceCents?: number | null variableQuantity?: boolean tagIds?: number[] accountIds?: string[] variantGroupIds?: number[] printerGroupIds?: string[] servingCenterId?: number | null titlePrep?: string | null prepTimeMinutes?: number | null coursingOrder?: number | null preparation?: string | null printPreparation?: boolean color?: string | null kitchenSortOrder?: integer | null scheduleRules?: Array<{ id?: string dayOfWeek?: integer startTime?: string endTime?: string active?: boolean }> | null isInherited?: boolean inheritToChildren?: boolean parentItemId?: number | null sourceLocationId?: number | null overriddenFields?: string[] | null allowedOverrides?: string[] | null inheritanceUpdatedAt?: string | null // date-time isPosSynced?: boolean posOverriddenFields?: string[] channelAvailability?: { byChannel?: { KIOSK?: boolean POS?: boolean ONLINE?: boolean TAKEAWAY?: boolean WOLT?: boolean FOODORA?: boolean UBER_EATS?: boolean } online?: boolean qr?: boolean kiosk?: boolean pos?: boolean wolt?: boolean foodora?: boolean uberEats?: boolean } posIntegration?: { integrationConfigId: number provider: string externalItemId: string | null isMapped: boolean } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/inventory/{id} Update inventory item Modify an existing inventory item's details **Permissions:** 🟠 `inventory.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) — Inventory item ID Request body: ```ts { ingredients?: string | null imageUrl?: string | null externalImageUrl?: string | null integrationId?: number | null integrationItemId?: string | null context?: { gift_card?: { template_id?: integer preset_amounts_cents?: integer[] allow_digital?: boolean allow_physical?: boolean allow_pickup?: boolean allow_delivery?: boolean delivery_fee_cents?: integer physical_card_fee_cents?: integer recipient_instructions?: string | null } } | string | null images?: Array<{ url?: string name?: string size?: number type?: string }> | null customFields?: unknown | unknown[] classification?: { primary: string secondary?: string | null tertiary?: string | null } | null purchaseCostCents?: number | null deleted?: boolean madeToOrder?: boolean articleNumber?: string | null pricings?: Array<{ id?: string price?: number name?: string order?: number metadata?: { mode?: "absolute" | "relative" percentage?: number maxAdjustment?: number minPrice?: number maxPrice?: number formula?: string variables?: { … } } | null scheduleId?: string | null channelTypes?: string[] | null }> | null takeawayVatId?: integer | null takeawayAccountId?: string | null ministock?: number | null ministockCents?: number | null servingCenterId?: number | null titlePrep?: string | null prepTimeMinutes?: number | null coursingOrder?: number | null preparation?: string | null printPreparation?: boolean color?: string | null kitchenSortOrder?: integer | null isAlcohol?: boolean variablePricing?: boolean | null variableQuantity?: boolean minPriceCents?: number | null maxPriceCents?: number | null tags?: string[] | Record visibility?: "POS" | "KIOSK" | "TAKEAWAY" | "ONLINE" | "GENERAL"[] vatId?: integer | null vatValue?: number | null accountIds?: string[] printerGroupIds?: string[] variantGroupIds?: number[] pricingGroupId?: string | null // uuid measurement?: { unit: "mg" | "g" | "hg" | "kg" | "ml" | "cl" | "dl" | "l" | "mm" | "cm" | "m" priceQuantityMicros: integer defaultAmountMicros: integer storefrontInput: boolean } | null measuredStockAtoms?: integer | null title?: string description?: string | null itemType?: string ean?: string | null allowedTabTypes?: "table" | "over_the_counter" | "takeaway"[] | null metadata?: Record } ``` Response 200 — Default Response ```ts { status: "success" data: { 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 } pricingGroupId?: string | null // uuid pricingGroupName?: string | null measurement?: { unit: "mg" | "g" | "hg" | "kg" | "ml" | "cl" | "dl" | "l" | "mm" | "cm" | "m" priceQuantityMicros: integer defaultAmountMicros: integer storefrontInput: boolean } | null measuredStockAtoms?: integer | null guestInput?: { schemaToken: string fields: { … }[] } id: number locationId: number title: string description?: string | null calculatedPricesByChannel?: Record calculatedPrice?: number itemType?: string | null vatId?: integer | null vatValue?: number | null takeawayVatId?: integer | null takeawayVatValue?: number | null takeawayAccountId?: string | null manualIsAvailable?: boolean ingredientPaused?: boolean isAvailable?: boolean allowedTabTypes?: "table" | "over_the_counter" | "takeaway"[] | null ean?: string | null deleted: boolean madeToOrder: boolean tags?: Record customTagDetails?: Array<{ id: number key: string categoryHandle: string }> createdDate: string // date-time updatedAt: string // date-time ingredients?: string | null imageUrl?: string | null externalImageUrl?: string | null images?: Array<{ url?: string name?: string size?: number type?: string }> | null integrationId?: number | null integrationItemId?: string | null context?: { gift_card?: { template_id?: integer preset_amounts_cents?: unknown[] allow_digital?: boolean allow_physical?: boolean allow_pickup?: boolean allow_delivery?: boolean delivery_fee_cents?: integer physical_card_fee_cents?: integer recipient_instructions?: string | null } } | null pricings?: Array<{ id?: string // uuid price?: number name?: string order?: number metadata?: { … } | null validFrom?: string | null validTo?: string | null scheduleId?: string | null channelTypes?: unknown[] | null scheduleActive?: boolean }> purchaseCostCents?: number | null ministock?: number | null ministockCents?: number | null isAlcohol?: boolean variablePricing?: boolean | null minPriceCents?: number | null maxPriceCents?: number | null variableQuantity?: boolean tagIds?: number[] accountIds?: string[] variantGroupIds?: number[] printerGroupIds?: string[] servingCenterId?: number | null titlePrep?: string | null prepTimeMinutes?: number | null coursingOrder?: number | null preparation?: string | null printPreparation?: boolean color?: string | null kitchenSortOrder?: integer | null scheduleRules?: Array<{ id?: string dayOfWeek?: integer startTime?: string endTime?: string active?: boolean }> | null isInherited?: boolean inheritToChildren?: boolean parentItemId?: number | null sourceLocationId?: number | null overriddenFields?: string[] | null allowedOverrides?: string[] | null inheritanceUpdatedAt?: string | null // date-time isPosSynced?: boolean posOverriddenFields?: string[] channelAvailability?: { byChannel?: { KIOSK?: boolean POS?: boolean ONLINE?: boolean TAKEAWAY?: boolean WOLT?: boolean FOODORA?: boolean UBER_EATS?: boolean } online?: boolean qr?: boolean kiosk?: boolean pos?: boolean wolt?: boolean foodora?: boolean uberEats?: boolean } posIntegration?: { integrationConfigId: number provider: string externalItemId: string | null isMapped: boolean } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/inventory/{id} Delete inventory item Soft-delete an inventory item (marks as deleted without removing from database) **Permissions:** 🔴 `inventory.delete` Path parameters: - `id` (string, required) — Inventory item ID Response 200 — Item successfully deleted ```ts { status?: "success" data?: { deleted?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/inventory/{id}/inheritance Update item inheritance Update inheritance settings for an inventory item in a multi-location hierarchy **Permissions:** 🟠 `inventory.update` Path parameters: - `id` (string, required) — Inventory item ID Request body: ```ts { inheritToChildren?: boolean allowedOverrides?: "title" | "pricings" | "description" | "ingredients" | "is_available" | "image_url" | "ministock" | "printer_group_ids"[] } ``` Response 200 — Default Response ```ts { status?: "success" data?: { id?: number inheritToChildren?: boolean allowedOverrides?: string[] } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PATCH /api/v1/inventory/{id}/metadata Merge metadata into an inventory item Stores customer- or integration-owned identifiers on an inventory item without requiring dedicated schema fields. The supplied top-level keys are shallow-merged into the existing metadata object. Existing keys omitted from the request are preserved; supplied keys replace values with the same name. Use provider- or company-prefixed key names to avoid collisions. Do not store credentials, access tokens, payment data, or other secrets in metadata. **Permissions:** 🟠 `inventory.update` Path parameters: - `id` (string, required) — Inventory item ID Request body: ```ts { metadata: Record } ``` Response 200 — Default Response ```ts { status: "success" data: { 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 } pricingGroupId?: string | null // uuid pricingGroupName?: string | null measurement?: { unit: "mg" | "g" | "hg" | "kg" | "ml" | "cl" | "dl" | "l" | "mm" | "cm" | "m" priceQuantityMicros: integer defaultAmountMicros: integer storefrontInput: boolean } | null measuredStockAtoms?: integer | null guestInput?: { schemaToken: string fields: { … }[] } id: number locationId: number title: string description?: string | null calculatedPricesByChannel?: Record calculatedPrice?: number itemType?: string | null vatId?: integer | null vatValue?: number | null takeawayVatId?: integer | null takeawayVatValue?: number | null takeawayAccountId?: string | null manualIsAvailable?: boolean ingredientPaused?: boolean isAvailable?: boolean allowedTabTypes?: "table" | "over_the_counter" | "takeaway"[] | null ean?: string | null deleted: boolean madeToOrder: boolean tags?: Record customTagDetails?: Array<{ id: number key: string categoryHandle: string }> createdDate: string // date-time updatedAt: string // date-time ingredients?: string | null imageUrl?: string | null externalImageUrl?: string | null images?: Array<{ url?: string name?: string size?: number type?: string }> | null integrationId?: number | null integrationItemId?: string | null context?: { gift_card?: { template_id?: integer preset_amounts_cents?: unknown[] allow_digital?: boolean allow_physical?: boolean allow_pickup?: boolean allow_delivery?: boolean delivery_fee_cents?: integer physical_card_fee_cents?: integer recipient_instructions?: string | null } } | null pricings?: Array<{ id?: string // uuid price?: number name?: string order?: number metadata?: { … } | null validFrom?: string | null validTo?: string | null scheduleId?: string | null channelTypes?: unknown[] | null scheduleActive?: boolean }> purchaseCostCents?: number | null ministock?: number | null ministockCents?: number | null isAlcohol?: boolean variablePricing?: boolean | null minPriceCents?: number | null maxPriceCents?: number | null variableQuantity?: boolean tagIds?: number[] accountIds?: string[] variantGroupIds?: number[] printerGroupIds?: string[] servingCenterId?: number | null titlePrep?: string | null prepTimeMinutes?: number | null coursingOrder?: number | null preparation?: string | null printPreparation?: boolean color?: string | null kitchenSortOrder?: integer | null scheduleRules?: Array<{ id?: string dayOfWeek?: integer startTime?: string endTime?: string active?: boolean }> | null isInherited?: boolean inheritToChildren?: boolean parentItemId?: number | null sourceLocationId?: number | null overriddenFields?: string[] | null allowedOverrides?: string[] | null inheritanceUpdatedAt?: string | null // date-time isPosSynced?: boolean posOverriddenFields?: string[] channelAvailability?: { byChannel?: { KIOSK?: boolean POS?: boolean ONLINE?: boolean TAKEAWAY?: boolean WOLT?: boolean FOODORA?: boolean UBER_EATS?: boolean } online?: boolean qr?: boolean kiosk?: boolean pos?: boolean wolt?: boolean foodora?: boolean uberEats?: boolean } posIntegration?: { integrationConfigId: number provider: string externalItemId: string | null isMapped: boolean } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/inventory/{id}/overrides Get item overrides Get all location-specific overrides for an inherited inventory item **Permissions:** 🔵 `inventory.read` Query parameters: - `locationId` (number, optional) — Filter overrides by location ID Path parameters: - `id` (string, required) — Inventory item ID Response 200 — Default Response ```ts { status?: "success" data?: { title?: string | null description?: string | null ingredients?: string | null isAvailable?: boolean | null imageUrl?: string | null pricingGroupId?: string | null // uuid pricings?: Record[] | null ministock?: number | null measuredStockAtoms?: integer | null ministockCents?: number | null ministockEnabled?: boolean | null variantGroupsAdded?: number[] variantGroupsRemoved?: number[] printerGroupIds?: string[] | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/inventory/{id}/overrides Update item overrides Update location-specific overrides for an inherited inventory item **Permissions:** 🟠 `inventory.update` Query parameters: - `locationId` (number, optional) — Location ID for the override Path parameters: - `id` (string, required) — Inventory item ID Request body: ```ts { title?: string | null description?: string | null ingredients?: string | null isAvailable?: boolean | null imageUrl?: string | null pricingGroupId?: string | null // uuid pricings?: Array<{ id?: string price: number name: string order?: number metadata?: Record | null validFrom?: string | null validTo?: string | null scheduleId?: string | null channelTypes?: string[] | null }> | null ministock?: number | null measuredStockAtoms?: integer | null ministockCents?: number | null ministockEnabled?: boolean | null variantGroupsAdded?: number[] | null variantGroupsRemoved?: number[] | null printerGroupIds?: string[] | null } ``` Response 200 — Default Response ```ts { status?: "success" data?: { title?: string | null description?: string | null ingredients?: string | null isAvailable?: boolean | null imageUrl?: string | null pricingGroupId?: string | null // uuid pricings?: Record[] | null ministock?: number | null measuredStockAtoms?: integer | null ministockCents?: number | null ministockEnabled?: boolean | null variantGroupsAdded?: number[] variantGroupsRemoved?: number[] printerGroupIds?: string[] | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/inventory/{itemId}/availability Toggle item availability Enable or disable an inventory item for customer ordering **Permissions:** 🟠 `inventory.update` Path parameters: - `itemId` (string, required) — Inventory item ID Request body: ```ts { action: "ACTIVATE" | "DEACTIVATE" } ``` Response 200 — Default Response ```ts { status: "success" data: { itemId: number action: "ACTIVATE" | "DEACTIVATE" isAvailable: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/inventory/bulk Bulk delete inventory items Soft-deletes up to 500 items at a location. **Permissions:** 🔴 `inventory.delete` Request body: ```ts { locationId: integer itemIds: integer[] } ``` Response 200 — Default Response ```ts Record ``` ## PATCH /api/v1/inventory/bulk Bulk update inventory items Applies one partial update to up to 500 items at a location. **Permissions:** 🟠 `inventory.update` Request body: ```ts { locationId: integer itemIds: integer[] patch: { customFields?: unknown | unknown[] isAvailable?: boolean ministock?: number basePrice?: number } } ``` Response 200 — Default Response ```ts Record ``` ## POST /api/v1/inventory/bulk-upsert Bulk create or update inventory items Creates items without an id and updates items with an explicit id. An id must belong to the supplied location. **Permissions:** 🟢 `inventory.create` Request body: ```ts { locationId: integer items: Array<{ customFields?: …[] id?: integer title: string description?: string | null articleNumber?: string | null itemType?: string | null ministock?: number | null basePrice?: number isAvailable?: boolean }> } ``` Response 200 — Default Response ```ts Record ``` ## POST /api/v1/inventory/validate Validate inventory items Validate inventory items and variants before creating an order. Returns validation results for each item. Request body: ```ts { locationId: number items: Array<{ itemId: number variants?: { … }[] quantity?: number menuId?: string }> validateMenu?: boolean } ``` Response 200 — Validation response containing valid and invalid items with nested variant structures ```ts Record ``` ## GET /api/v1/price-clusters List price clusters owned by or assigned to a location Query parameters: - `locationId` (integer, required) - `limit` (integer, optional) - `offset` (integer, optional) - `search` (string, optional) - `itemId` (integer, optional) - `version` (integer, optional) Response 200 — Default Response ```ts { status?: "success" data?: { items?: Array<{ id?: string // uuid name?: string version?: integer ownerLocationId?: integer ownerLocationName?: string inherited?: boolean entries?: unknown[] locations?: unknown[] }> hasMore?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/price-clusters Create a price cluster Each entry uses either a direct price in currency units or a shared pricing group with its rules. Entries and descendant locations replace the complete cluster. Saving immediately writes the existing item override table. Removing an assignment restores its previous pricing override. Updates require the current version. Query parameters: - `locationId` (integer, required) - `limit` (integer, optional) - `offset` (integer, optional) - `search` (string, optional) - `itemId` (integer, optional) - `version` (integer, optional) Request body: ```ts { name: string version?: integer entries: Array<{ itemId: integer price?: number pricingGroupId?: string // uuid }> locationIds: integer[] } ``` Response 201 — Default Response ```ts { status?: "success" data?: { id?: string // uuid version?: integer } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/price-clusters/{id} Replace a price cluster Each entry uses either a direct price in currency units or a shared pricing group with its rules. Entries and descendant locations replace the complete cluster. Saving immediately writes the existing item override table. Removing an assignment restores its previous pricing override. Updates require the current version. Query parameters: - `locationId` (integer, required) - `limit` (integer, optional) - `offset` (integer, optional) - `search` (string, optional) - `itemId` (integer, optional) - `version` (integer, optional) Path parameters: - `id` (string, required) Request body: ```ts { name: string version: integer entries: Array<{ itemId: integer price?: number pricingGroupId?: string // uuid }> locationIds: integer[] } ``` Response 200 — Default Response ```ts { status?: "success" data?: { id?: string // uuid version?: integer } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/price-clusters/{id} Delete a price cluster and restore child prices Query parameters: - `locationId` (integer, required) - `limit` (integer, optional) - `offset` (integer, optional) - `search` (string, optional) - `itemId` (integer, optional) - `version` (integer, required) Path parameters: - `id` (string, required) Response 200 — Default Response ```ts { status?: "success" data?: { deleted?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/price-clusters/active Find the cluster controlling an item price at a location Query parameters: - `locationId` (integer, required) - `limit` (integer, optional) - `offset` (integer, optional) - `search` (string, optional) - `itemId` (integer, required) - `version` (integer, optional) Response 200 — Default Response ```ts { status?: "success" data?: { cluster?: { id?: string // uuid name?: string } | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/price-clusters/descendants List descendant locations eligible for a price cluster Query parameters: - `locationId` (integer, required) - `limit` (integer, optional) - `offset` (integer, optional) - `search` (string, optional) - `itemId` (integer, optional) - `version` (integer, optional) Response 200 — Default Response ```ts { status?: "success" data?: { items?: Array<{ id?: integer name?: string }> hasMore?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/prices List local and inherited reusable prices Query parameters: - `locationId` (integer, required) - `limit` (integer, optional) - `offset` (integer, optional) - `search` (string, optional) Response 200 — Default Response ```ts { status?: "success" data?: { items?: Array<{ id?: string // uuid name?: string locationId?: integer locationName?: string inherited?: boolean inheritToChildren?: boolean version?: integer itemCount?: integer overrideCount?: integer pricings?: unknown[] }> hasMore?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/prices Create a reusable price Requires grouped_pricing and inventory create/update permission at the owning location. Children may select inherited definitions but cannot edit them. Rules replace the complete definition, beginning with an unconditional BASE. Assign the returned ID using pricingGroupId on inventory create/update or child override endpoints. Updates require the latest version and immediately affect every linked item. Query parameters: - `locationId` (integer, required) - `limit` (integer, optional) - `offset` (integer, optional) - `search` (string, optional) Request body: ```ts { name: string inheritToChildren: boolean version?: integer pricings: Array<{ id?: string name: "BASE" | "FIXED" | "PERCENT" | "FORMULA" | "TIME" | "DISCOUNT" | "DYNAMIC" price: number order?: integer metadata?: Record | null scheduleId?: string | null // uuid channelTypes?: "KIOSK" | "POS" | "ONLINE" | "TAKEAWAY" | "WOLT" | "FOODORA" | "UBER_EATS"[] | null validFrom?: string | null // date-time validTo?: string | null // date-time }> } ``` Response 201 — Default Response ```ts { status?: "success" data?: { id?: string // uuid version?: integer } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/prices/{id} Update a reusable price and all linked items Requires grouped_pricing and inventory create/update permission at the owning location. Children may select inherited definitions but cannot edit them. Rules replace the complete definition, beginning with an unconditional BASE. Assign the returned ID using pricingGroupId on inventory create/update or child override endpoints. Updates require the latest version and immediately affect every linked item. Query parameters: - `locationId` (integer, required) - `limit` (integer, optional) - `offset` (integer, optional) - `search` (string, optional) Path parameters: - `id` (string, required) Request body: ```ts { name: string inheritToChildren: boolean version: integer pricings: Array<{ id?: string name: "BASE" | "FIXED" | "PERCENT" | "FORMULA" | "TIME" | "DISCOUNT" | "DYNAMIC" price: number order?: integer metadata?: Record | null scheduleId?: string | null // uuid channelTypes?: "KIOSK" | "POS" | "ONLINE" | "TAKEAWAY" | "WOLT" | "FOODORA" | "UBER_EATS"[] | null validFrom?: string | null // date-time validTo?: string | null // date-time }> } ``` Response 200 — Default Response ```ts { status?: "success" data?: { id?: string // uuid version?: integer } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Item Accounts Bookkeeping accounts for sales lines, per item ## POST /api/v1/integrations/fortnox/item-accounts/sync Sync accounts from Fortnox Import active revenue (3xxx) accounts from the location's connected Fortnox and upsert them into Karma item-accounts (keyed on account number). Non-destructive and idempotent. Requires Fortnox to be connected. **Permissions:** 🟠 `inventory.update` Request body: ```ts { locationId: number } ``` Response 200 — Default Response ```ts { status?: "success" data?: { created?: number updated?: number total?: number } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/item-accounts List item accounts List item accounts for a location (or company) **Permissions:** 🔵 `inventory.read` Query parameters: - `locationId` (number, optional) — Filter by location ID - `companyId` (number, optional) — Filter by company ID (joined through locations) Response 200 — Default Response ```ts { status: "success" data: { itemAccounts: Array<{ vatId?: integer | null id: string // uuid account: string description: string | null locationId: number | null isInherited?: boolean sourceLocationId?: number | null inheritToChildren?: boolean createdAt: string // date-time updatedAt: string // date-time }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/item-accounts Create item account Create a new item account **Permissions:** 🟢 `inventory.create` Request body: ```ts { vatId?: integer | null account: string description?: string | null locationId?: number | null } ``` Response 201 — Default Response ```ts { status: "success" data: { vatId?: integer | null id: string // uuid account: string description: string | null locationId: number | null isInherited?: boolean sourceLocationId?: number | null inheritToChildren?: boolean createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/item-accounts/{id} Get item account Get a single item account by ID **Permissions:** 🔵 `inventory.read` Path parameters: - `id` (string, required) — Item account UUID Response 200 — Default Response ```ts { status: "success" data: { vatId?: integer | null id: string // uuid account: string description: string | null locationId: number | null isInherited?: boolean sourceLocationId?: number | null inheritToChildren?: boolean createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/item-accounts/{id} Update item account Update an item account. `locationId` cannot be changed. **Permissions:** 🟠 `inventory.update` Path parameters: - `id` (string, required) — Item account UUID Request body: ```ts { vatId?: integer | null account?: string description?: string | null } ``` Response 200 — Default Response ```ts { status: "success" data: { vatId?: integer | null id: string // uuid account: string description: string | null locationId: number | null isInherited?: boolean sourceLocationId?: number | null inheritToChildren?: boolean createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/item-accounts/{id} Delete item account Delete an item account **Permissions:** 🔴 `inventory.delete` Path parameters: - `id` (string, required) — Item account UUID Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid deleted: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/items/{itemId}/accounts Get accounts linked to an item List the item accounts associated with a given inventory item **Permissions:** 🔵 `inventory.read` Path parameters: - `itemId` (string, required) — Inventory item ID Response 200 — Default Response ```ts { status: "success" data: { itemAccounts: Array<{ vatId?: integer | null id: string // uuid account: string description: string | null locationId: number | null isInherited?: boolean sourceLocationId?: number | null inheritToChildren?: boolean createdAt: string // date-time updatedAt: string // date-time }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/items/{itemId}/accounts Link an account to an item Associate an item account with an inventory item. Idempotent — succeeds if the link already exists. **Permissions:** 🟠 `inventory.update` Path parameters: - `itemId` (string, required) — Inventory item ID Request body: ```ts { accountId: string // uuid } ``` Response 200 — Default Response ```ts { status: "success" data: { itemAccounts: Array<{ vatId?: integer | null id: string // uuid account: string description: string | null locationId: number | null isInherited?: boolean sourceLocationId?: number | null inheritToChildren?: boolean createdAt: string // date-time updatedAt: string // date-time }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/items/{itemId}/accounts/{accountId} Unlink an account from an item Remove an account-to-item association. Idempotent — succeeds if the link does not exist. **Permissions:** 🟠 `inventory.update` Path parameters: - `itemId` (string, required) — Inventory item ID - `accountId` (string, required) — Item account UUID Response 200 — Default Response ```ts { status: "success" data: { itemAccounts: Array<{ vatId?: integer | null id: string // uuid account: string description: string | null locationId: number | null isInherited?: boolean sourceLocationId?: number | null inheritToChildren?: boolean createdAt: string // date-time updatedAt: string // date-time }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Menu Layouts Menu structure and organization ## GET /api/v1/menu-layouts List menu layouts List menu layouts with optional filtering by location, schedule, and channels **Permissions:** 🔵 `menu_layouts.read` Query parameters: - `locationId` (number, optional) — Filter by location ID - `page` (number, optional) — Page number - `limit` (number, optional) — Items per page - `availableOnly` (string, optional) — Filter by schedule availability (ISO timestamp or "true") - `channels` (string, optional) — Filter by channels (comma-separated channel types) - `templatesOnly` ("true" | "false", optional) — Filter to only return templates - `excludeTemplates` ("true" | "false", optional) — Exclude templates from results - `resolveReferences` ("true" | "false", optional) — Resolve linked template references in the context - `excludeUnmapped` ("true" | "false", optional) — Drop product nodes whose item lacks a POS integration_resource_mappings row. Used by storefront to hide items that would silently fail at the POS. No-op when the location has no active POS integration. Response 200 — Default Response ```ts { status: "success" data: { layouts: Array<{ id: string locationId: number context: { … } createdAt: string // date-time updatedAt?: string | null // date-time sortOrder?: number | null isTemplate?: boolean | null templateName?: string | null usageCount?: number scheduleRules?: unknown[] channelAvailability?: { … } checkoutSwitchEligible?: boolean isAvailableNow?: boolean }> pagination: { page: number limit: number total: number totalPages: number } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/menu-layouts Create menu layout Create a new menu layout **Permissions:** 🟢 `menu_layouts.create` Request body: ```ts { locationId: number context: { id?: unknown // Editor-generated node ID (UUID, occasionally prefixed like `layout-`). Not the menu layout row ID. type?: unknown // Node kind. Known values: `layout` (category/folder with children in `data`), `product` (product button), `reference` (l… title?: unknown // Display name shown on the button/category color?: unknown // Background color — hex code or `transparent` data?: unknown // Array of child nodes, each the same shape as this one — this is where the tree recurses. Populated on `layout` nodes an… gridWidth?: unknown // Grid columns this tile spans gridHeight?: unknown // Grid rows this tile spans gridRow?: unknown // Grid row position of this tile gridCol?: unknown // Grid column position of this tile gridCols?: unknown // Number of columns in THIS layout's own grid (2-6). Layout/root nodes only. hideTitle?: unknown // Hide the title in storefront top navigation, landing page and grid (top-level groups) hideImage?: unknown // Hide the product image and show the background color instead resetToRoot?: unknown // Navigate back to the root layout after adding a product from this layout fontSize?: unknown // Font size for `text` nodes: `default` or `large` storefrontStyle?: unknown // Storefront/kiosk rendering style for a category. Known values: `default_listing`, `small_card`, `large_card`, `card_car… productRef?: unknown // Inventory item ID (`items.id`) as a string — `product` nodes only. A few legacy nodes store it as a number, so read it … presetVariantItemIds?: unknown // Array of pre-configured variant item IDs (numbers) for a product card. When set, tapping the card auto-applies these va… referenceId?: unknown // Menu layout row ID (UUID) of the template being referenced — `reference` nodes only linked?: unknown // Boolean. true (or omitted) = live reference that follows template updates and is expanded by `resolveReferences=true`; … actionType?: unknown // POS action for `action` nodes. Known values: Discount, Quantity, Upsell, Close, Print, Move, Note, TableName, Home, Cou… quantityValue?: unknown // Preset quantity (number) for the Quantity action (e.g. 3 = x3); skips the calculator dialog coursingValue?: unknown // Preset course (number) for the Coursing action; when unset the action opens the course selector voucherCode?: unknown // Preset voucher code for the Voucher action; when unset the action opens the voucher picker voucherTitle?: unknown // Display-title snapshot for the preset voucher, captured at save time targetItemId?: unknown // Target inventory item ID (number) for the VariantFilter action customUrl?: unknown // URL opened in a new window by the CustomUrl action customUrlTitle?: unknown // Display label for the CustomUrl action button noteText?: unknown // Preset note text for the Note action; when unset the action opens the notes drawer reportScope?: unknown // SalesReport action — which sales the report may show: `costCenter` (the device's own, the default), `user` (the signed-… reportDateMode?: unknown // SalesReport action — `today` (the location's current business day) or `ask` (opens a date picker) reportPeriodMode?: unknown // SalesReport action — `day` (the whole business day, the default) or `shift` (one of the location's configured shift win… reportShiftId?: unknown // SalesReport action — pinned shift id for `reportPeriodMode: shift`; when unset the action asks. Only the id is stored: … targetPrinterGroupId?: unknown // StationMessage / SalesReport action — where the print goes: a preparation group id, the sentinel `__device__` (SalesRep… targetStationTitle?: unknown // Display-title snapshot of `targetPrinterGroupId`, captured at save time; clients prefer the live printer name. Absent f… isDynamicGroup?: unknown // Boolean. Layout node whose children are resolved live from inventory tags at render time; `data` stays empty dynamicTagKey?: unknown // Tag category key for a dynamic group, e.g. `CATEGORY_PRIMARY`, `CUSTOM`, `ALLERGEN`, `PREFERENCE` dynamicTagValue?: unknown // Tag value within `dynamicTagKey`, e.g. `DRINKS` scheduleRules?: unknown // Schedule rules for this layout node, or null — attached by the API on read, ignored on write. Same entries as the top-l… channelAvailability?: unknown // Channel availability for this layout node, or null — attached by the API on read, ignored on write. Keys: `byChannel`, … _isResolvedReference?: unknown // Boolean set by the API when `resolveReferences=true` expanded a reference node into this layout node _referenceId?: unknown // Template row ID a resolved reference was expanded from _originalReferenceNodeId?: unknown // Node ID of the original reference node _referenceNotFound?: unknown // Boolean set by the API when a reference points at a template that no longer exists actionBars?: unknown // Per-menu POS action bars (persist across sub-layout navigation). Root node only. Each side holds an array of menu layou… detachedLayouts?: unknown // Array of layouts not placed in the main grid, used only as targets for action-bar references or other links. Root node … actionBar?: unknown // Deprecated — use `actionBars`. Legacy single action bar (array of nodes). actionBarPosition?: unknown // Deprecated — use `actionBars`. Position of the legacy `actionBar`: left, right, top or bottom. } isTemplate?: boolean templateName?: string } ``` Response 201 — Default Response ```ts { status: "success" data: { id: string locationId: number context: { id?: unknown // Editor-generated node ID (UUID, occasionally prefixed like `layout-`). Not the menu layout row ID. type?: unknown // Node kind. Known values: `layout` (category/folder with children in `data`), `product` (product button), `reference` (l… title?: unknown // Display name shown on the button/category color?: unknown // Background color — hex code or `transparent` data?: unknown // Array of child nodes, each the same shape as this one — this is where the tree recurses. Populated on `layout` nodes an… gridWidth?: unknown // Grid columns this tile spans gridHeight?: unknown // Grid rows this tile spans gridRow?: unknown // Grid row position of this tile gridCol?: unknown // Grid column position of this tile gridCols?: unknown // Number of columns in THIS layout's own grid (2-6). Layout/root nodes only. hideTitle?: unknown // Hide the title in storefront top navigation, landing page and grid (top-level groups) hideImage?: unknown // Hide the product image and show the background color instead resetToRoot?: unknown // Navigate back to the root layout after adding a product from this layout fontSize?: unknown // Font size for `text` nodes: `default` or `large` storefrontStyle?: unknown // Storefront/kiosk rendering style for a category. Known values: `default_listing`, `small_card`, `large_card`, `card_car… productRef?: unknown // Inventory item ID (`items.id`) as a string — `product` nodes only. A few legacy nodes store it as a number, so read it … presetVariantItemIds?: unknown // Array of pre-configured variant item IDs (numbers) for a product card. When set, tapping the card auto-applies these va… referenceId?: unknown // Menu layout row ID (UUID) of the template being referenced — `reference` nodes only linked?: unknown // Boolean. true (or omitted) = live reference that follows template updates and is expanded by `resolveReferences=true`; … actionType?: unknown // POS action for `action` nodes. Known values: Discount, Quantity, Upsell, Close, Print, Move, Note, TableName, Home, Cou… quantityValue?: unknown // Preset quantity (number) for the Quantity action (e.g. 3 = x3); skips the calculator dialog coursingValue?: unknown // Preset course (number) for the Coursing action; when unset the action opens the course selector voucherCode?: unknown // Preset voucher code for the Voucher action; when unset the action opens the voucher picker voucherTitle?: unknown // Display-title snapshot for the preset voucher, captured at save time targetItemId?: unknown // Target inventory item ID (number) for the VariantFilter action customUrl?: unknown // URL opened in a new window by the CustomUrl action customUrlTitle?: unknown // Display label for the CustomUrl action button noteText?: unknown // Preset note text for the Note action; when unset the action opens the notes drawer reportScope?: unknown // SalesReport action — which sales the report may show: `costCenter` (the device's own, the default), `user` (the signed-… reportDateMode?: unknown // SalesReport action — `today` (the location's current business day) or `ask` (opens a date picker) reportPeriodMode?: unknown // SalesReport action — `day` (the whole business day, the default) or `shift` (one of the location's configured shift win… reportShiftId?: unknown // SalesReport action — pinned shift id for `reportPeriodMode: shift`; when unset the action asks. Only the id is stored: … targetPrinterGroupId?: unknown // StationMessage / SalesReport action — where the print goes: a preparation group id, the sentinel `__device__` (SalesRep… targetStationTitle?: unknown // Display-title snapshot of `targetPrinterGroupId`, captured at save time; clients prefer the live printer name. Absent f… isDynamicGroup?: unknown // Boolean. Layout node whose children are resolved live from inventory tags at render time; `data` stays empty dynamicTagKey?: unknown // Tag category key for a dynamic group, e.g. `CATEGORY_PRIMARY`, `CUSTOM`, `ALLERGEN`, `PREFERENCE` dynamicTagValue?: unknown // Tag value within `dynamicTagKey`, e.g. `DRINKS` scheduleRules?: unknown // Schedule rules for this layout node, or null — attached by the API on read, ignored on write. Same entries as the top-l… channelAvailability?: unknown // Channel availability for this layout node, or null — attached by the API on read, ignored on write. Keys: `byChannel`, … _isResolvedReference?: unknown // Boolean set by the API when `resolveReferences=true` expanded a reference node into this layout node _referenceId?: unknown // Template row ID a resolved reference was expanded from _originalReferenceNodeId?: unknown // Node ID of the original reference node _referenceNotFound?: unknown // Boolean set by the API when a reference points at a template that no longer exists actionBars?: unknown // Per-menu POS action bars (persist across sub-layout navigation). Root node only. Each side holds an array of menu layou… detachedLayouts?: unknown // Array of layouts not placed in the main grid, used only as targets for action-bar references or other links. Root node … actionBar?: unknown // Deprecated — use `actionBars`. Legacy single action bar (array of nodes). actionBarPosition?: unknown // Deprecated — use `actionBars`. Position of the legacy `actionBar`: left, right, top or bottom. } createdAt: string // date-time updatedAt?: string | null // date-time sortOrder?: number | null isTemplate?: boolean | null templateName?: string | null usageCount?: number scheduleRules?: Array<{ id?: string dayOfWeek?: number startTime?: string endTime?: string active?: boolean }> channelAvailability?: { byChannel?: { KIOSK?: boolean POS?: boolean ONLINE?: boolean TAKEAWAY?: boolean WOLT?: boolean FOODORA?: boolean UBER_EATS?: boolean } online?: boolean qr?: boolean kiosk?: boolean pos?: boolean wolt?: boolean foodora?: boolean uberEats?: boolean } checkoutSwitchEligible?: boolean isAvailableNow?: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/menu-layouts/{id} Get menu layout Get a single menu layout by ID. Use resolveReferences=true to expand linked template references. **Permissions:** 🔵 `menu_layouts.read` Query parameters: - `resolveReferences` ("true" | "false", optional) — Resolve linked template references in the context - `excludeUnmapped` ("true" | "false", optional) — Drop product nodes whose item lacks a POS integration_resource_mappings row. No-op when the location has no active POS integration. Path parameters: - `id` (string, required) — Menu layout ID (UUID) Response 200 — Default Response ```ts { status: "success" data: { id: string locationId: number context: { id?: unknown // Editor-generated node ID (UUID, occasionally prefixed like `layout-`). Not the menu layout row ID. type?: unknown // Node kind. Known values: `layout` (category/folder with children in `data`), `product` (product button), `reference` (l… title?: unknown // Display name shown on the button/category color?: unknown // Background color — hex code or `transparent` data?: unknown // Array of child nodes, each the same shape as this one — this is where the tree recurses. Populated on `layout` nodes an… gridWidth?: unknown // Grid columns this tile spans gridHeight?: unknown // Grid rows this tile spans gridRow?: unknown // Grid row position of this tile gridCol?: unknown // Grid column position of this tile gridCols?: unknown // Number of columns in THIS layout's own grid (2-6). Layout/root nodes only. hideTitle?: unknown // Hide the title in storefront top navigation, landing page and grid (top-level groups) hideImage?: unknown // Hide the product image and show the background color instead resetToRoot?: unknown // Navigate back to the root layout after adding a product from this layout fontSize?: unknown // Font size for `text` nodes: `default` or `large` storefrontStyle?: unknown // Storefront/kiosk rendering style for a category. Known values: `default_listing`, `small_card`, `large_card`, `card_car… productRef?: unknown // Inventory item ID (`items.id`) as a string — `product` nodes only. A few legacy nodes store it as a number, so read it … presetVariantItemIds?: unknown // Array of pre-configured variant item IDs (numbers) for a product card. When set, tapping the card auto-applies these va… referenceId?: unknown // Menu layout row ID (UUID) of the template being referenced — `reference` nodes only linked?: unknown // Boolean. true (or omitted) = live reference that follows template updates and is expanded by `resolveReferences=true`; … actionType?: unknown // POS action for `action` nodes. Known values: Discount, Quantity, Upsell, Close, Print, Move, Note, TableName, Home, Cou… quantityValue?: unknown // Preset quantity (number) for the Quantity action (e.g. 3 = x3); skips the calculator dialog coursingValue?: unknown // Preset course (number) for the Coursing action; when unset the action opens the course selector voucherCode?: unknown // Preset voucher code for the Voucher action; when unset the action opens the voucher picker voucherTitle?: unknown // Display-title snapshot for the preset voucher, captured at save time targetItemId?: unknown // Target inventory item ID (number) for the VariantFilter action customUrl?: unknown // URL opened in a new window by the CustomUrl action customUrlTitle?: unknown // Display label for the CustomUrl action button noteText?: unknown // Preset note text for the Note action; when unset the action opens the notes drawer reportScope?: unknown // SalesReport action — which sales the report may show: `costCenter` (the device's own, the default), `user` (the signed-… reportDateMode?: unknown // SalesReport action — `today` (the location's current business day) or `ask` (opens a date picker) reportPeriodMode?: unknown // SalesReport action — `day` (the whole business day, the default) or `shift` (one of the location's configured shift win… reportShiftId?: unknown // SalesReport action — pinned shift id for `reportPeriodMode: shift`; when unset the action asks. Only the id is stored: … targetPrinterGroupId?: unknown // StationMessage / SalesReport action — where the print goes: a preparation group id, the sentinel `__device__` (SalesRep… targetStationTitle?: unknown // Display-title snapshot of `targetPrinterGroupId`, captured at save time; clients prefer the live printer name. Absent f… isDynamicGroup?: unknown // Boolean. Layout node whose children are resolved live from inventory tags at render time; `data` stays empty dynamicTagKey?: unknown // Tag category key for a dynamic group, e.g. `CATEGORY_PRIMARY`, `CUSTOM`, `ALLERGEN`, `PREFERENCE` dynamicTagValue?: unknown // Tag value within `dynamicTagKey`, e.g. `DRINKS` scheduleRules?: unknown // Schedule rules for this layout node, or null — attached by the API on read, ignored on write. Same entries as the top-l… channelAvailability?: unknown // Channel availability for this layout node, or null — attached by the API on read, ignored on write. Keys: `byChannel`, … _isResolvedReference?: unknown // Boolean set by the API when `resolveReferences=true` expanded a reference node into this layout node _referenceId?: unknown // Template row ID a resolved reference was expanded from _originalReferenceNodeId?: unknown // Node ID of the original reference node _referenceNotFound?: unknown // Boolean set by the API when a reference points at a template that no longer exists actionBars?: unknown // Per-menu POS action bars (persist across sub-layout navigation). Root node only. Each side holds an array of menu layou… detachedLayouts?: unknown // Array of layouts not placed in the main grid, used only as targets for action-bar references or other links. Root node … actionBar?: unknown // Deprecated — use `actionBars`. Legacy single action bar (array of nodes). actionBarPosition?: unknown // Deprecated — use `actionBars`. Position of the legacy `actionBar`: left, right, top or bottom. } createdAt: string // date-time updatedAt?: string | null // date-time sortOrder?: number | null isTemplate?: boolean | null templateName?: string | null usageCount?: number scheduleRules?: Array<{ id?: string dayOfWeek?: number startTime?: string endTime?: string active?: boolean }> channelAvailability?: { byChannel?: { KIOSK?: boolean POS?: boolean ONLINE?: boolean TAKEAWAY?: boolean WOLT?: boolean FOODORA?: boolean UBER_EATS?: boolean } online?: boolean qr?: boolean kiosk?: boolean pos?: boolean wolt?: boolean foodora?: boolean uberEats?: boolean } checkoutSwitchEligible?: boolean isAvailableNow?: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/menu-layouts/{id} Update menu layout Update an existing menu layout **Permissions:** 🟠 `menu_layouts.update` Path parameters: - `id` (string, required) — Menu layout ID (UUID) Request body: ```ts { context?: { id?: unknown // Editor-generated node ID (UUID, occasionally prefixed like `layout-`). Not the menu layout row ID. type?: unknown // Node kind. Known values: `layout` (category/folder with children in `data`), `product` (product button), `reference` (l… title?: unknown // Display name shown on the button/category color?: unknown // Background color — hex code or `transparent` data?: unknown // Array of child nodes, each the same shape as this one — this is where the tree recurses. Populated on `layout` nodes an… gridWidth?: unknown // Grid columns this tile spans gridHeight?: unknown // Grid rows this tile spans gridRow?: unknown // Grid row position of this tile gridCol?: unknown // Grid column position of this tile gridCols?: unknown // Number of columns in THIS layout's own grid (2-6). Layout/root nodes only. hideTitle?: unknown // Hide the title in storefront top navigation, landing page and grid (top-level groups) hideImage?: unknown // Hide the product image and show the background color instead resetToRoot?: unknown // Navigate back to the root layout after adding a product from this layout fontSize?: unknown // Font size for `text` nodes: `default` or `large` storefrontStyle?: unknown // Storefront/kiosk rendering style for a category. Known values: `default_listing`, `small_card`, `large_card`, `card_car… productRef?: unknown // Inventory item ID (`items.id`) as a string — `product` nodes only. A few legacy nodes store it as a number, so read it … presetVariantItemIds?: unknown // Array of pre-configured variant item IDs (numbers) for a product card. When set, tapping the card auto-applies these va… referenceId?: unknown // Menu layout row ID (UUID) of the template being referenced — `reference` nodes only linked?: unknown // Boolean. true (or omitted) = live reference that follows template updates and is expanded by `resolveReferences=true`; … actionType?: unknown // POS action for `action` nodes. Known values: Discount, Quantity, Upsell, Close, Print, Move, Note, TableName, Home, Cou… quantityValue?: unknown // Preset quantity (number) for the Quantity action (e.g. 3 = x3); skips the calculator dialog coursingValue?: unknown // Preset course (number) for the Coursing action; when unset the action opens the course selector voucherCode?: unknown // Preset voucher code for the Voucher action; when unset the action opens the voucher picker voucherTitle?: unknown // Display-title snapshot for the preset voucher, captured at save time targetItemId?: unknown // Target inventory item ID (number) for the VariantFilter action customUrl?: unknown // URL opened in a new window by the CustomUrl action customUrlTitle?: unknown // Display label for the CustomUrl action button noteText?: unknown // Preset note text for the Note action; when unset the action opens the notes drawer reportScope?: unknown // SalesReport action — which sales the report may show: `costCenter` (the device's own, the default), `user` (the signed-… reportDateMode?: unknown // SalesReport action — `today` (the location's current business day) or `ask` (opens a date picker) reportPeriodMode?: unknown // SalesReport action — `day` (the whole business day, the default) or `shift` (one of the location's configured shift win… reportShiftId?: unknown // SalesReport action — pinned shift id for `reportPeriodMode: shift`; when unset the action asks. Only the id is stored: … targetPrinterGroupId?: unknown // StationMessage / SalesReport action — where the print goes: a preparation group id, the sentinel `__device__` (SalesRep… targetStationTitle?: unknown // Display-title snapshot of `targetPrinterGroupId`, captured at save time; clients prefer the live printer name. Absent f… isDynamicGroup?: unknown // Boolean. Layout node whose children are resolved live from inventory tags at render time; `data` stays empty dynamicTagKey?: unknown // Tag category key for a dynamic group, e.g. `CATEGORY_PRIMARY`, `CUSTOM`, `ALLERGEN`, `PREFERENCE` dynamicTagValue?: unknown // Tag value within `dynamicTagKey`, e.g. `DRINKS` scheduleRules?: unknown // Schedule rules for this layout node, or null — attached by the API on read, ignored on write. Same entries as the top-l… channelAvailability?: unknown // Channel availability for this layout node, or null — attached by the API on read, ignored on write. Keys: `byChannel`, … _isResolvedReference?: unknown // Boolean set by the API when `resolveReferences=true` expanded a reference node into this layout node _referenceId?: unknown // Template row ID a resolved reference was expanded from _originalReferenceNodeId?: unknown // Node ID of the original reference node _referenceNotFound?: unknown // Boolean set by the API when a reference points at a template that no longer exists actionBars?: unknown // Per-menu POS action bars (persist across sub-layout navigation). Root node only. Each side holds an array of menu layou… detachedLayouts?: unknown // Array of layouts not placed in the main grid, used only as targets for action-bar references or other links. Root node … actionBar?: unknown // Deprecated — use `actionBars`. Legacy single action bar (array of nodes). actionBarPosition?: unknown // Deprecated — use `actionBars`. Position of the legacy `actionBar`: left, right, top or bottom. } isTemplate?: boolean templateName?: string } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string locationId: number context: { id?: unknown // Editor-generated node ID (UUID, occasionally prefixed like `layout-`). Not the menu layout row ID. type?: unknown // Node kind. Known values: `layout` (category/folder with children in `data`), `product` (product button), `reference` (l… title?: unknown // Display name shown on the button/category color?: unknown // Background color — hex code or `transparent` data?: unknown // Array of child nodes, each the same shape as this one — this is where the tree recurses. Populated on `layout` nodes an… gridWidth?: unknown // Grid columns this tile spans gridHeight?: unknown // Grid rows this tile spans gridRow?: unknown // Grid row position of this tile gridCol?: unknown // Grid column position of this tile gridCols?: unknown // Number of columns in THIS layout's own grid (2-6). Layout/root nodes only. hideTitle?: unknown // Hide the title in storefront top navigation, landing page and grid (top-level groups) hideImage?: unknown // Hide the product image and show the background color instead resetToRoot?: unknown // Navigate back to the root layout after adding a product from this layout fontSize?: unknown // Font size for `text` nodes: `default` or `large` storefrontStyle?: unknown // Storefront/kiosk rendering style for a category. Known values: `default_listing`, `small_card`, `large_card`, `card_car… productRef?: unknown // Inventory item ID (`items.id`) as a string — `product` nodes only. A few legacy nodes store it as a number, so read it … presetVariantItemIds?: unknown // Array of pre-configured variant item IDs (numbers) for a product card. When set, tapping the card auto-applies these va… referenceId?: unknown // Menu layout row ID (UUID) of the template being referenced — `reference` nodes only linked?: unknown // Boolean. true (or omitted) = live reference that follows template updates and is expanded by `resolveReferences=true`; … actionType?: unknown // POS action for `action` nodes. Known values: Discount, Quantity, Upsell, Close, Print, Move, Note, TableName, Home, Cou… quantityValue?: unknown // Preset quantity (number) for the Quantity action (e.g. 3 = x3); skips the calculator dialog coursingValue?: unknown // Preset course (number) for the Coursing action; when unset the action opens the course selector voucherCode?: unknown // Preset voucher code for the Voucher action; when unset the action opens the voucher picker voucherTitle?: unknown // Display-title snapshot for the preset voucher, captured at save time targetItemId?: unknown // Target inventory item ID (number) for the VariantFilter action customUrl?: unknown // URL opened in a new window by the CustomUrl action customUrlTitle?: unknown // Display label for the CustomUrl action button noteText?: unknown // Preset note text for the Note action; when unset the action opens the notes drawer reportScope?: unknown // SalesReport action — which sales the report may show: `costCenter` (the device's own, the default), `user` (the signed-… reportDateMode?: unknown // SalesReport action — `today` (the location's current business day) or `ask` (opens a date picker) reportPeriodMode?: unknown // SalesReport action — `day` (the whole business day, the default) or `shift` (one of the location's configured shift win… reportShiftId?: unknown // SalesReport action — pinned shift id for `reportPeriodMode: shift`; when unset the action asks. Only the id is stored: … targetPrinterGroupId?: unknown // StationMessage / SalesReport action — where the print goes: a preparation group id, the sentinel `__device__` (SalesRep… targetStationTitle?: unknown // Display-title snapshot of `targetPrinterGroupId`, captured at save time; clients prefer the live printer name. Absent f… isDynamicGroup?: unknown // Boolean. Layout node whose children are resolved live from inventory tags at render time; `data` stays empty dynamicTagKey?: unknown // Tag category key for a dynamic group, e.g. `CATEGORY_PRIMARY`, `CUSTOM`, `ALLERGEN`, `PREFERENCE` dynamicTagValue?: unknown // Tag value within `dynamicTagKey`, e.g. `DRINKS` scheduleRules?: unknown // Schedule rules for this layout node, or null — attached by the API on read, ignored on write. Same entries as the top-l… channelAvailability?: unknown // Channel availability for this layout node, or null — attached by the API on read, ignored on write. Keys: `byChannel`, … _isResolvedReference?: unknown // Boolean set by the API when `resolveReferences=true` expanded a reference node into this layout node _referenceId?: unknown // Template row ID a resolved reference was expanded from _originalReferenceNodeId?: unknown // Node ID of the original reference node _referenceNotFound?: unknown // Boolean set by the API when a reference points at a template that no longer exists actionBars?: unknown // Per-menu POS action bars (persist across sub-layout navigation). Root node only. Each side holds an array of menu layou… detachedLayouts?: unknown // Array of layouts not placed in the main grid, used only as targets for action-bar references or other links. Root node … actionBar?: unknown // Deprecated — use `actionBars`. Legacy single action bar (array of nodes). actionBarPosition?: unknown // Deprecated — use `actionBars`. Position of the legacy `actionBar`: left, right, top or bottom. } createdAt: string // date-time updatedAt?: string | null // date-time sortOrder?: number | null isTemplate?: boolean | null templateName?: string | null usageCount?: number scheduleRules?: Array<{ id?: string dayOfWeek?: number startTime?: string endTime?: string active?: boolean }> channelAvailability?: { byChannel?: { KIOSK?: boolean POS?: boolean ONLINE?: boolean TAKEAWAY?: boolean WOLT?: boolean FOODORA?: boolean UBER_EATS?: boolean } online?: boolean qr?: boolean kiosk?: boolean pos?: boolean wolt?: boolean foodora?: boolean uberEats?: boolean } checkoutSwitchEligible?: boolean isAvailableNow?: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/menu-layouts/{id} Delete menu layout Delete a menu layout. If the layout is a template that is being used, set force=true to delete anyway. **Permissions:** 🔴 `menu_layouts.delete` Query parameters: - `force` ("true" | "false", optional) — Force delete a template even if it is being used in menus Path parameters: - `id` (string, required) — Menu layout ID (UUID) Response 200 — Menu layout successfully deleted ```ts { status?: "success" data?: { deleted?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/menu-layouts/{id}/restore Restore a deleted menu layout Restore a soft-deleted menu layout under its original ID, which re-links the channel assignments and schedules that outlived the delete. Returns 404 if the layout does not exist or is not deleted. **Permissions:** 🟠 `menu_layouts.update` Path parameters: - `id` (string, required) — Menu layout ID (UUID) Response 200 — Default Response ```ts { status: "success" data: { id: string locationId: number context: { id?: unknown // Editor-generated node ID (UUID, occasionally prefixed like `layout-`). Not the menu layout row ID. type?: unknown // Node kind. Known values: `layout` (category/folder with children in `data`), `product` (product button), `reference` (l… title?: unknown // Display name shown on the button/category color?: unknown // Background color — hex code or `transparent` data?: unknown // Array of child nodes, each the same shape as this one — this is where the tree recurses. Populated on `layout` nodes an… gridWidth?: unknown // Grid columns this tile spans gridHeight?: unknown // Grid rows this tile spans gridRow?: unknown // Grid row position of this tile gridCol?: unknown // Grid column position of this tile gridCols?: unknown // Number of columns in THIS layout's own grid (2-6). Layout/root nodes only. hideTitle?: unknown // Hide the title in storefront top navigation, landing page and grid (top-level groups) hideImage?: unknown // Hide the product image and show the background color instead resetToRoot?: unknown // Navigate back to the root layout after adding a product from this layout fontSize?: unknown // Font size for `text` nodes: `default` or `large` storefrontStyle?: unknown // Storefront/kiosk rendering style for a category. Known values: `default_listing`, `small_card`, `large_card`, `card_car… productRef?: unknown // Inventory item ID (`items.id`) as a string — `product` nodes only. A few legacy nodes store it as a number, so read it … presetVariantItemIds?: unknown // Array of pre-configured variant item IDs (numbers) for a product card. When set, tapping the card auto-applies these va… referenceId?: unknown // Menu layout row ID (UUID) of the template being referenced — `reference` nodes only linked?: unknown // Boolean. true (or omitted) = live reference that follows template updates and is expanded by `resolveReferences=true`; … actionType?: unknown // POS action for `action` nodes. Known values: Discount, Quantity, Upsell, Close, Print, Move, Note, TableName, Home, Cou… quantityValue?: unknown // Preset quantity (number) for the Quantity action (e.g. 3 = x3); skips the calculator dialog coursingValue?: unknown // Preset course (number) for the Coursing action; when unset the action opens the course selector voucherCode?: unknown // Preset voucher code for the Voucher action; when unset the action opens the voucher picker voucherTitle?: unknown // Display-title snapshot for the preset voucher, captured at save time targetItemId?: unknown // Target inventory item ID (number) for the VariantFilter action customUrl?: unknown // URL opened in a new window by the CustomUrl action customUrlTitle?: unknown // Display label for the CustomUrl action button noteText?: unknown // Preset note text for the Note action; when unset the action opens the notes drawer reportScope?: unknown // SalesReport action — which sales the report may show: `costCenter` (the device's own, the default), `user` (the signed-… reportDateMode?: unknown // SalesReport action — `today` (the location's current business day) or `ask` (opens a date picker) reportPeriodMode?: unknown // SalesReport action — `day` (the whole business day, the default) or `shift` (one of the location's configured shift win… reportShiftId?: unknown // SalesReport action — pinned shift id for `reportPeriodMode: shift`; when unset the action asks. Only the id is stored: … targetPrinterGroupId?: unknown // StationMessage / SalesReport action — where the print goes: a preparation group id, the sentinel `__device__` (SalesRep… targetStationTitle?: unknown // Display-title snapshot of `targetPrinterGroupId`, captured at save time; clients prefer the live printer name. Absent f… isDynamicGroup?: unknown // Boolean. Layout node whose children are resolved live from inventory tags at render time; `data` stays empty dynamicTagKey?: unknown // Tag category key for a dynamic group, e.g. `CATEGORY_PRIMARY`, `CUSTOM`, `ALLERGEN`, `PREFERENCE` dynamicTagValue?: unknown // Tag value within `dynamicTagKey`, e.g. `DRINKS` scheduleRules?: unknown // Schedule rules for this layout node, or null — attached by the API on read, ignored on write. Same entries as the top-l… channelAvailability?: unknown // Channel availability for this layout node, or null — attached by the API on read, ignored on write. Keys: `byChannel`, … _isResolvedReference?: unknown // Boolean set by the API when `resolveReferences=true` expanded a reference node into this layout node _referenceId?: unknown // Template row ID a resolved reference was expanded from _originalReferenceNodeId?: unknown // Node ID of the original reference node _referenceNotFound?: unknown // Boolean set by the API when a reference points at a template that no longer exists actionBars?: unknown // Per-menu POS action bars (persist across sub-layout navigation). Root node only. Each side holds an array of menu layou… detachedLayouts?: unknown // Array of layouts not placed in the main grid, used only as targets for action-bar references or other links. Root node … actionBar?: unknown // Deprecated — use `actionBars`. Legacy single action bar (array of nodes). actionBarPosition?: unknown // Deprecated — use `actionBars`. Position of the legacy `actionBar`: left, right, top or bottom. } createdAt: string // date-time updatedAt?: string | null // date-time sortOrder?: number | null isTemplate?: boolean | null templateName?: string | null usageCount?: number scheduleRules?: Array<{ id?: string dayOfWeek?: number startTime?: string endTime?: string active?: boolean }> channelAvailability?: { byChannel?: { KIOSK?: boolean POS?: boolean ONLINE?: boolean TAKEAWAY?: boolean WOLT?: boolean FOODORA?: boolean UBER_EATS?: boolean } online?: boolean qr?: boolean kiosk?: boolean pos?: boolean wolt?: boolean foodora?: boolean uberEats?: boolean } checkoutSwitchEligible?: boolean isAvailableNow?: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/menu-layouts/{id}/usage Get template usage Get information about which menus are using a template **Permissions:** 🔵 `menu_layouts.read` Path parameters: - `id` (string, required) — Menu layout ID (UUID) Response 200 — Default Response ```ts { status: "success" data: { templateId: string usageCount: number usedIn: Array<{ menuId?: string menuTitle?: string linked?: boolean }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/menu-layouts/reorder Reorder menu layouts Reorder menu layouts by updating their sort order **Permissions:** 🟠 `menu_layouts.update` Request body: ```ts { layoutIds: string[] } ``` Response 200 — Menu layouts successfully reordered ```ts { status?: "success" data?: { success?: boolean layoutIds?: string[] } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Translations Multi-language translations for inventory and menus ## GET /api/v1/translations/inventory Get translated inventory items by location and locale with ID-keyed response for simple client merging Query parameters: - `locationId` (number, required) — Location ID - `locale` ("en" | "sv" | "no" | "fi" | "es" | "de" | "fr" | "ar" | "pl" | "ru" | "zh" | "ja", required) — Target language code - `includeVariants` (boolean, optional) — Include variant group translations - `page` (number, optional) — Page number for pagination - `limit` (number, optional) — Items per page (max 1000) Response 200 — Default Response ```ts { status: "success" data: { inventory: Record variants: Record metadata: { totalItems: number uniqueStrings: number cacheHits: number cacheMisses: number executionTimeMs: number } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Variant Groups Product options and modifiers ## GET /api/v1/variant-groups List variant groups List variant groups with optional filtering. Group customFields belong to variant_groups; variants[].customFields belong to each linked inventory item. See [Variant custom fields](/docs/variant-custom-fields). **Permissions:** 🔵 `variant_groups.read` Query parameters: - `locationId` (number, optional) — Filter by location ID - `type` ("EXTRA" | "VARIANT", optional) — Filter by group type - `includeVariants` (boolean, optional) — Include variants in response - `page` (number, optional) — Page number - `limit` (number, optional) — Items per page Response 200 — Default Response ```ts { status: "success" data: { variantGroups: Array<{ id: number locationId: number name: string type: "EXTRA" | "VARIANT" description?: string | null min: number max?: number | null isVisible: boolean useChildCoursing: boolean maxOptionsVisible: number variants: unknown[] scheduleRules?: unknown[] | null channelAvailability?: { … } | null posOverriddenFields?: unknown[] customFields?: unknown[] metadata?: { … } | null }> pagination: { page: number limit: number total: number totalPages: number } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/variant-groups Create variant group Create a new variant group with variants **Permissions:** 🟢 `variant_groups.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 name: string type: "EXTRA" | "VARIANT" description?: string | null min?: number max?: number | null isVisible?: boolean useChildCoursing?: boolean maxOptionsVisible?: number variants: Array<{ customFields?: …[] name: string price?: number | null defaultSelection?: boolean description?: string | null receiptTitle?: string | null rank?: number | null itemId?: number | null quantity?: number allowQuantityChange?: boolean quantityCents?: number minQuantityCents?: number | null maxQuantityCents?: number | null }> customFields?: unknown | unknown[] } ``` Response 201 — Default Response ```ts { status: "success" data: { id: number locationId: number name: string type: "EXTRA" | "VARIANT" description?: string | null min: number max?: number | null isVisible: boolean useChildCoursing: boolean maxOptionsVisible: number variants: Array<{ id: number variantGroupId: number name: string price?: number | null defaultSelection: boolean description?: string | null rank?: number | null itemId?: number | null quantity: number allowQuantityChange: boolean minQuantity?: number | null maxQuantity?: number | null quantityCents: number minQuantityCents?: number | null maxQuantityCents?: number | null posOverriddenFields?: unknown[] customFields?: unknown[] metadata?: { … } | null }> scheduleRules?: Array<{ id?: string dayOfWeek?: number startTime?: string endTime?: string active?: boolean }> | null channelAvailability?: { byChannel?: { KIOSK?: boolean POS?: boolean ONLINE?: boolean TAKEAWAY?: boolean WOLT?: boolean FOODORA?: boolean UBER_EATS?: boolean } online?: boolean qr?: boolean kiosk?: boolean pos?: boolean wolt?: boolean foodora?: boolean uberEats?: boolean } | null posOverriddenFields?: string[] 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 } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/variant-groups/{id} Get variant group Get a single variant group by ID with its variants. Group customFields belong to variant_groups; variants[].customFields belong to each linked inventory item. See [Variant custom fields](/docs/variant-custom-fields). **Permissions:** 🔵 `variant_groups.read` Path parameters: - `id` (string, required) — Variant group ID Response 200 — Default Response ```ts { status: "success" data: { id: number locationId: number name: string type: "EXTRA" | "VARIANT" description?: string | null min: number max?: number | null isVisible: boolean useChildCoursing: boolean maxOptionsVisible: number variants: Array<{ id: number variantGroupId: number name: string price?: number | null defaultSelection: boolean description?: string | null rank?: number | null itemId?: number | null quantity: number allowQuantityChange: boolean minQuantity?: number | null maxQuantity?: number | null quantityCents: number minQuantityCents?: number | null maxQuantityCents?: number | null posOverriddenFields?: unknown[] customFields?: unknown[] metadata?: { … } | null }> scheduleRules?: Array<{ id?: string dayOfWeek?: number startTime?: string endTime?: string active?: boolean }> | null channelAvailability?: { byChannel?: { KIOSK?: boolean POS?: boolean ONLINE?: boolean TAKEAWAY?: boolean WOLT?: boolean FOODORA?: boolean UBER_EATS?: boolean } online?: boolean qr?: boolean kiosk?: boolean pos?: boolean wolt?: boolean foodora?: boolean uberEats?: boolean } | null posOverriddenFields?: string[] 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 } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/variant-groups/{id} Update variant group Update an existing variant group **Permissions:** 🟠 `variant_groups.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) — Variant group ID Request body: ```ts { name?: string description?: string | null min?: number max?: number | null isVisible?: boolean useChildCoursing?: boolean maxOptionsVisible?: number variants?: Array<{ customFields?: …[] name: string price?: number | null defaultSelection?: boolean description?: string | null receiptTitle?: string | null rank?: number | null itemId?: number | null quantity?: number allowQuantityChange?: boolean quantityCents?: number minQuantityCents?: number | null maxQuantityCents?: number | null id?: number }> customFields?: unknown | unknown[] } ``` Response 200 — Default Response ```ts { status: "success" data: { id: number locationId: number name: string type: "EXTRA" | "VARIANT" description?: string | null min: number max?: number | null isVisible: boolean useChildCoursing: boolean maxOptionsVisible: number variants: Array<{ id: number variantGroupId: number name: string price?: number | null defaultSelection: boolean description?: string | null rank?: number | null itemId?: number | null quantity: number allowQuantityChange: boolean minQuantity?: number | null maxQuantity?: number | null quantityCents: number minQuantityCents?: number | null maxQuantityCents?: number | null posOverriddenFields?: unknown[] customFields?: unknown[] metadata?: { … } | null }> scheduleRules?: Array<{ id?: string dayOfWeek?: number startTime?: string endTime?: string active?: boolean }> | null channelAvailability?: { byChannel?: { KIOSK?: boolean POS?: boolean ONLINE?: boolean TAKEAWAY?: boolean WOLT?: boolean FOODORA?: boolean UBER_EATS?: boolean } online?: boolean qr?: boolean kiosk?: boolean pos?: boolean wolt?: boolean foodora?: boolean uberEats?: boolean } | null posOverriddenFields?: string[] 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 } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/variant-groups/{id} Delete variant group Delete a variant group and its variants **Permissions:** 🔴 `variant_groups.delete` Path parameters: - `id` (string, required) — Variant group ID Response 200 — Variant group successfully deleted ```ts { status?: "success" data?: { deleted?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/variants/{id} Get variant custom fields Read or patch the linked inventory item's stored fields through a variant. Requires variant_groups.read and inventory.read; writes also require inventory.update and API write channels. Values are shared by variants linking to the same item at this location. Group-level fields are a separate variant_groups object. See [Variant custom fields](/docs/variant-custom-fields). Set customFields using fieldId or key, value or reset, and optional expectedVersion. Query parameters: - `locationId` (integer, required) Path parameters: - `id` (integer, required) Response 200 — Default Response ```ts { status?: "success" data?: { id?: integer itemId?: integer 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 } | null } } ``` ## PATCH /api/v1/variants/{id} Update variant custom fields Read or patch the linked inventory item's stored fields through a variant. Requires variant_groups.read and inventory.read; writes also require inventory.update and API write channels. Values are shared by variants linking to the same item at this location. Group-level fields are a separate variant_groups object. See [Variant custom fields](/docs/variant-custom-fields). Set customFields using fieldId or key, value or reset, and optional expectedVersion. Query parameters: - `locationId` (integer, required) Path parameters: - `id` (integer, required) Request body: ```ts { customFields: unknown | unknown[] } ``` Response 200 — Default Response ```ts { status?: "success" data?: { id?: integer itemId?: integer 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 } | null } } ``` # VAT Rates VAT rates available for a location's country ## GET /api/v1/vat-rates List VAT rates Get all VAT rates available for the location's country. Rates are returned as percentages (e.g. 25.0 for 25%). These are reference data — all standard VAT rates for the country are included. Inventory items reference a rate by its `id`: set `vatId` for dine-in and `takeawayVatId` for takeaway. **Permissions:** 🔵 `inventory.read` Query parameters: - `locationId` (number, optional) — Location ID (optional if authenticated with a single-location key/user) Response 200 — Default Response ```ts { status: "success" data: { vatRates: Array<{ id: number value: number }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Coursings Multi-course meal sequencing ## GET /api/v1/coursings List coursings List coursings with optional filtering **Permissions:** 🔵 `coursings.read` Query parameters: - `locationId` (number, optional) — Filter by location ID - `page` (number, optional) — Page number - `limit` (number, optional) — Items per page Response 200 — Default Response ```ts { status: "success" data: { coursings: Array<{ id: string locationId: number title: string order: number color: string | null isInherited?: boolean sourceLocationId?: number inheritToChildren?: boolean 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 } } ``` ## POST /api/v1/coursings Create coursing Create a new coursing **Permissions:** 🟢 `coursings.create` Request body: ```ts { locationId: number title: string order: number color?: string | null } ``` Response 201 — Default Response ```ts { status: "success" data: { id: string locationId: number title: string order: number color: string | null isInherited?: boolean sourceLocationId?: number inheritToChildren?: boolean createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/coursings/{id} Get coursing Get a single coursing by ID **Permissions:** 🔵 `coursings.read` Path parameters: - `id` (string, required) — Coursing ID (UUID) Response 200 — Default Response ```ts { status: "success" data: { id: string locationId: number title: string order: number color: string | null isInherited?: boolean sourceLocationId?: number inheritToChildren?: boolean createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/coursings/{id} Update coursing Update an existing coursing **Permissions:** 🟠 `coursings.update` Path parameters: - `id` (string, required) — Coursing ID (UUID) Request body: ```ts { title?: string order?: number color?: string | null } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string locationId: number title: string order: number color: string | null isInherited?: boolean sourceLocationId?: number inheritToChildren?: boolean createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/coursings/{id} Delete coursing Delete a coursing **Permissions:** 🔴 `coursings.delete` Path parameters: - `id` (string, required) — Coursing ID (UUID) Response 200 — Coursing successfully deleted ```ts { status?: "success" data?: { deleted?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Order cancelled Webhook event: `order.cancelled` Fired when an order is cancelled. Includes the location, tab, cancelling retailer user and an optional reason. Delivered payload: ```ts { id: string // uuid event: "order.cancelled" resource: "order" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null kitchenDispatchId: string // uuid location: { customFields?: { … }[] id: integer metadata: Record } tab: { customFields?: { … }[] id: string // uuid metadata: Record } | null user: { id: integer metadata: Record } | null reason: string | null cancelledAt: string // date-time } } ``` # Order completed Webhook event: `order.completed` Fired when an order is marked complete in the KDS. Delivered payload: ```ts { id: string // uuid event: "order.completed" resource: "order" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null kitchenDispatchId: string // uuid tabId: string // uuid completedBy: integer | null totalAmountCents: integer locationId: integer completedAt: string // date-time } } ``` # Order (kitchen dispatch) created Webhook event: `order.created` Fired when items are committed to the kitchen (POST /api/v1/inventory/commit). One event per dispatch. Delivered payload: ```ts { id: string // uuid event: "order.created" resource: "order" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null kitchenDispatchId: string // uuid dailyOrderNumber: integer tabMemberId: string | null location: { customFields?: { … }[] id: integer metadata: Record } tab: { customFields?: { … }[] id: string // uuid metadata: Record } user: { id: integer metadata: Record } | null state: string | null orderChannel: string orderType: string orderFlow: string orderDestination: string orderDestinationId: string | null totalAmountCents: integer itemCount: integer tableId: string | null items: Array<{ kitchenDispatchItemId: string // uuid quantity: number status: string | null item: { … } }> externalOrderId?: string customerName?: string | null createdAt: string // date-time } } ``` # Items added to an existing order Webhook event: `order.items_added` Fired when items are added to a kitchen dispatch that already exists. Delivered payload: ```ts { id: string // uuid event: "order.items_added" resource: "order" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null kitchenDispatchId: string // uuid addedItems: Array<{ kitchenDispatchItemId: string // uuid itemId: integer quantity: integer status: string }> itemCount: integer locationId: integer addedAt: string // date-time } } ``` # Order requires payment Webhook event: `order.payment_required` Fired when an order needs payment (e.g. INSTANT flow, or tab settlement). Delivered payload: ```ts { id: string // uuid event: "order.payment_required" resource: "order" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null kitchenDispatchId: string // uuid tabId: string // uuid orderFlow: "INSTANT" | "OPEN_TAB" amountCents: integer currency: string paymentRequired: boolean locationId: integer timestamp: string // date-time } } ``` # Order reversed (void) Webhook event: `order.reversed` Fired when a kitchen dispatch is reversed — e.g. void requested or booking unseated. Carries full item snapshots so subscribers (printer-service) can produce KORRBONG void tickets. Delivered payload: ```ts { id: string // uuid event: "order.reversed" resource: "order" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null kitchenDispatchId: string // uuid locationId: integer tabId: string // uuid tabMemberId: string | null dailyOrderNumber: integer tableId: string | null previousState: string reversedBy: { type: "retailer_user" | "user" | "system" id: string } reason: string items: Array<{ kitchenDispatchItemId: string // uuid itemId: integer title: string | null quantity: integer quantityCents: … originalQuantityCents?: … price: integer previousItemState: string | null }> reasonCode: string | null reasonNote: string | null retailerUserId: integer | null isItemVoid: boolean reversedAt: string // date-time } } ``` # Order updated Webhook event: `order.updated` Fired on any KDS state transition or detail change. `changes` describes what shifted. Delivered payload: ```ts { id: string // uuid event: "order.updated" resource: "order" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null kitchenDispatchId: string // uuid location: { customFields?: { … }[] id: integer metadata: Record } tab: { customFields?: { … }[] id: string // uuid metadata: Record } | null user: { id: integer metadata: Record } | null changes: Record updatedAt: string // date-time } } ``` # Tab closed Webhook event: `tab.closed` Fired when a tab is manually closed or administratively aborted. This is a lifecycle signal, not proof of payment; use tab.payment_completed for payment success. Delivered payload: ```ts { id: string // uuid event: "tab.closed" resource: "tab" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null tabId: string // uuid locationId: integer tableId?: string | null costCenterId?: integer | null totalAmount: integer state: "closed" | "aborted" reason?: string | null closedAt: string // date-time } } ``` # Tab member added Webhook event: `tab.member_added` Fired when a guest joins an existing tab (POST /api/v1/tabs/:id/members). Delivered payload: ```ts { id: string // uuid event: "tab.member_added" resource: "tab" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null tabId: string // uuid tabMemberId: string // uuid userId: integer isNewUser: boolean memberType: "GUEST" | "STAFF" locationId: integer addedAt: string // date-time } } ``` # Tab opened Webhook event: `tab.opened` Fired when a new tab is created (e.g. POST /api/v1/tabs/resolve, or table seating). Delivered payload: ```ts { id: string // uuid event: "tab.opened" resource: "tab" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null tabId: string // uuid locationId: integer tabType: string paymentFlow: "open_tab" | "instant" qrCodeId?: string | null tableId?: string | null createdBy: integer costCenterId?: integer | null createdAt: string // date-time } } ``` # Tab payment completed Webhook event: `tab.payment_completed` Authoritative success signal for one payment, which may cover only part of the tab. It does not mean the tab was manually closed. Use tabSettled (or remainingBalanceCents) to decide whether the tab is fully paid, and use tab.closed only for manual or administrative closure. Delivered payload: ```ts { id: string // uuid event: "tab.payment_completed" resource: "tab" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record tab: { customFields?: { … }[] id: string // uuid metadata: Record } location: { customFields?: { … }[] id: integer metadata: Record } tabSettlementId: string | null purchaseIntentId: string // uuid paymentMethod: "prepaid" | "invoice" | "swish" | "terminal" | "online" | "gift_card" | "unknown" paymentMode: "ITEM" | "AMOUNT" | "INSTANT_PAY" amountCents: integer tabState?: string remainingBalanceCents?: integer tabSettled?: boolean pspReference?: string items?: Array<{ kitchenDispatchItemId: string // uuid item: … }> itemCount?: integer surplusItemCount?: integer surplusAmountCents?: integer completedAt: string // date-time } } ``` # Tab payment failed Webhook event: `tab.payment_failed` Authoritative failure signal for a tab payment. Includes error code and message for debugging. Delivered payload: ```ts { id: string // uuid event: "tab.payment_failed" resource: "tab" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null tabId: string // uuid tabSettlementId: string // uuid purchaseIntentId: string // uuid paymentMethod: string paymentMode: "ITEM" | "AMOUNT" | "INSTANT_PAY" amountCents: integer errorCode?: string errorMessage: string itemIds?: string[] locationId: integer failedAt: string // date-time } } ``` # Tab payment initiated Webhook event: `tab.payment_initiated` Fired when a payment is initiated (POST /api/v1/tabs/:id/pay). Mode-specific fields (`itemCount`, `itemIds`) are present only for ITEM mode. Delivered payload: ```ts { id: string // uuid event: "tab.payment_initiated" resource: "tab" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null tabId: string // uuid tabSettlementId: string // uuid purchaseIntentId: string // uuid paymentMethod: "prepaid" | "invoice" | "swish" | "terminal" | "online" | "gift_card" | "unknown" paymentMode: "ITEM" | "AMOUNT" amountCents: integer itemCount?: integer itemIds?: string[] locationId: integer initiatedAt: string // date-time } } ``` # Tab updated Webhook event: `tab.updated` Fired on any tab modification: items added, payments received, balance updated, etc. `changes` describes what shifted. Delivered payload: ```ts { id: string // uuid event: "tab.updated" resource: "tab" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { customFields?: Array<{ fieldId: string key: string label: string group: string type: string value: unknown displayValue: string version: integer status: string }> metadata?: Record | null tabId: string // uuid locationId: integer tableId?: string | null costCenterId?: integer | null tabType?: string | null changes: Record updatedAt: string // date-time } } ``` # Extended Waiting Time Extended waiting times during busy periods ## GET /api/v1/extended-waiting-time Get extended waiting time for a location Query parameters: - `locationId` (number, optional) — Location ID (optional if X-Location-Id header is provided) Response 200 — Extended waiting time retrieved successfully ```ts { status: "success" data: { extendedWaitingTime: { id: number locationId: number endsAt: string // date-time delayInMinutes?: number | null } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/extended-waiting-time Create or update extended waiting time for a location Request body: ```ts { locationId: number endsAt: string // date-time delayInMinutes?: number | null } ``` Response 201 — Extended waiting time created/updated successfully ```ts { status: "success" data: { extendedWaitingTime: { id: number locationId: number endsAt: string // date-time delayInMinutes?: number | null } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/extended-waiting-time Delete extended waiting time by location ID Query parameters: - `locationId` (number, required) — Location ID Response 200 — Extended waiting time deleted successfully ```ts { status: "success" data: { deleted: true id: number } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/extended-waiting-time/{id} Delete extended waiting time by ID Path parameters: - `id` (number, required) — Extended waiting time ID Response 200 — Extended waiting time deleted successfully ```ts { status: "success" data: { deleted: true id: number } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Notes Notes attached to tabs and orders ## GET /api/v1/notes List notes for an entity List all notes attached to a specific entity (tab or user). Notes are returned in sort order (lowest sortOrder first). **Permissions:** 🔵 `notes.read` Query parameters: - `entityType` ("tab" | "user" | "booking", required) — Type of entity to fetch notes for - `entityId` (string, required) — ID of the entity (tab UUID or user ID) Response 200 — Default Response ```ts { status: "success" data: { notes: Array<{ uuid: string // uuid entityType: "tab" | "user" | "booking" 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 }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/notes Create a note Create a new note attached to an entity (tab or user). The note will be associated with the current location and the authenticated user (if using JWT auth). **Permissions:** 🟢 `notes.create` Request body: ```ts { entityType: "tab" | "user" | "booking" entityId: string note: string } ``` Response 201 — Default Response ```ts { status: "success" data: { uuid: string // uuid entityType: "tab" | "user" | "booking" 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 } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/notes/{uuid} Get a note Get a single note by UUID **Permissions:** 🔵 `notes.read` Path parameters: - `uuid` (string, required) — Note UUID Response 200 — Default Response ```ts { status: "success" data: { uuid: string // uuid entityType: "tab" | "user" | "booking" 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 } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/notes/{uuid} Delete a note Delete a note by UUID **Permissions:** 🔴 `notes.delete` Path parameters: - `uuid` (string, required) — Note UUID Response 200 — Default Response ```ts { status: "success" data: { deleted: true } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PATCH /api/v1/notes/{uuid} Update a note Update an existing note. The updated_by fields will be set to the authenticated user. **Permissions:** 🟠 `notes.update` Path parameters: - `uuid` (string, required) — Note UUID Request body: ```ts { note: string } ``` Response 200 — Default Response ```ts { status: "success" data: { uuid: string // uuid entityType: "tab" | "user" | "booking" 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 } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/notes/reorder Reorder notes Reorder notes for an entity. Pass an array of note UUIDs in the desired order. Each note will have its sortOrder updated to match its position in the array. **Permissions:** 🟠 `notes.update` Query parameters: - `entityType` ("tab" | "user" | "booking", required) — Type of entity to fetch notes for - `entityId` (string, required) — ID of the entity (tab UUID or user ID) Request body: ```ts { noteUuids: string[] } ``` Response 200 — Default Response ```ts { status: "success" data: { notes: Array<{ uuid: string // uuid entityType: "tab" | "user" | "booking" 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 }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # 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[] } 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 ``` 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 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 } | 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 } ``` ## 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 } 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 | 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 ``` # Serving Centers Serving centers that prepare and hand out orders ## POST /api/v1/locations/{locationId}/serving-centers Create serving center Create a serving center for a location **Permissions:** 🟢 `serving_centers.create` Path parameters: - `locationId` (number, required) Request body: ```ts { name: string color?: string | null } ``` Response 201 — Default Response ```ts { status: "success" data: { id: number locationId: number name: string color: string | null createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/serving-centers/{id} Update serving center Update a serving center **Permissions:** 🟠 `serving_centers.update` Path parameters: - `id` (number, required) Request body: ```ts { name?: string color?: string | null } ``` Response 200 — Default Response ```ts { status: "success" data: { id: number locationId: number name: string color: string | null createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/serving-centers/{id} Delete serving center Delete a serving center **Permissions:** 🔴 `serving_centers.delete` Path parameters: - `id` (number, required) Response 200 — Default Response ```ts { status: "success" data: { id: number deleted: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Tabs Auto-Close Settings When open tabs close automatically ## GET /api/v1/locations/{locationId}/tabs-auto-close-settings Get auto-close-tabs settings Get the location's auto-close rules for open tabs. Both fields are null when opted out. **Permissions:** 🔵 `settings.read` Path parameters: - `locationId` (string, required) — Location ID Response 200 — Default Response ```ts { status: "success" data: { locationId: number afterHours: integer | null atTime: string | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/locations/{locationId}/tabs-auto-close-settings Update auto-close-tabs settings Update the location's auto-close rules. Omit a field to leave it unchanged; pass null to clear it. **Permissions:** 🟠 `settings.update` Path parameters: - `locationId` (string, required) — Location ID Request body: ```ts { afterHours?: integer | null atTime?: string | null } ``` Response 200 — Default Response ```ts { status: "success" data: { locationId: number afterHours: integer | null atTime: string | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # 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 clientKey?: string environment?: "test" | "live" adyenIntegrationId?: string | null // uuid freeOrder?: boolean } meta?: Record } ``` ## 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 } ``` ## 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 } ``` ## 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 voucher_codes?: string[] recipient_id?: string // uuid reference_id?: string // uuid gift_card_code?: string gift_card_issuer_location_id?: integer payment_data?: Record 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 pspReference?: string refusalReason?: string refusalReasonCode?: string checkoutUrl?: string checkoutUrlFallback?: { purchaseIntentId?: string qrCodeSvgBase64?: string } } } meta?: Record } ``` ## 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 payment_data?: string } ``` Response 200 — Payment details processed successfully ```ts { status?: "success" data?: { resultCode?: string action?: Record | null pspReference?: string | null refusalReason?: string | null refusalReasonCode?: string | null state?: string | null } meta?: Record } ``` ## 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 } ``` ## 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 } 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 } | 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 } 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 } 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 balance?: Record summary?: Record verification?: Record purchases?: Record[] items?: Record[] } 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 customFields?: unknown | unknown[] } ``` Response 200 — Default Response ```ts { success?: boolean data?: { metadata: Record } 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 } 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 } kitchenDispatchItems?: string[] paymentSplit?: number totalAmountCents?: number splitAmountCents?: number paidAmountCents?: number remainingBalanceCents?: number paidSplits?: number remainingSplits?: number vatBreakdown?: Record } 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[] query?: Record } 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 } 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 } ``` ## 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 clientKey?: string environment?: "test" | "live" adyenIntegrationId?: string | null // uuid freeOrder?: boolean } meta?: Record } ``` # Checkout Cart pricing quotes before payment ## POST /api/v3/checkout/quote Quote a purchase (read-only pricing) Compute the priced response for a checkout intent without inserting any rows. Re-uses the same pricing pipeline that `confirmPurchaseIntent` runs, so the result is byte-for-byte aligned with what the client will eventually pay. The returned `expiresAt` window is 5 minutes from now (D-23). If any cart item or KDI is unavailable (out of stock, KDI aborted/removed) the call fails with 400 `items_unavailable` (D-25). **Permissions:** ⚪ `tabs.pay` Request body: ```ts { tabId?: string // uuid tabMemberId?: string // uuid locationId: integer mode: "upfront" | "items" | "amount" | "remainder" cart?: Record[] kitchenDispatchItemIds?: string[] amountCents?: integer tipAmountCents?: integer persist?: boolean voucherCodes?: string[] metadata?: Record } ``` Response 200 — Priced quote (read-only) ```ts { status?: "success" data?: { mode: "upfront" | "items" | "amount" | "remainder" lineItems: Array<{ type?: "product" | "tip" | "amount" title?: string finalAmountCents?: integer vatRateBasisPoints?: integer vatAmountCents?: integer quantity?: number quantityCents?: integer totalAmountCents?: integer totalVatAmountCents?: integer originItemId?: integer | null kitchenDispatchItemId?: string | null // uuid appliedTotalDiscountAmountCents?: integer | null }> vatBreakdown?: Array<{ vatRateBasisPoints?: integer netAmountCents?: integer vatAmountCents?: integer grossAmountCents?: integer }> subtotalCents?: integer tipAmountCents?: integer discountCents?: integer surplusServiceFeeCents?: integer totalAmountCents: integer quoteId?: string // uuid currencyCode: string availablePaymentMethods?: string[] expiresAt: string // date-time } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Gift Cards Gift card issuance, balance, and redemption ## GET /api/v1/gift-cards List gift cards List gift cards with optional filters and pagination **Permissions:** 🔵 `gift-cards.read` Query parameters: - `location_id` (number, optional) — Filter by location ID - `include_children` (boolean, optional) — Include authorized descendants of location_id. - `location_ids` (string, optional) — Optional comma-separated subset of the resolved location hierarchy. - `status` ("active" | "redeemed" | "expired" | "frozen" | "cancelled", optional) — Filter by status - `code` (string, optional) — Exact code match (case-insensitive) - `loyalty_member_id` (number, optional) — Filter by loyalty member ID - `page` (number, optional) - `limit` (number, optional) - `sort` ("created_at" | "valid_end_date" | "balance_cents" | "status" | "code", optional) - `order` ("ASC" | "DESC", optional) Response 200 — Paginated list of gift cards ```ts { status?: "success" data?: { giftCards?: Array<{ id?: string // uuid locationId?: number companyId?: number code?: string status?: "active" | "redeemed" | "expired" | "frozen" | "cancelled" balanceCents?: number initialBalanceCents?: number currency?: string voucherClassification?: "enfunktion" | "flerfunktion" validStartDate?: string // date-time validEndDate?: string // date-time designId?: string | null // uuid loyaltyMemberId?: number | null recipientName?: string | null recipientEmail?: string | null recipientPhone?: string | null senderName?: string | null senderEmail?: string | null purchaserUserId?: number | null recipientAddress?: { … } | null emailLanguage?: string | null personalMessage?: string | null sourcePurchaseLineItemId?: number | null createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean issuingLocationName?: string }> pagination?: { page?: number limit?: number total?: number totalPages?: number } } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## POST /api/v1/gift-cards Create gift card Create a new gift card with initial balance **Permissions:** 🟢 `gift-cards.create` Request body: ```ts { locationId: integer companyId?: integer code?: string initialBalanceCents: integer currency?: string voucherClassification?: "enfunktion" | "flerfunktion" validStartDate?: string // date validEndDate?: string // date designId?: string // uuid loyaltyMemberId?: number recipientName?: string recipientEmail?: string // email recipientPhone?: string senderName?: string senderEmail?: string // email personalMessage?: string sourcePurchaseLineItemId?: number sendEmail?: boolean } ``` Response 201 — Gift card created successfully ```ts { status?: "success" data?: { giftCard?: { id?: string // uuid locationId?: number companyId?: number code?: string status?: "active" | "redeemed" | "expired" | "frozen" | "cancelled" balanceCents?: number initialBalanceCents?: number currency?: string voucherClassification?: "enfunktion" | "flerfunktion" validStartDate?: string // date-time validEndDate?: string // date-time designId?: string | null // uuid loyaltyMemberId?: number | null recipientName?: string | null recipientEmail?: string | null recipientPhone?: string | null senderName?: string | null senderEmail?: string | null purchaserUserId?: number | null recipientAddress?: Record | null emailLanguage?: string | null personalMessage?: string | null sourcePurchaseLineItemId?: number | null createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean issuingLocationName?: string } transaction?: { id?: number giftCardId?: string // uuid type?: "create" | "spend" | "refill" | "expire" | "migrate" | "adjustment" | "freeze" | "unfreeze" | "invalidate" amountCents?: number balanceAfterCents?: number referenceType?: string | null referenceId?: string | null notes?: string | null actorType?: string | null actorId?: string | null createdAt?: string // date-time } } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## GET /api/v1/gift-cards/{id} Get gift card Get a gift card by UUID with full transaction history **Permissions:** 🔵 `gift-cards.read` Path parameters: - `id` (string, required) — Gift card UUID Response 200 — Gift card with transactions ```ts { status?: "success" data?: { id?: string // uuid locationId?: number companyId?: number code?: string status?: "active" | "redeemed" | "expired" | "frozen" | "cancelled" balanceCents?: number initialBalanceCents?: number currency?: string voucherClassification?: "enfunktion" | "flerfunktion" validStartDate?: string // date-time validEndDate?: string // date-time designId?: string | null // uuid loyaltyMemberId?: number | null recipientName?: string | null recipientEmail?: string | null recipientPhone?: string | null senderName?: string | null senderEmail?: string | null purchaserUserId?: number | null recipientAddress?: Record | null emailLanguage?: string | null personalMessage?: string | null sourcePurchaseLineItemId?: number | null createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean issuingLocationName?: string transactions?: Array<{ id?: number giftCardId?: string // uuid type?: "create" | "spend" | "refill" | "expire" | "migrate" | "adjustment" | "freeze" | "unfreeze" | "invalidate" amountCents?: number balanceAfterCents?: number referenceType?: string | null referenceId?: string | null notes?: string | null actorType?: string | null actorId?: string | null createdAt?: string // date-time }> } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## DELETE /api/v1/gift-cards/{id} Invalidate gift card Permanently cancel a gift card while retaining its balance and immutable audit history **Permissions:** 🔴 `gift-cards.delete` Path parameters: - `id` (string, required) Request body: ```ts { reason: string } ``` Response 200 — Updated gift card and audit transaction ```ts { status?: "success" data?: { giftCard?: { id?: string // uuid locationId?: number companyId?: number code?: string status?: "active" | "redeemed" | "expired" | "frozen" | "cancelled" balanceCents?: number initialBalanceCents?: number currency?: string voucherClassification?: "enfunktion" | "flerfunktion" validStartDate?: string // date-time validEndDate?: string // date-time designId?: string | null // uuid loyaltyMemberId?: number | null recipientName?: string | null recipientEmail?: string | null recipientPhone?: string | null senderName?: string | null senderEmail?: string | null purchaserUserId?: number | null recipientAddress?: Record | null emailLanguage?: string | null personalMessage?: string | null sourcePurchaseLineItemId?: number | null createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean issuingLocationName?: string } transaction?: { id?: number giftCardId?: string // uuid type?: "create" | "spend" | "refill" | "expire" | "migrate" | "adjustment" | "freeze" | "unfreeze" | "invalidate" amountCents?: number balanceAfterCents?: number referenceType?: string | null referenceId?: string | null notes?: string | null actorType?: string | null actorId?: string | null createdAt?: string // date-time } } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## PATCH /api/v1/gift-cards/{id} Update gift card Update allowed fields on a gift card (not balance) **Permissions:** 🟠 `gift-cards.update` Path parameters: - `id` (string, required) — Gift card UUID Request body: ```ts { code?: string reason?: string designId?: string | null // uuid loyaltyMemberId?: number | null recipientName?: string | null recipientEmail?: string | null // email recipientPhone?: string | null recipientAddress?: Record | null senderName?: string | null senderEmail?: string | null // email personalMessage?: string | null emailLanguage?: "en" | "sv" validEndDate?: string // date } ``` Response 200 — Updated gift card ```ts { status?: "success" data?: { id?: string // uuid locationId?: number companyId?: number code?: string status?: "active" | "redeemed" | "expired" | "frozen" | "cancelled" balanceCents?: number initialBalanceCents?: number currency?: string voucherClassification?: "enfunktion" | "flerfunktion" validStartDate?: string // date-time validEndDate?: string // date-time designId?: string | null // uuid loyaltyMemberId?: number | null recipientName?: string | null recipientEmail?: string | null recipientPhone?: string | null senderName?: string | null senderEmail?: string | null purchaserUserId?: number | null recipientAddress?: Record | null emailLanguage?: string | null personalMessage?: string | null sourcePurchaseLineItemId?: number | null createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean issuingLocationName?: string } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## POST /api/v1/gift-cards/{id}/add-balance Add gift card balance Atomically increase a gift card balance with an audit reason **Permissions:** ⚪ `gift-cards.increase` Path parameters: - `id` (string, required) — Gift card UUID Request body: ```ts { amountCents: integer referenceId?: string reason: string expectedBalanceCents?: integer } ``` Response 200 — Refill result with updated gift card and transaction record ```ts { status?: "success" data?: { giftCard?: { id?: string // uuid locationId?: number companyId?: number code?: string status?: "active" | "redeemed" | "expired" | "frozen" | "cancelled" balanceCents?: number initialBalanceCents?: number currency?: string voucherClassification?: "enfunktion" | "flerfunktion" validStartDate?: string // date-time validEndDate?: string // date-time designId?: string | null // uuid loyaltyMemberId?: number | null recipientName?: string | null recipientEmail?: string | null recipientPhone?: string | null senderName?: string | null senderEmail?: string | null purchaserUserId?: number | null recipientAddress?: Record | null emailLanguage?: string | null personalMessage?: string | null sourcePurchaseLineItemId?: number | null createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean issuingLocationName?: string } transaction?: { id?: number giftCardId?: string // uuid type?: "create" | "spend" | "refill" | "expire" | "migrate" | "adjustment" | "freeze" | "unfreeze" | "invalidate" amountCents?: number balanceAfterCents?: number referenceType?: string | null referenceId?: string | null notes?: string | null actorType?: string | null actorId?: string | null createdAt?: string // date-time } } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## GET /api/v1/gift-cards/{id}/audit Get gift card audit history Read field changes and email resend requests for a gift card **Permissions:** 🔵 `gift-cards.read` Query parameters: - `page` (integer, optional) - `limit` (integer, optional) Path parameters: - `id` (string, required) Response 200 — Default Response ```ts { status?: string data?: { events?: Array<{ id?: string giftCardId?: string action?: string changes?: { … } actorType?: string | null actorId?: string | null actor?: { … } | null requestId?: string | null reason?: string | null createdAt?: string // date-time }> pagination?: { page?: number limit?: number total?: number } } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## POST /api/v1/gift-cards/{id}/deduct Deduct gift card balance Atomically reduce a gift card balance with an audit reason **Permissions:** ⚪ `gift-cards.reduce` Path parameters: - `id` (string, required) — Gift card UUID Request body: ```ts { amountCents: integer referenceId?: string reason: string expectedBalanceCents?: integer } ``` Response 200 — Deduction result with updated gift card and transaction record ```ts { status?: "success" data?: { giftCard?: { id?: string // uuid locationId?: number companyId?: number code?: string status?: "active" | "redeemed" | "expired" | "frozen" | "cancelled" balanceCents?: number initialBalanceCents?: number currency?: string voucherClassification?: "enfunktion" | "flerfunktion" validStartDate?: string // date-time validEndDate?: string // date-time designId?: string | null // uuid loyaltyMemberId?: number | null recipientName?: string | null recipientEmail?: string | null recipientPhone?: string | null senderName?: string | null senderEmail?: string | null purchaserUserId?: number | null recipientAddress?: Record | null emailLanguage?: string | null personalMessage?: string | null sourcePurchaseLineItemId?: number | null createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean issuingLocationName?: string } transaction?: { id?: number giftCardId?: string // uuid type?: "create" | "spend" | "refill" | "expire" | "migrate" | "adjustment" | "freeze" | "unfreeze" | "invalidate" amountCents?: number balanceAfterCents?: number referenceType?: string | null referenceId?: string | null notes?: string | null actorType?: string | null actorId?: string | null createdAt?: string // date-time } } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## POST /api/v1/gift-cards/{id}/freeze Freeze gift card Temporarily prevent a gift card from being redeemed **Permissions:** ⚪ `gift-cards.freeze` Path parameters: - `id` (string, required) Request body: ```ts { reason: string } ``` Response 200 — Updated gift card and audit transaction ```ts { status?: "success" data?: { giftCard?: { id?: string // uuid locationId?: number companyId?: number code?: string status?: "active" | "redeemed" | "expired" | "frozen" | "cancelled" balanceCents?: number initialBalanceCents?: number currency?: string voucherClassification?: "enfunktion" | "flerfunktion" validStartDate?: string // date-time validEndDate?: string // date-time designId?: string | null // uuid loyaltyMemberId?: number | null recipientName?: string | null recipientEmail?: string | null recipientPhone?: string | null senderName?: string | null senderEmail?: string | null purchaserUserId?: number | null recipientAddress?: Record | null emailLanguage?: string | null personalMessage?: string | null sourcePurchaseLineItemId?: number | null createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean issuingLocationName?: string } transaction?: { id?: number giftCardId?: string // uuid type?: "create" | "spend" | "refill" | "expire" | "migrate" | "adjustment" | "freeze" | "unfreeze" | "invalidate" amountCents?: number balanceAfterCents?: number referenceType?: string | null referenceId?: string | null notes?: string | null actorType?: string | null actorId?: string | null createdAt?: string // date-time } } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## POST /api/v1/gift-cards/{id}/link-loyalty Link gift card to loyalty member Link a gift card to a loyalty member by user ID. Fails with 409 if already linked to a different user. **Permissions:** 🟠 `gift-cards.update` Path parameters: - `id` (string, required) — Gift card UUID Request body: ```ts { userId: number } ``` Response 200 — Gift card linked successfully ```ts { status?: "success" data?: { id?: string // uuid locationId?: number companyId?: number code?: string status?: "active" | "redeemed" | "expired" | "frozen" | "cancelled" balanceCents?: number initialBalanceCents?: number currency?: string voucherClassification?: "enfunktion" | "flerfunktion" validStartDate?: string // date-time validEndDate?: string // date-time designId?: string | null // uuid loyaltyMemberId?: number | null recipientName?: string | null recipientEmail?: string | null recipientPhone?: string | null senderName?: string | null senderEmail?: string | null purchaserUserId?: number | null recipientAddress?: Record | null emailLanguage?: string | null personalMessage?: string | null sourcePurchaseLineItemId?: number | null createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean issuingLocationName?: string } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## DELETE /api/v1/gift-cards/{id}/link-loyalty Unlink gift card from loyalty member Unlink a gift card from a loyalty member. Only succeeds if the card is linked to the specified user. **Permissions:** 🟠 `gift-cards.update` Path parameters: - `id` (string, required) — Gift card UUID Request body: ```ts { userId: number } ``` Response 200 — Gift card unlinked successfully ```ts { status?: "success" data?: { id?: string // uuid locationId?: number companyId?: number code?: string status?: "active" | "redeemed" | "expired" | "frozen" | "cancelled" balanceCents?: number initialBalanceCents?: number currency?: string voucherClassification?: "enfunktion" | "flerfunktion" validStartDate?: string // date-time validEndDate?: string // date-time designId?: string | null // uuid loyaltyMemberId?: number | null recipientName?: string | null recipientEmail?: string | null recipientPhone?: string | null senderName?: string | null senderEmail?: string | null purchaserUserId?: number | null recipientAddress?: Record | null emailLanguage?: string | null personalMessage?: string | null sourcePurchaseLineItemId?: number | null createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean issuingLocationName?: string } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## POST /api/v1/gift-cards/{id}/resend-email Resend a gift card email Queue a gift card email for the saved recipient and record the request **Permissions:** 🟠 `gift-cards.update` Path parameters: - `id` (string, required) Response 200 — Default Response ```ts { status?: string data?: { queued?: boolean } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## GET /api/v1/gift-cards/{id}/transactions Get gift card transactions Get transaction history for a gift card **Permissions:** 🔵 `gift-cards.read` Path parameters: - `id` (string, required) — Gift card UUID Response 200 — Transaction history ```ts { status?: "success" data?: { transactions?: Array<{ id?: number giftCardId?: string // uuid type?: "create" | "spend" | "refill" | "expire" | "migrate" | "adjustment" | "freeze" | "unfreeze" | "invalidate" amountCents?: number balanceAfterCents?: number referenceType?: string | null referenceId?: string | null notes?: string | null actorType?: string | null actorId?: string | null createdAt?: string // date-time }> } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## POST /api/v1/gift-cards/{id}/unfreeze Unfreeze gift card Allow a frozen gift card to be redeemed again **Permissions:** ⚪ `gift-cards.freeze` Path parameters: - `id` (string, required) Request body: ```ts { reason: string } ``` Response 200 — Updated gift card and audit transaction ```ts { status?: "success" data?: { giftCard?: { id?: string // uuid locationId?: number companyId?: number code?: string status?: "active" | "redeemed" | "expired" | "frozen" | "cancelled" balanceCents?: number initialBalanceCents?: number currency?: string voucherClassification?: "enfunktion" | "flerfunktion" validStartDate?: string // date-time validEndDate?: string // date-time designId?: string | null // uuid loyaltyMemberId?: number | null recipientName?: string | null recipientEmail?: string | null recipientPhone?: string | null senderName?: string | null senderEmail?: string | null purchaserUserId?: number | null recipientAddress?: Record | null emailLanguage?: string | null personalMessage?: string | null sourcePurchaseLineItemId?: number | null createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean issuingLocationName?: string } transaction?: { id?: number giftCardId?: string // uuid type?: "create" | "spend" | "refill" | "expire" | "migrate" | "adjustment" | "freeze" | "unfreeze" | "invalidate" amountCents?: number balanceAfterCents?: number referenceType?: string | null referenceId?: string | null notes?: string | null actorType?: string | null actorId?: string | null createdAt?: string // date-time } } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## GET /api/v1/gift-cards/by-code Get gift card by code Resolve a gift card by code at the X-Location-ID issuing location **Permissions:** 🔵 `gift-cards.read` Query parameters: - `code` (string, required) — Gift card code (case-insensitive) - `issuerLocationId` (integer, optional) Response 200 — Gift card details ```ts { status?: "success" data?: { id?: string // uuid locationId?: number companyId?: number code?: string status?: "active" | "redeemed" | "expired" | "frozen" | "cancelled" balanceCents?: number initialBalanceCents?: number currency?: string voucherClassification?: "enfunktion" | "flerfunktion" validStartDate?: string // date-time validEndDate?: string // date-time designId?: string | null // uuid loyaltyMemberId?: number | null recipientName?: string | null recipientEmail?: string | null recipientPhone?: string | null senderName?: string | null senderEmail?: string | null purchaserUserId?: number | null recipientAddress?: Record | null emailLanguage?: string | null personalMessage?: string | null sourcePurchaseLineItemId?: number | null createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean issuingLocationName?: string } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## POST /api/v1/gift-cards/lookup Look up gift card by code Resolve a gift card at the X-Location-ID issuing location without putting its bearer code in the request URL **Permissions:** 🔵 `gift-cards.read` Request body: ```ts { code: string issuerLocationId?: integer } ``` Response 200 — Gift card details with transaction history ```ts { status?: "success" data?: { id?: string // uuid locationId?: number companyId?: number code?: string status?: "active" | "redeemed" | "expired" | "frozen" | "cancelled" balanceCents?: number initialBalanceCents?: number currency?: string voucherClassification?: "enfunktion" | "flerfunktion" validStartDate?: string // date-time validEndDate?: string // date-time designId?: string | null // uuid loyaltyMemberId?: number | null recipientName?: string | null recipientEmail?: string | null recipientPhone?: string | null senderName?: string | null senderEmail?: string | null purchaserUserId?: number | null recipientAddress?: Record | null emailLanguage?: string | null personalMessage?: string | null sourcePurchaseLineItemId?: number | null createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean issuingLocationName?: string transactions?: Array<{ id?: number giftCardId?: string // uuid type?: "create" | "spend" | "refill" | "expire" | "migrate" | "adjustment" | "freeze" | "unfreeze" | "invalidate" amountCents?: number balanceAfterCents?: number referenceType?: string | null referenceId?: string | null notes?: string | null actorType?: string | null actorId?: string | null createdAt?: string // date-time }> } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## POST /api/v1/gift-cards/redeemable-issuers Request body: ```ts { code: string locationId: integer } ``` Response 200 — Default Response ## GET /api/v1/gift-cards/settings Get gift card settings Location-level defaults applied when gift cards are created. Location comes from X-Location-Id unless locationId is given. **Permissions:** 🔵 `gift-cards.read` Query parameters: - `locationId` (number, optional) — Overrides the X-Location-Id header Response 200 — Gift card settings for the location ```ts { status?: "success" data?: { locationId?: number voucherClassification?: "enfunktion" | "flerfunktion" giftCardDefaultValidityDays?: number giftCardLiabilityAccount?: string giftCardDeliveryFeeAccount?: string giftCardPhysicalFeeAccount?: string } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## PUT /api/v1/gift-cards/settings Update gift card settings Update the location-level gift card defaults. Only newly created cards are affected — existing cards keep the valid_end_date stamped at creation. giftCardLiabilityAccount is read-only and is ignored if sent. **Permissions:** 🟠 `gift-cards.update` Request body: ```ts { locationId?: number voucherClassification?: "enfunktion" | "flerfunktion" giftCardDefaultValidityDays?: integer giftCardDeliveryFeeAccount?: string giftCardPhysicalFeeAccount?: string } ``` Response 200 — Updated gift card settings ```ts { status?: "success" data?: { locationId?: number voucherClassification?: "enfunktion" | "flerfunktion" giftCardDefaultValidityDays?: number giftCardLiabilityAccount?: string giftCardDeliveryFeeAccount?: string giftCardPhysicalFeeAccount?: string } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## GET /api/v1/gift-cards/stats Get gift card portfolio statistics Get issued, redeemed, outstanding, expired, and upcoming expiry values for a location portfolio. **Permissions:** 🔵 `gift-cards.read` Query parameters: - `location_id` (number, optional) — Location ID; defaults to X-Location-Id - `include_children` (boolean, optional) — Include authorized descendants of location_id. - `location_ids` (string, optional) — Optional comma-separated subset of the resolved location hierarchy. Response 200 — Gift card portfolio statistics ```ts { status?: "success" data?: { totalLiabilityCents?: number totalCards?: number activeCards?: number fullyRedeemedCards?: number expiredCards?: number totalIssuedCents?: number totalRedeemedCents?: number totalRefilledCents?: number totalExpiredCents?: number avgBalanceCents?: number avgInitialValueCents?: number redemptionRate?: number expiredRate?: number expiring?: { days30?: { count?: number valueCents?: number } days90?: { count?: number valueCents?: number } days365?: { count?: number valueCents?: number } } last30Days?: { cardsCreated?: number cardsRedeemed?: number issuedCents?: number redeemedCents?: number } monthlyFlow?: Array<{ month?: string issuedCents?: number redeemedCents?: number expiredCents?: number }> } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` ## POST /api/v1/gift-cards/validate Validate gift card for payment Validate a gift card code for payment use **Permissions:** 🔵 `gift-cards.read` Request body: ```ts { code: string locationId: number issuerLocationId?: integer } ``` Response 200 — Validation result ```ts { status?: "success" data?: { valid?: boolean reason?: string | null giftCard?: { id?: string // uuid locationId?: number companyId?: number code?: string status?: "active" | "redeemed" | "expired" | "frozen" | "cancelled" balanceCents?: number initialBalanceCents?: number currency?: string voucherClassification?: "enfunktion" | "flerfunktion" validStartDate?: string // date-time validEndDate?: string // date-time designId?: string | null // uuid loyaltyMemberId?: number | null recipientName?: string | null recipientEmail?: string | null recipientPhone?: string | null senderName?: string | null senderEmail?: string | null purchaserUserId?: number | null recipientAddress?: Record | null emailLanguage?: string | null personalMessage?: string | null sourcePurchaseLineItemId?: number | null createdAt?: string // date-time updatedAt?: string // date-time isInherited?: boolean issuingLocationName?: string } | null } meta?: { requestId?: string timestamp?: string // date-time executionTimeMs?: number } } ``` # Invoice Recipients ## GET /api/v1/invoice-recipients/{recipientId}/auto-send-schedule Get auto-send schedule Fetch the auto-send schedule for a recipient. Returns null when no schedule is configured. **Permissions:** 🔵 `invoices.read` Path parameters: - `recipientId` (string, required) — Recipient UUID Response 200 — Default Response ```ts { status: "success" data: { schedule?: { id?: string // uuid recipientId?: string // uuid isActive?: boolean scheduleType?: "weekly" | "monthly" | "interval_days" dayOfWeek?: number | null dayOfMonth?: number | null intervalDays?: number | null timeOfDay?: string timezone?: string nextRunAt?: string | null // date-time lastRunAt?: string | null // date-time lastRunStatus?: string | null createdAt?: string // date-time updatedAt?: string // date-time } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/invoice-recipients/{recipientId}/auto-send-schedule Create or update auto-send schedule Configure automatic sending of accumulated draft invoices for a recipient. Supported shapes: weekly (dayOfWeek + timeOfDay), monthly (dayOfMonth 1–28 + timeOfDay), or every N days (intervalDays + timeOfDay). The sweep runs every 15 minutes and sends all non-empty open drafts (one per reference); empty schedules are skipped silently. **Permissions:** 🟠 `invoices.update` Path parameters: - `recipientId` (string, required) — Recipient UUID Request body: ```ts { isActive: boolean scheduleType: "weekly" | "monthly" | "interval_days" dayOfWeek?: number dayOfMonth?: number intervalDays?: number timeOfDay: string timezone?: string } ``` Response 200 — Default Response ```ts { status: "success" data: { id?: string // uuid recipientId?: string // uuid isActive?: boolean scheduleType?: "weekly" | "monthly" | "interval_days" dayOfWeek?: number | null dayOfMonth?: number | null intervalDays?: number | null timeOfDay?: string timezone?: string nextRunAt?: string | null // date-time lastRunAt?: string | null // date-time lastRunStatus?: string | null createdAt?: string // date-time updatedAt?: string // date-time } | null meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/invoice-recipients/{recipientId}/auto-send-schedule Delete auto-send schedule Remove the auto-send schedule for a recipient. **Permissions:** 🔴 `invoices.delete` Path parameters: - `recipientId` (string, required) — Recipient UUID Response 200 — Default Response ```ts { status: "success" data: { deleted: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/invoice-recipients/{recipientId}/references List recipient references List references for an invoice recipient. Each reference accumulates its own draft invoice — a recipient can have multiple open invoices, one per reference. **Permissions:** 🔵 `invoices.read` Query parameters: - `includeInactive` (boolean, optional) — Include deactivated references - `locationId` (number, optional) — Viewing child location ID, used when reading references from an inherited recipient Path parameters: - `recipientId` (string, required) — Recipient UUID Response 200 — Default Response ```ts { status: "success" data: { references: Array<{ id: string // uuid recipientId: string // uuid reference: string isActive: boolean createdAt: string // date-time updatedAt: string // date-time }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/invoice-recipients/{recipientId}/references Create recipient reference Add a reference to an invoice recipient. Upserts: re-adding an inactive reference reactivates it. References typed in the POS during invoice payment are created through this endpoint too. **Permissions:** 🟢 `invoices.create` Path parameters: - `recipientId` (string, required) — Recipient UUID Request body: ```ts { reference: string } ``` Response 201 — Default Response ```ts { status: "success" data: { id: string // uuid recipientId: string // uuid reference: string isActive: boolean createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/invoice-recipients/{recipientId}/references/{referenceId} Update recipient reference Rename or activate/deactivate a recipient reference. **Permissions:** 🟠 `invoices.update` Path parameters: - `recipientId` (string, required) - `referenceId` (string, required) Request body: ```ts { reference?: string isActive?: boolean } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid recipientId: string // uuid reference: string isActive: boolean createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/invoice-recipients/guest-lookup Look up guest by email Resolve a guest user by exact email (case-insensitive) for connecting to an invoice recipient. Returns 404 when no user matches. **Permissions:** 🔵 `invoices.read` Query parameters: - `locationId` (number, required) - `email` (string, required) Response 200 — Default Response ```ts { status: "success" data: { userId: number uuid: string name?: string | null email: string } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Invoices Invoice management, recipients, and location settings ## GET /api/v1/invoice-location-settings Get invoice location settings Retrieve invoice settings for a specific location. Creates default settings if none exist. **Permissions:** 🔵 `invoices.read` Query parameters: - `locationId` (number, required) — Location ID to fetch settings for (required) Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number defaultPaymentTermsDays: number invoiceNumberPrefix?: string | null invoiceNumberCounter: number minInvoiceAmount: number senderName?: string | null senderAddressLine1?: string | null senderAddressLine2?: string | null senderPostalCode?: string | null senderCity?: string | null senderCountry?: string | null senderEmail?: string | null senderPhone?: string | null senderOrganizationNumber?: string | null senderVatNumber?: string | null senderLogoUrl?: string | null bankAccountNumber?: string | null bankName?: string | null swiftBic?: string | null iban?: string | null paymentReferencePrefix?: string | null invoiceFooterText?: string | null invoiceTermsText?: string | null latePaymentFee: number latePaymentInterestRate: number autoGenerateEnabled: boolean autoSendOnGeneration: boolean defaultInvoiceSendMode: "manual" | "scheduled" | "immediate" defaultInvoiceSchedule: { scheduleType: "weekly" | "monthly" | "interval_days" dayOfWeek: number | null dayOfMonth: number | null intervalDays: number | null timeOfDay: string timezone: string } | null registerPurchasesInCashRegister: boolean collectionPeriodType: "calendar_month" | "rolling_30_days" | "weekly" reminderEnabled: boolean firstReminderDaysAfterDue: number secondReminderDaysAfterDue: number thirdReminderDaysAfterDue: number reminderFeeAmount: number autoMarkOverdueDays: number currency: string overrideParentSettings: boolean shareInvoiceNumberSeries: boolean allowChildRecipientCreation: boolean canCreateRecipients: boolean requiredRecipientFields: "name" | "email" | "organizationName" | "organizationNumber" | "phone" | "billingAddressLine1" | "billingAddressLine2" | "postalCode" | "city" | "country" | "reference" | "contactPerson" | "vatNumber"[] posReferenceRequirement: "optional" | "required" | "existing" sourceLocationId: number sourceLocationName?: string | null isInherited: boolean hasParent: boolean numberSeriesSourceLocationId: number numberSeriesSourceLocationName?: string | null createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/invoice-location-settings Update invoice location settings Update invoice settings for a specific location. Creates settings if they do not exist (upsert). All fields except locationId are optional for partial updates. **Permissions:** 🟠 `invoices.update` Request body: ```ts { locationId: number defaultPaymentTermsDays?: number invoiceNumberPrefix?: string | null minInvoiceAmount?: number senderName?: string | null senderAddressLine1?: string | null senderAddressLine2?: string | null senderPostalCode?: string | null senderCity?: string | null senderCountry?: string | null senderEmail?: string | null senderPhone?: string | null senderOrganizationNumber?: string | null senderVatNumber?: string | null senderLogoUrl?: string | null bankAccountNumber?: string | null bankName?: string | null swiftBic?: string | null iban?: string | null paymentReferencePrefix?: string | null invoiceFooterText?: string | null invoiceTermsText?: string | null latePaymentFee?: number latePaymentInterestRate?: number autoGenerateEnabled?: boolean autoSendOnGeneration?: boolean defaultInvoiceSendMode?: "manual" | "scheduled" | "immediate" defaultInvoiceSchedule?: { scheduleType: "weekly" | "monthly" | "interval_days" dayOfWeek: number | null dayOfMonth: number | null intervalDays: number | null timeOfDay: string timezone: string } | null registerPurchasesInCashRegister?: boolean collectionPeriodType?: "calendar_month" | "rolling_30_days" | "weekly" reminderEnabled?: boolean firstReminderDaysAfterDue?: number secondReminderDaysAfterDue?: number thirdReminderDaysAfterDue?: number reminderFeeAmount?: number autoMarkOverdueDays?: number currency?: string overrideParentSettings?: boolean shareInvoiceNumberSeries?: boolean allowChildRecipientCreation?: boolean requiredRecipientFields?: "name" | "email" | "organizationName" | "organizationNumber" | "phone" | "billingAddressLine1" | "billingAddressLine2" | "postalCode" | "city" | "country" | "reference" | "contactPerson" | "vatNumber"[] posReferenceRequirement?: "optional" | "required" | "existing" } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number defaultPaymentTermsDays: number invoiceNumberPrefix?: string | null invoiceNumberCounter: number minInvoiceAmount: number senderName?: string | null senderAddressLine1?: string | null senderAddressLine2?: string | null senderPostalCode?: string | null senderCity?: string | null senderCountry?: string | null senderEmail?: string | null senderPhone?: string | null senderOrganizationNumber?: string | null senderVatNumber?: string | null senderLogoUrl?: string | null bankAccountNumber?: string | null bankName?: string | null swiftBic?: string | null iban?: string | null paymentReferencePrefix?: string | null invoiceFooterText?: string | null invoiceTermsText?: string | null latePaymentFee: number latePaymentInterestRate: number autoGenerateEnabled: boolean autoSendOnGeneration: boolean defaultInvoiceSendMode: "manual" | "scheduled" | "immediate" defaultInvoiceSchedule: { scheduleType: "weekly" | "monthly" | "interval_days" dayOfWeek: number | null dayOfMonth: number | null intervalDays: number | null timeOfDay: string timezone: string } | null registerPurchasesInCashRegister: boolean collectionPeriodType: "calendar_month" | "rolling_30_days" | "weekly" reminderEnabled: boolean firstReminderDaysAfterDue: number secondReminderDaysAfterDue: number thirdReminderDaysAfterDue: number reminderFeeAmount: number autoMarkOverdueDays: number currency: string overrideParentSettings: boolean shareInvoiceNumberSeries: boolean allowChildRecipientCreation: boolean canCreateRecipients: boolean requiredRecipientFields: "name" | "email" | "organizationName" | "organizationNumber" | "phone" | "billingAddressLine1" | "billingAddressLine2" | "postalCode" | "city" | "country" | "reference" | "contactPerson" | "vatNumber"[] posReferenceRequirement: "optional" | "required" | "existing" sourceLocationId: number sourceLocationName?: string | null isInherited: boolean hasParent: boolean numberSeriesSourceLocationId: number numberSeriesSourceLocationName?: string | null createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/invoice-recipients List invoice recipients List all invoice recipients for a location with optional filtering and pagination. **Permissions:** 🔵 `invoices.read` Query parameters: - `locationId` (number, required) — Location ID (required) - `isActive` (boolean, optional) — Filter by active status - `search` (string, optional) — Match against name, organization name, email or organization number - `page` (number, optional) — Page number - `limit` (number, optional) — Items per page Response 200 — Default Response ```ts { status: "success" data: { items: Array<{ id: string // uuid locationId: number name: string email: string organizationName?: string | null organizationNumber?: string | null phone?: string | null billingAddressLine1?: string | null billingAddressLine2?: string | null postalCode?: string | null city?: string | null country?: string | null reference?: string | null contactPerson?: string | null vatNumber?: string | null totalInvoicedAmount: number totalPaidAmount: number outstandingAmount: number invoiceCount: number isActive: boolean showInvoiceAddOns: boolean invoiceReferenceGrouping: "per_reference" | "combined" sourceLocationId: number sourceLocationName?: string | null isInherited: boolean relationship: "local" | "ancestor" | "descendant" 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 } } ``` ## POST /api/v1/invoice-recipients Create invoice recipient Create a new invoice recipient for a location. **Permissions:** 🟢 `invoices.create` Request body: ```ts { locationId: number name: string email: string organizationName?: string organizationNumber?: string phone?: string billingAddressLine1?: string billingAddressLine2?: string postalCode?: string city?: string country: string reference?: string contactPerson?: string vatNumber?: string showInvoiceAddOns?: boolean invoiceReferenceGrouping?: "per_reference" | "combined" } ``` Response 201 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number name: string email: string organizationName?: string | null organizationNumber?: string | null phone?: string | null billingAddressLine1?: string | null billingAddressLine2?: string | null postalCode?: string | null city?: string | null country?: string | null reference?: string | null contactPerson?: string | null vatNumber?: string | null totalInvoicedAmount: number totalPaidAmount: number outstandingAmount: number invoiceCount: number isActive: boolean showInvoiceAddOns: boolean invoiceReferenceGrouping: "per_reference" | "combined" sourceLocationId: number sourceLocationName?: string | null isInherited: boolean relationship: "local" | "ancestor" | "descendant" createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/invoice-recipients/{recipientId} Get invoice recipient Get a single invoice recipient by ID. **Permissions:** 🔵 `invoices.read` Path parameters: - `recipientId` (string, required) — Recipient UUID Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number name: string email: string organizationName?: string | null organizationNumber?: string | null phone?: string | null billingAddressLine1?: string | null billingAddressLine2?: string | null postalCode?: string | null city?: string | null country?: string | null reference?: string | null contactPerson?: string | null vatNumber?: string | null totalInvoicedAmount: number totalPaidAmount: number outstandingAmount: number invoiceCount: number isActive: boolean showInvoiceAddOns: boolean invoiceReferenceGrouping: "per_reference" | "combined" sourceLocationId: number sourceLocationName?: string | null isInherited: boolean relationship: "local" | "ancestor" | "descendant" createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/invoice-recipients/{recipientId} Update invoice recipient Update an existing invoice recipient. All fields are optional for partial updates. **Permissions:** 🟠 `invoices.update` Path parameters: - `recipientId` (string, required) — Recipient UUID Request body: ```ts { name?: string email?: string organizationName?: string | null organizationNumber?: string | null phone?: string | null billingAddressLine1?: string | null billingAddressLine2?: string | null postalCode?: string | null city?: string | null country?: string | null reference?: string | null contactPerson?: string | null vatNumber?: string | null isActive?: boolean showInvoiceAddOns?: boolean invoiceReferenceGrouping?: "per_reference" | "combined" } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number name: string email: string organizationName?: string | null organizationNumber?: string | null phone?: string | null billingAddressLine1?: string | null billingAddressLine2?: string | null postalCode?: string | null city?: string | null country?: string | null reference?: string | null contactPerson?: string | null vatNumber?: string | null totalInvoicedAmount: number totalPaidAmount: number outstandingAmount: number invoiceCount: number isActive: boolean showInvoiceAddOns: boolean invoiceReferenceGrouping: "per_reference" | "combined" sourceLocationId: number sourceLocationName?: string | null isInherited: boolean relationship: "local" | "ancestor" | "descendant" createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/invoice-recipients/{recipientId} Delete invoice recipient Permanently delete an invoice recipient that has no invoices. **Permissions:** 🔴 `invoices.delete` Path parameters: - `recipientId` (string, required) — Recipient UUID Response 200 — Default Response ```ts { status: "success" data: { deleted: true } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/invoice-recipients/{recipientId}/lift Lift invoice recipient Move a recipient owned by a descendant location into the viewing parent while preserving invoice links. **Permissions:** 🟠 `invoices.update` Path parameters: - `recipientId` (string, required) — Recipient UUID Request body: ```ts { targetLocationId: number } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number name: string email: string organizationName?: string | null organizationNumber?: string | null phone?: string | null billingAddressLine1?: string | null billingAddressLine2?: string | null postalCode?: string | null city?: string | null country?: string | null reference?: string | null contactPerson?: string | null vatNumber?: string | null totalInvoicedAmount: number totalPaidAmount: number outstandingAmount: number invoiceCount: number isActive: boolean showInvoiceAddOns: boolean invoiceReferenceGrouping: "per_reference" | "combined" sourceLocationId: number sourceLocationName?: string | null isInherited: boolean relationship: "local" | "ancestor" | "descendant" createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/invoice-recipients/{recipientId}/rules List recipient rules List all active eligibility rules for an invoice recipient. **Permissions:** 🔵 `invoices.read` Path parameters: - `recipientId` (string, required) — Recipient UUID Response 200 — Default Response ```ts { status: "success" data: { rules: Array<{ id: string // uuid recipientId: string // uuid locationId: number ruleType: "email_exact" | "email_wildcard" | "user_id" ruleValue: string isActive: boolean maxCreditLimit?: number | null autoApprove: boolean createdAt: string // date-time updatedAt: string // date-time userName?: string | null userEmail?: string | null }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/invoice-recipients/{recipientId}/rules Create recipient rule Create a new eligibility rule for automatic invoice assignment. **Permissions:** 🟢 `invoices.create` Path parameters: - `recipientId` (string, required) — Recipient UUID Request body: ```ts { locationId: number ruleType: "email_exact" | "email_wildcard" | "user_id" ruleValue: string maxCreditLimit?: number autoApprove?: boolean } ``` Response 201 — Default Response ```ts { status: "success" data: { id: string // uuid recipientId: string // uuid locationId: number ruleType: "email_exact" | "email_wildcard" | "user_id" ruleValue: string isActive: boolean maxCreditLimit?: number | null autoApprove: boolean createdAt: string // date-time updatedAt: string // date-time userName?: string | null userEmail?: string | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/invoice-recipients/{recipientId}/rules/{ruleId} Delete recipient rule Delete an eligibility rule. **Permissions:** 🔴 `invoices.delete` Path parameters: - `recipientId` (string, required) — Recipient UUID - `ruleId` (string, required) — Rule UUID Response 200 — Default Response ```ts { status: "success" data: { deleted: true } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/invoices List invoices List all invoices for a location with optional filtering and pagination. **Permissions:** 🔵 `invoices.read` Query parameters: - `locationId` (number, required) — Location ID (required) - `includeChildren` (boolean, optional) — Include authorized descendants of locationId. - `locationIds` (string, optional) — Optional comma-separated subset of the resolved location hierarchy. - `recipientId` (string, optional) — Filter by recipient - `status` ("draft" | "sent" | "paid" | "overdue" | "cancelled" | "auto_draft", optional) — Filter by status - `invoiceType` ("manual" | "auto_generated" | "credit", optional) — Filter by invoice type - `startDate` (string, optional) — Filter by start date - `endDate` (string, optional) — Filter by end date - `page` (number, optional) — Page number - `limit` (number, optional) — Items per page Response 200 — Default Response ```ts { status: "success" data: { items: Array<{ id: string // uuid locationId: number recipientId: string // uuid referenceId?: string | null // uuid reference?: string | null invoiceNumber: string status: "draft" | "sent" | "paid" | "overdue" | "cancelled" | "auto_draft" invoiceType: "manual" | "auto_generated" | "credit" collectionPeriodStart?: string | null // date-time collectionPeriodEnd?: string | null // date-time totalAmount: number taxAmount: number subtotalAmount: number currency: string dueDate: string // date issuedDate?: string | null // date paidDate?: string | null // date paymentReference?: string | null notes?: string | null createdBy?: number | null createdAt: string // date-time updatedAt: string // date-time originalInvoiceId?: string | null // uuid timeline?: unknown[] }> pagination: { page: number limit: number total: number totalPages: number } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/invoices Create invoice Create a new invoice. Invoice number is automatically generated. **Permissions:** 🟢 `invoices.create` Request body: ```ts { locationId: number recipientId: string // uuid referenceId?: string // uuid dueDate: string // date currency?: string invoiceType?: "manual" | "auto_generated" collectionPeriodStart?: string // date-time collectionPeriodEnd?: string // date-time totalAmount?: number taxAmount?: number subtotalAmount?: number issuedDate?: string // date paymentReference?: string notes?: string purchaseIds?: number[] } ``` Response 201 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number recipientId: string // uuid referenceId?: string | null // uuid reference?: string | null invoiceNumber: string status: "draft" | "sent" | "paid" | "overdue" | "cancelled" | "auto_draft" invoiceType: "manual" | "auto_generated" | "credit" collectionPeriodStart?: string | null // date-time collectionPeriodEnd?: string | null // date-time totalAmount: number taxAmount: number subtotalAmount: number currency: string dueDate: string // date issuedDate?: string | null // date paidDate?: string | null // date paymentReference?: string | null notes?: string | null createdBy?: number | null createdAt: string // date-time updatedAt: string // date-time originalInvoiceId?: string | null // uuid timeline?: Array<{ id: string action: "created" | "updated" | "sent" | "marked_sent" | "sent_to_fortnox" | "paid" | "voided" | "credit_created" | "purchases_added" | "purchase_removed" occurredAt: string // date-time actor: { … } details: { … } }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/invoices/{invoiceId} Get invoice Get a single invoice by ID. **Permissions:** 🔵 `invoices.read` Path parameters: - `invoiceId` (string, required) — Invoice UUID Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number recipientId: string // uuid referenceId?: string | null // uuid reference?: string | null invoiceNumber: string status: "draft" | "sent" | "paid" | "overdue" | "cancelled" | "auto_draft" invoiceType: "manual" | "auto_generated" | "credit" collectionPeriodStart?: string | null // date-time collectionPeriodEnd?: string | null // date-time totalAmount: number taxAmount: number subtotalAmount: number currency: string dueDate: string // date issuedDate?: string | null // date paidDate?: string | null // date paymentReference?: string | null notes?: string | null createdBy?: number | null createdAt: string // date-time updatedAt: string // date-time originalInvoiceId?: string | null // uuid timeline?: Array<{ id: string action: "created" | "updated" | "sent" | "marked_sent" | "sent_to_fortnox" | "paid" | "voided" | "credit_created" | "purchases_added" | "purchase_removed" occurredAt: string // date-time actor: { … } details: { … } }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/invoices/{invoiceId} Update invoice Update an existing invoice. All fields are optional for partial updates. **Permissions:** 🟠 `invoices.update` Path parameters: - `invoiceId` (string, required) — Invoice UUID Request body: ```ts { status?: "draft" | "sent" | "paid" | "overdue" | "cancelled" | "auto_draft" dueDate?: string // date issuedDate?: string | null // date paidDate?: string | null // date paymentReference?: string | null notes?: string | null totalAmount?: number taxAmount?: number subtotalAmount?: number } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number recipientId: string // uuid referenceId?: string | null // uuid reference?: string | null invoiceNumber: string status: "draft" | "sent" | "paid" | "overdue" | "cancelled" | "auto_draft" invoiceType: "manual" | "auto_generated" | "credit" collectionPeriodStart?: string | null // date-time collectionPeriodEnd?: string | null // date-time totalAmount: number taxAmount: number subtotalAmount: number currency: string dueDate: string // date issuedDate?: string | null // date paidDate?: string | null // date paymentReference?: string | null notes?: string | null createdBy?: number | null createdAt: string // date-time updatedAt: string // date-time originalInvoiceId?: string | null // uuid timeline?: Array<{ id: string action: "created" | "updated" | "sent" | "marked_sent" | "sent_to_fortnox" | "paid" | "voided" | "credit_created" | "purchases_added" | "purchase_removed" occurredAt: string // date-time actor: { … } details: { … } }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/invoices/{invoiceId} Delete invoice Delete a draft invoice. Only draft or auto_draft invoices can be deleted. **Permissions:** 🔴 `invoices.delete` Path parameters: - `invoiceId` (string, required) — Invoice UUID Response 200 — Default Response ```ts { status: "success" data: { deleted: true } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/invoices/{invoiceId}/cancel Void invoice Void an unsent draft invoice. **Permissions:** 🟠 `invoices.update` Path parameters: - `invoiceId` (string, required) — Invoice UUID Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number recipientId: string // uuid referenceId?: string | null // uuid reference?: string | null invoiceNumber: string status: "draft" | "sent" | "paid" | "overdue" | "cancelled" | "auto_draft" invoiceType: "manual" | "auto_generated" | "credit" collectionPeriodStart?: string | null // date-time collectionPeriodEnd?: string | null // date-time totalAmount: number taxAmount: number subtotalAmount: number currency: string dueDate: string // date issuedDate?: string | null // date paidDate?: string | null // date paymentReference?: string | null notes?: string | null createdBy?: number | null createdAt: string // date-time updatedAt: string // date-time originalInvoiceId?: string | null // uuid timeline?: Array<{ id: string action: "created" | "updated" | "sent" | "marked_sent" | "sent_to_fortnox" | "paid" | "voided" | "credit_created" | "purchases_added" | "purchase_removed" occurredAt: string // date-time actor: { … } details: { … } }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/invoices/{invoiceId}/credit-invoices Create credit invoice Create a negative draft invoice from selected lines on a sent invoice. Amounts are specified in whole cents. **Permissions:** 🟢 `invoices.create` Path parameters: - `invoiceId` (string, required) — Original invoice UUID Request body: ```ts { lines: Array<{ lineItemId: integer amountCents: integer }> notes?: string } ``` Response 201 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number recipientId: string // uuid referenceId?: string | null // uuid reference?: string | null invoiceNumber: string status: "draft" | "sent" | "paid" | "overdue" | "cancelled" | "auto_draft" invoiceType: "manual" | "auto_generated" | "credit" collectionPeriodStart?: string | null // date-time collectionPeriodEnd?: string | null // date-time totalAmount: number taxAmount: number subtotalAmount: number currency: string dueDate: string // date issuedDate?: string | null // date paidDate?: string | null // date paymentReference?: string | null notes?: string | null createdBy?: number | null createdAt: string // date-time updatedAt: string // date-time originalInvoiceId?: string | null // uuid timeline?: Array<{ id: string action: "created" | "updated" | "sent" | "marked_sent" | "sent_to_fortnox" | "paid" | "voided" | "credit_created" | "purchases_added" | "purchase_removed" occurredAt: string // date-time actor: { … } details: { … } }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/invoices/{invoiceId}/mark-paid Mark invoice as paid Mark an invoice as paid. Cannot mark cancelled invoices as paid. **Permissions:** 🟠 `invoices.update` Path parameters: - `invoiceId` (string, required) — Invoice UUID Request body: ```ts { paymentReference?: string } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number recipientId: string // uuid referenceId?: string | null // uuid reference?: string | null invoiceNumber: string status: "draft" | "sent" | "paid" | "overdue" | "cancelled" | "auto_draft" invoiceType: "manual" | "auto_generated" | "credit" collectionPeriodStart?: string | null // date-time collectionPeriodEnd?: string | null // date-time totalAmount: number taxAmount: number subtotalAmount: number currency: string dueDate: string // date issuedDate?: string | null // date paidDate?: string | null // date paymentReference?: string | null notes?: string | null createdBy?: number | null createdAt: string // date-time updatedAt: string // date-time originalInvoiceId?: string | null // uuid timeline?: Array<{ id: string action: "created" | "updated" | "sent" | "marked_sent" | "sent_to_fortnox" | "paid" | "voided" | "credit_created" | "purchases_added" | "purchase_removed" occurredAt: string // date-time actor: { … } details: { … } }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/invoices/{invoiceId}/mark-sent Mark invoice as sent Mark a draft invoice as sent without emailing it or creating it in an accounting integration. **Permissions:** 🟠 `invoices.update` Path parameters: - `invoiceId` (string, required) — Invoice UUID Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number recipientId: string // uuid referenceId?: string | null // uuid reference?: string | null invoiceNumber: string status: "draft" | "sent" | "paid" | "overdue" | "cancelled" | "auto_draft" invoiceType: "manual" | "auto_generated" | "credit" collectionPeriodStart?: string | null // date-time collectionPeriodEnd?: string | null // date-time totalAmount: number taxAmount: number subtotalAmount: number currency: string dueDate: string // date issuedDate?: string | null // date paidDate?: string | null // date paymentReference?: string | null notes?: string | null createdBy?: number | null createdAt: string // date-time updatedAt: string // date-time originalInvoiceId?: string | null // uuid timeline?: Array<{ id: string action: "created" | "updated" | "sent" | "marked_sent" | "sent_to_fortnox" | "paid" | "voided" | "credit_created" | "purchases_added" | "purchase_removed" occurredAt: string // date-time actor: { … } details: { … } }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/invoices/{invoiceId}/pdf-data Get invoice PDF data Get all data needed for PDF generation (invoice, recipient, settings, location, purchases). **Permissions:** 🔵 `invoices.read` Path parameters: - `invoiceId` (string, required) — Invoice UUID Response 200 — Default Response ```ts { status: "success" data: { invoice: { id: string // uuid locationId: number recipientId: string // uuid referenceId?: string | null // uuid reference?: string | null invoiceNumber: string status: "draft" | "sent" | "paid" | "overdue" | "cancelled" | "auto_draft" invoiceType: "manual" | "auto_generated" | "credit" collectionPeriodStart?: string | null // date-time collectionPeriodEnd?: string | null // date-time totalAmount: number taxAmount: number subtotalAmount: number currency: string dueDate: string // date issuedDate?: string | null // date paidDate?: string | null // date paymentReference?: string | null notes?: string | null createdBy?: number | null createdAt: string // date-time updatedAt: string // date-time originalInvoiceId?: string | null // uuid timeline?: { … }[] } recipient?: { id: string // uuid locationId: number name: string email: string organizationName?: string | null organizationNumber?: string | null phone?: string | null billingAddressLine1?: string | null billingAddressLine2?: string | null postalCode?: string | null city?: string | null country?: string | null reference?: string | null contactPerson?: string | null vatNumber?: string | null totalInvoicedAmount: number totalPaidAmount: number outstandingAmount: number invoiceCount: number isActive: boolean showInvoiceAddOns: boolean invoiceReferenceGrouping: "per_reference" | "combined" sourceLocationId: number sourceLocationName?: string | null isInherited: boolean relationship: "local" | "ancestor" | "descendant" createdAt: string // date-time updatedAt: string // date-time } | null settings?: { id: string // uuid locationId: number defaultPaymentTermsDays: number invoiceNumberPrefix?: string | null invoiceNumberCounter: number minInvoiceAmount: number senderName?: string | null senderAddressLine1?: string | null senderAddressLine2?: string | null senderPostalCode?: string | null senderCity?: string | null senderCountry?: string | null senderEmail?: string | null senderPhone?: string | null senderOrganizationNumber?: string | null senderVatNumber?: string | null senderLogoUrl?: string | null bankAccountNumber?: string | null bankName?: string | null swiftBic?: string | null iban?: string | null paymentReferencePrefix?: string | null invoiceFooterText?: string | null invoiceTermsText?: string | null latePaymentFee: number latePaymentInterestRate: number autoGenerateEnabled: boolean autoSendOnGeneration: boolean defaultInvoiceSendMode: "manual" | "scheduled" | "immediate" defaultInvoiceSchedule: { scheduleType: "weekly" | "monthly" | "interval_days" dayOfWeek: number | null dayOfMonth: number | null intervalDays: number | null timeOfDay: string timezone: string } | null registerPurchasesInCashRegister: boolean collectionPeriodType: "calendar_month" | "rolling_30_days" | "weekly" reminderEnabled: boolean firstReminderDaysAfterDue: number secondReminderDaysAfterDue: number thirdReminderDaysAfterDue: number reminderFeeAmount: number autoMarkOverdueDays: number currency: string overrideParentSettings: boolean shareInvoiceNumberSeries: boolean allowChildRecipientCreation: boolean canCreateRecipients: boolean requiredRecipientFields: "name" | "email" | "organizationName" | "organizationNumber" | "phone" | "billingAddressLine1" | "billingAddressLine2" | "postalCode" | "city" | "country" | "reference" | "contactPerson" | "vatNumber"[] posReferenceRequirement: "optional" | "required" | "existing" sourceLocationId: number sourceLocationName?: string | null isInherited: boolean hasParent: boolean numberSeriesSourceLocationId: number numberSeriesSourceLocationName?: string | null createdAt: string // date-time updatedAt: string // date-time } | null location?: { id: number name?: string | null address?: string | null postalCode?: string | null city?: string | null country?: string | null phone?: string | null organizationNumber?: string | null email?: string | null vatNumber?: string | null } | null purchaseDetails: Array<{ id: number intentId: string purchasedAt: string // date-time totalAmountCents: number vatAmountCents: number items?: string | null reference?: string | null orderNumber?: string | null orderComments?: string | null lineItems: unknown[] }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/invoices/{invoiceId}/purchases Add purchases to invoice Add purchases to an invoice. Only draft invoices can have purchases added. **Permissions:** 🟠 `invoices.update` Path parameters: - `invoiceId` (string, required) — Invoice UUID Request body: ```ts { purchaseIds: number[] } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number recipientId: string // uuid referenceId?: string | null // uuid reference?: string | null invoiceNumber: string status: "draft" | "sent" | "paid" | "overdue" | "cancelled" | "auto_draft" invoiceType: "manual" | "auto_generated" | "credit" collectionPeriodStart?: string | null // date-time collectionPeriodEnd?: string | null // date-time totalAmount: number taxAmount: number subtotalAmount: number currency: string dueDate: string // date issuedDate?: string | null // date paidDate?: string | null // date paymentReference?: string | null notes?: string | null createdBy?: number | null createdAt: string // date-time updatedAt: string // date-time originalInvoiceId?: string | null // uuid timeline?: Array<{ id: string action: "created" | "updated" | "sent" | "marked_sent" | "sent_to_fortnox" | "paid" | "voided" | "credit_created" | "purchases_added" | "purchase_removed" occurredAt: string // date-time actor: { … } details: { … } }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/invoices/{invoiceId}/purchases/{purchaseId} Remove purchase from invoice Remove a purchase from an invoice. Only draft invoices can have purchases removed. **Permissions:** 🟠 `invoices.update` Path parameters: - `invoiceId` (string, required) — Invoice UUID - `purchaseId` (number, required) — Purchase ID to remove Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number recipientId: string // uuid referenceId?: string | null // uuid reference?: string | null invoiceNumber: string status: "draft" | "sent" | "paid" | "overdue" | "cancelled" | "auto_draft" invoiceType: "manual" | "auto_generated" | "credit" collectionPeriodStart?: string | null // date-time collectionPeriodEnd?: string | null // date-time totalAmount: number taxAmount: number subtotalAmount: number currency: string dueDate: string // date issuedDate?: string | null // date paidDate?: string | null // date paymentReference?: string | null notes?: string | null createdBy?: number | null createdAt: string // date-time updatedAt: string // date-time originalInvoiceId?: string | null // uuid timeline?: Array<{ id: string action: "created" | "updated" | "sent" | "marked_sent" | "sent_to_fortnox" | "paid" | "voided" | "credit_created" | "purchases_added" | "purchase_removed" occurredAt: string // date-time actor: { … } details: { … } }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PATCH /api/v1/invoices/{invoiceId}/purchases/{purchaseId} Set a purchase's reference on an invoice Set or clear the reference shown for one purchase on an invoice. Only draft invoices can change. **Permissions:** 🟠 `invoices.update` Path parameters: - `invoiceId` (string, required) — Invoice UUID - `purchaseId` (number, required) — Purchase ID on the invoice Request body: ```ts { reference: string | null } ``` Response 200 — Default Response ```ts { status?: string data?: { purchaseId?: number reference?: string | null } } ``` ## POST /api/v1/invoices/{invoiceId}/send Send invoice Send an invoice to the recipient: generates the PDF server-side, stores it in GCS, emails the recipient (PDF attached + link) via communication-service, and marks the invoice as sent. Only draft invoices can be sent. **Permissions:** 🟠 `invoices.update` Path parameters: - `invoiceId` (string, required) — Invoice UUID Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number recipientId: string // uuid referenceId?: string | null // uuid reference?: string | null invoiceNumber: string status: "draft" | "sent" | "paid" | "overdue" | "cancelled" | "auto_draft" invoiceType: "manual" | "auto_generated" | "credit" collectionPeriodStart?: string | null // date-time collectionPeriodEnd?: string | null // date-time totalAmount: number taxAmount: number subtotalAmount: number currency: string dueDate: string // date issuedDate?: string | null // date paidDate?: string | null // date paymentReference?: string | null notes?: string | null createdBy?: number | null createdAt: string // date-time updatedAt: string // date-time originalInvoiceId?: string | null // uuid timeline?: Array<{ id: string action: "created" | "updated" | "sent" | "marked_sent" | "sent_to_fortnox" | "paid" | "voided" | "credit_created" | "purchases_added" | "purchase_removed" occurredAt: string // date-time actor: { … } details: { … } }> pdfUrl: string emailSentTo: string } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/invoices/{invoiceId}/send-reminder Send invoice reminder Send a payment reminder for an overdue invoice. **Permissions:** 🟠 `invoices.update` Path parameters: - `invoiceId` (string, required) — Invoice UUID Request body: ```ts { reminderNumber: number } ``` Response 200 — Default Response ```ts { status: "success" data: { sent: boolean reminderNumber: number } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/invoices/{invoiceId}/send-to-fortnox Send invoice Send an invoice to the recipient: generates the PDF server-side, stores it in GCS, emails the recipient (PDF attached + link) via communication-service, and marks the invoice as sent. Only draft invoices can be sent. **Permissions:** 🟠 `invoices.update` Path parameters: - `invoiceId` (string, required) — Invoice UUID Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid locationId: number recipientId: string // uuid referenceId?: string | null // uuid reference?: string | null invoiceNumber: string status: "draft" | "sent" | "paid" | "overdue" | "cancelled" | "auto_draft" invoiceType: "manual" | "auto_generated" | "credit" collectionPeriodStart?: string | null // date-time collectionPeriodEnd?: string | null // date-time totalAmount: number taxAmount: number subtotalAmount: number currency: string dueDate: string // date issuedDate?: string | null // date paidDate?: string | null // date paymentReference?: string | null notes?: string | null createdBy?: number | null createdAt: string // date-time updatedAt: string // date-time originalInvoiceId?: string | null // uuid timeline?: Array<{ id: string action: "created" | "updated" | "sent" | "marked_sent" | "sent_to_fortnox" | "paid" | "voided" | "credit_created" | "purchases_added" | "purchase_removed" occurredAt: string // date-time actor: { … } details: { … } }> pdfUrl: string emailSentTo: string } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/invoices/{invoiceId}/upload-pdf Upload invoice PDF Upload a generated PDF for an invoice. The PDF should be base64-encoded. **Permissions:** 🟠 `invoices.update` Path parameters: - `invoiceId` (string, required) — Invoice UUID Request body: ```ts { pdfBase64: string } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid invoiceId: string // uuid pdfUrl: string cdnPath: string fileSize: number generatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/invoices/auto-send/run Run auto-send sweep Claim due auto-send schedules and send their accumulated draft invoices. Invoked by Cloud Scheduler every 15 minutes with an INTERNAL-tier API key. Idempotent: claiming advances next_run_at so concurrent calls cannot double-send. **Permissions:** 🟠 `invoices.update` Response 200 — Default Response ```ts { status: "success" data: { schedulesProcessed: number invoicesSent: number invoicesFailed: number details?: Array<{ recipientId?: string recipientName?: string sent?: unknown[] failed?: unknown[] }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/invoices/check-eligibility Check invoice eligibility Check if a user/email is eligible for invoice payment at a location. **Permissions:** 🔵 `invoices.read` Query parameters: - `locationId` (number, required) — Location ID (required) - `email` (string, required) — Email to check eligibility for (required) - `userId` (number, optional) — User ID to check eligibility for (optional) Response 200 — Default Response ```ts { status: "success" data: { eligible: boolean recipientId?: string | null creditLimit?: number | null outstandingAmount?: number | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/invoices/start-purchase Start invoice purchase (POS) Start an invoice-based purchase from POS. Creates a tab settlement and initiates payment via the invoice PSP. **Permissions:** 🟢 `invoices.create` Request body: ```ts { tabId: string // uuid locationId: number recipientId?: string // uuid referenceId?: string // uuid selectedKitchenDispatchItemIds?: string[] voucherCodes?: string[] } ``` Response 200 — Default Response ```ts { status: "success" data: { success: boolean purchaseIntentId: string // uuid invoiceReference?: string | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/invoices/stats Invoice summary stats Summary cards + 12-month volume series for the invoices page. Amounts are in major currency units (same unit as invoice totalAmount). Unsent = draft + auto_draft; sent = sent + overdue (issued, unpaid). **Permissions:** 🔵 `invoices.read` Query parameters: - `locationId` (number, required) — Location ID - `includeChildren` (boolean, optional) — Include authorized descendant locations Response 200 — Default Response ```ts { status: "success" data: { unsent: { count: number amount: number } sent: { count: number amount: number } monthly: Array<{ month: string amount: number count: number }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/invoices/unclaimed-purchases Get unclaimed invoice purchases Get purchases that were made with invoice payment but not yet assigned to an invoice. **Permissions:** 🔵 `invoices.read` Query parameters: - `locationId` (number, required) — Location ID (required) - `page` (number, optional) — Page number - `limit` (number, optional) — Items per page Response 200 — Default Response ```ts { status: "success" data: { items: Array<{ id: number intentId: string purchasedAt: string // date-time payingUserId?: number | null state: string currencyCode: string totalAmountCents: number vatAmountCents: number items?: string | null userEmail?: string | null userName?: string | null reference?: string | null orderNumber?: string | null }> pagination: { page: number limit: number total: number totalPages: number } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Payment Methods User and location payment methods management ## PUT /api/v1/locations/{locationId}/payment-methods/prepaid Add the prepaid payment method to a location Enables the built-in prepaid payment method for a location. The operation is idempotent and does not alter prepaid type definitions or other PSP settings. Use `POST /api/v1/prepaid-types` separately to add a selectable prepaid type such as Wolt or a house account. **Permissions:** 🟠 `prepaid_payment_methods.update` Path parameters: - `locationId` (string, required) — Location ID Response 200 — Default Response ```ts { status: "success" data: { paymentMethod: "prepaid" enabled: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/locations/{locationId}/payment-methods/prepaid Remove the prepaid payment method from a location Disables the built-in prepaid payment method for a location. The operation is idempotent and retains prepaid type definitions and historical payment data. Re-enable it with PUT on the same URL. **Permissions:** 🟠 `prepaid_payment_methods.update` Path parameters: - `locationId` (string, required) — Location ID Response 204 — Prepaid payment method disabled ```ts unknown ``` ## POST /api/v1/loyalty/adyen-card-session Create Adyen zero-value auth session for self-service card linking Creates an Adyen checkout session with amount=0 and storePaymentMethodMode=enabled. The client initialises Adyen Drop-in with the returned `sessionId` and `sessionData`. After the user enters their card details, Adyen tokenises the card and issues a webhook. The resulting token is stored in `user_payment_methods` for future card-linked loyalty recognition. **Requires authenticated user session.** **Permissions:** 🟢 `payment-methods.create` Request body: ```ts { returnUrl: string channel?: "Web" | "iOS" | "Android" } ``` Response 200 — Default Response ```ts { status: "success" data: { sessionId: string sessionData: string clientKey?: string | null environment: string } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/loyalty/finalize-card-session Finalize Adyen zero-auth session and store card token Verifies the Adyen session result and stores the tokenized card in user_payment_methods. Call this after Adyen Drop-in's onPaymentCompleted fires with resultCode 'Authorised'. The sessionResult token (from the Drop-in callback) is verified server-side against Adyen's Sessions API — the card is never stored based solely on client-supplied data. If this is the user's first card, it is automatically set as their default. **Requires authenticated user session.** **Permissions:** 🟢 `payment-methods.create` Request body: ```ts { sessionId: string sessionResult: string } ``` Response 200 — Default Response ```ts { status: "success" data: { card: { id: string brand?: string | null last4?: string | null expiryMonth?: integer | null expiryYear?: integer | null isDefault: boolean isTokenized: boolean nickname?: string | null } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/loyalty/link-payment-method Link payment card to user account Links a payment card (from a purchase) to a user account. This endpoint: 1. Extracts card data from the purchase 2. Finds or creates a user by email/phone 3. Links the card to the user for future recognition 4. Optionally enrolls the user in the loyalty program 5. Awards retroactive points for the triggering purchase **Use case**: After a guest makes a purchase and enters their email/phone, their card is linked so future purchases automatically earn loyalty points. **Permissions:** 🟢 `loyalty.create` Request body: ```ts { purchaseIntentId: string email?: string // email phone?: string enrollInProgram?: boolean locationId?: number } ``` Response 201 — Default Response ```ts { status: "success" data: { user: { id: number isNewUser: boolean } paymentMethod: { linked: boolean id: string | null // uuid brand: string | null last4: string | null } enrollment: { enrolled?: boolean pointsAwarded?: number | null newBalance?: number | null } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/loyalty/staff-link-payment-method Staff-assisted card link with verbal consent Links a payment card to a user account using verbal consent captured by a staff member. This endpoint: 1. Extracts card data from the purchase 2. Links the card to the specified user with verbal consent 3. Records a consent event with the staff member's retailerUserId 4. Auto-enrolls the user in the location's loyalty program if not already enrolled 5. Awards retroactive points for the triggering purchase **Use case**: When a guest doesn't scan the QR code, staff can look up the user and link their card manually after verbal confirmation. **Permissions:** 🟢 `loyalty.create` Request body: ```ts { purchaseIntentId: string userId: number locationId: number consentMethod: "verbal" retailerUserId: number } ``` Response 200 — Default Response ```ts { status: "success" data: { cardLinked: boolean enrolled: boolean pointsAwarded: number } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/loyalty/user-search Search users by name, email, or phone (staff) Search for users matching a query string. Used by staff during the fallback enrollment flow. Query parameters: - **q**: Search term (minimum 2 characters) — matched against email, name, and phone - **purchaseIntentId**: Optional — if provided, the tab member for that purchase is auto-suggested Results are sorted by relevance. Maximum 10 results returned. **Permissions:** 🔵 `users.read` Query parameters: - `q` (string, required) — Search term (name, email, or phone) - `purchaseIntentId` (string, optional) — Optional purchase intent ID for tab member auto-suggest Response 200 — Default Response ```ts { status: "success" data: { users: Array<{ id: number email?: string | null firstName?: string | null lastName?: string | null phone?: string | null }> autoSuggested: { userId?: number firstName?: string | null lastName?: string | null email?: string | null autoSuggested?: true } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/purchases/{purchaseIntentId}/loyalty-status Get loyalty status for a purchase Returns the loyalty scenario after a purchase completes. Used by the frontend to determine what modal/action to show: - **POINTS_EARNED**: User is enrolled, points were awarded automatically - **ENROLLMENT_AVAILABLE**: Card is linked to user, but not enrolled at this location - **CARD_LINK_AVAILABLE**: Card is not linked to any user, prompt for email/phone - **NO_LOYALTY_PROGRAM**: Location has no loyalty program - **PROCESSING**: Still processing (retry later) **Permissions:** 🔵 `loyalty.read` Path parameters: - `purchaseIntentId` (string, required) — Purchase intent ID Response 200 — Default Response ```ts { status: "success" data: { loyaltyScenario: "POINTS_EARNED" | "ENROLLMENT_AVAILABLE" | "CARD_LINK_AVAILABLE" | "NO_LOYALTY_PROGRAM" | "PROCESSING" pointsEarned: { points?: number newBalance?: number } | null enrollmentOffer: { programId?: number programName?: string potentialPoints?: number } | null cardLinkOffer: { programId?: number programName?: string potentialPoints?: number brand?: string | null last4?: string | null } | null user: { id?: number email?: string | null } | null location: { id: number name: string hasLoyaltyProgram: boolean } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/retailer/users/{userId}/payment-methods List payment methods for a specific user (staff view) Returns all saved payment methods for a specific user. Used by staff to view linked payment cards on the loyalty member detail page. This is a read-only view — staff cannot modify user payment methods through this endpoint. **Permissions:** 🔵 `users.read` Path parameters: - `userId` (string, required) — User ID Response 200 — Default Response ```ts { status: "success" data: { paymentMethods: Array<{ id?: string // uuid nickname?: string | null brand?: string | null last4?: string | null expiryMonth?: number | null expiryYear?: number | null isDefault?: boolean isTokenized?: boolean lastUsedAt?: string | null // date-time createdAt?: string // date-time }> defaultStoredMethodId?: string | null lastUsedStoredMethodId?: string | null lastUsedKind?: "card" | "swish" savedSwish?: { uuid: string // uuid payerAlias: string lastUsedAt: string | null // date-time } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/users/me/payment-methods List saved payment methods Returns all saved payment methods for the authenticated user. Payment methods are sorted by: 1. Default payment method first 2. Most recently used 3. Creation date **Permissions:** 🔵 `payment-methods.read` Response 200 — Default Response ```ts { status: "success" data: { paymentMethods: Array<{ id?: string // uuid nickname?: string | null brand?: string | null last4?: string | null expiryMonth?: number | null expiryYear?: number | null isDefault?: boolean isTokenized?: boolean lastUsedAt?: string | null // date-time createdAt?: string // date-time }> defaultStoredMethodId?: string | null lastUsedStoredMethodId?: string | null lastUsedKind?: "card" | "swish" savedSwish?: { uuid: string // uuid payerAlias: string lastUsedAt: string | null // date-time } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/users/me/payment-methods/{id} Remove saved payment method Removes a saved payment method (soft delete). The card will no longer be recognized for loyalty purposes. **Permissions:** 🔴 `payment-methods.delete` Path parameters: - `id` (string, required) — Payment method UUID Response 200 — Default Response ```ts { status: "success" data: { message?: string } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PATCH /api/v1/users/me/payment-methods/{id} Update payment method Updates a payment method's nickname. **Permissions:** 🟠 `payment-methods.update` Path parameters: - `id` (string, required) — Payment method UUID Request body: ```ts { nickname: string } ``` Response 200 — Default Response ```ts { status: "success" data: { id?: string // uuid nickname?: string | null brand?: string | null last4?: string | null expiryMonth?: number | null expiryYear?: number | null isDefault?: boolean isTokenized?: boolean lastUsedAt?: string | null // date-time createdAt?: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/users/me/payment-methods/{id}/default Set default payment method Sets a payment method as the default for the authenticated user. **Permissions:** 🟠 `payment-methods.update` Path parameters: - `id` (string, required) — Payment method UUID Response 200 — Default Response ```ts { status: "success" data: { message?: string } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Prepaid Types Prepaid payment categories for externally-paid tabs (e.g. third-party channels) ## GET /api/v1/prepaid-types List prepaid types List prepaid types for a location **Permissions:** 🔵 `prepaid_types.read` Query parameters: - `locationId` (number, optional) — Filter by location ID (required) - `includeArchived` (boolean, optional) — Include archived rows Response 200 — Default Response ```ts { status: "success" data: { prepaidTypes: Array<{ id: string locationId: number code: string label: string sortOrder: number costCenterId: number | null inheritToChildren?: boolean requiresComment: boolean archivedAt: string | null // date-time createdAt: string // date-time updatedAt: string // date-time }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/prepaid-types Create prepaid type Create a new prepaid type **Permissions:** 🟢 `prepaid_types.create` Request body: ```ts { locationId: number code: string label: string sortOrder?: number costCenterId?: number | null inheritToChildren?: boolean requiresComment?: boolean } ``` Response 201 — Default Response ```ts { status: "success" data: { id: string locationId: number code: string label: string sortOrder: number costCenterId: number | null inheritToChildren?: boolean requiresComment: boolean archivedAt: string | null // date-time createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/prepaid-types/{id} Get prepaid type Get a single prepaid type by ID **Permissions:** 🔵 `prepaid_types.read` Path parameters: - `id` (string, required) — Prepaid-type UUID Response 200 — Default Response ```ts { status: "success" data: { id: string locationId: number code: string label: string sortOrder: number costCenterId: number | null inheritToChildren?: boolean requiresComment: boolean archivedAt: string | null // date-time createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/prepaid-types/{id} Update prepaid type Update an existing prepaid type **Permissions:** 🟠 `prepaid_types.update` Path parameters: - `id` (string, required) — Prepaid-type UUID Request body: ```ts { code?: string label?: string sortOrder?: number costCenterId?: number | null inheritToChildren?: boolean requiresComment?: boolean } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string locationId: number code: string label: string sortOrder: number costCenterId: number | null inheritToChildren?: boolean requiresComment: boolean archivedAt: string | null // date-time createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/prepaid-types/{id} Archive prepaid type Soft-delete a prepaid type by setting archived_at. Historical psp_events keep their snapshot. **Permissions:** 🔴 `prepaid_types.delete` Path parameters: - `id` (string, required) — Prepaid-type UUID Response 204 — Prepaid type archived ```ts unknown ``` # Purchase Intents Payment intents for purchases — create, track, and settle a payment ## POST /api/v3/purchase-intents Create a purchase intent (v3 — purchase-service backed) Create a new purchase intent against the new purchase-service backend. Voucher validation, mode-dispatch, and KDI synthesis stay in karma-common-api; state machine, outbox, and expiry are owned by purchase-service. **Permissions:** ⚪ `tabs.pay` Request body: ```ts { locationId: integer mode: "upfront" | "items" | "amount" | "remainder" tabId?: string // uuid tabMemberId?: string // uuid kitchenDispatchItemIds?: string[] amountCents?: integer tipAmountCents?: integer voucherCodes?: string[] cart?: unknown[] paymentMethod: { type: string savedPaymentMethodId?: string // uuid terminalId?: string pspParams?: Record } savePaymentMethod?: boolean idempotencyKey: string clientRequestId?: string quoteId?: string guestSessionId?: string metadata?: Record } ``` Response 201 — Purchase intent created ```ts { status?: "success" data?: { purchaseIntentId: string // uuid state: string expiresAt?: string correlationId?: string pspInstruction?: Record | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v3/purchase-intents/{id} Get a purchase intent (v3) Fetch a purchase intent by id from purchase-service. **Permissions:** ⚪ `tabs.pay` Path parameters: - `id` (string, required) Response 200 — Purchase intent detail ```ts { status?: "success" data?: { purchaseIntentId: string // uuid state: string kind?: string amount?: integer currency?: string correlationId?: string metadata?: Record | null locationId?: integer | null createdAt?: string updatedAt?: string expiresAt?: string mode?: string amountCents?: integer tipAmountCents?: integer currencyCode?: string purchaseId?: integer | null tabId?: string | null tabMemberId?: string | null providerRoute?: string resultCode?: string action?: Record pspReference?: string refusalReason?: string refusalReasonCode?: string } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v3/purchase-intents/{id}/cancel Cancel a purchase intent (v3) Cancel a purchase intent via purchase-service. State machine + PSP cancel owned downstream. **Permissions:** ⚪ `tabs.pay` Path parameters: - `id` (string, required) Request body: ```ts { reason?: string guestSessionId?: string tabMemberId?: string // uuid requireConfirmedCancellation?: boolean } ``` Response 200 — Purchase intent cancelled ```ts { status?: "success" data?: { purchaseIntentId: string // uuid state: string kind?: string amount?: integer currency?: string correlationId?: string metadata?: Record | null locationId?: integer | null createdAt?: string updatedAt?: string expiresAt?: string mode?: string amountCents?: integer tipAmountCents?: integer currencyCode?: string purchaseId?: integer | null tabId?: string | null tabMemberId?: string | null providerRoute?: string resultCode?: string action?: Record pspReference?: string refusalReason?: string refusalReasonCode?: string } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v3/purchase-intents/{id}/submit-details Submit additional PSP details (v3) Advance a purchase intent by submitting PSP-specific details (e.g. Adyen 3DS). **Permissions:** ⚪ `tabs.pay` Path parameters: - `id` (string, required) Request body: ```ts { details: Record guestSessionId?: string tabMemberId?: string // uuid } ``` Response 200 — Details submitted ```ts { status?: "success" data?: { purchaseIntentId: string // uuid state: string kind?: string amount?: integer currency?: string correlationId?: string metadata?: Record | null locationId?: integer | null createdAt?: string updatedAt?: string expiresAt?: string mode?: string amountCents?: integer tipAmountCents?: integer currencyCode?: string purchaseId?: integer | null tabId?: string | null tabMemberId?: string | null providerRoute?: string resultCode?: string action?: Record pspReference?: string refusalReason?: string refusalReasonCode?: string } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v3/purchase-intents/{id}/terminal/status Poll a Cloud Terminal purchase (v3) Query Adyen through purchase-service using the immutable contract and POI mapping bound to the intent. **Permissions:** ⚪ `tabs.pay` Path parameters: - `id` (string, required) Response 200 — Default Response ```ts { status?: "success" data?: { purchaseIntentId: string // uuid state: string terminalOutcome: "success" | "not_successful" | "in_progress" | "unknown" } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v3/purchase-intents/preview Compute cart totals (subtotal, surplus service fee, tip, total) without creating a purchase intent. **Permissions:** ⚪ `tabs.pay` Request body: ```ts { locationId: integer kdiIds: string[] tipCents?: integer } ``` Response 200 — Default Response ```ts { status?: "success" data?: { subtotalCents?: integer discountCents?: integer surplusUserFeeCents?: integer surplusUserFeeBps?: integer tipCents?: integer totalCents?: integer currencyCode?: string hasSurplusItems?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Purchases Purchase history, transaction details, and refunds ## GET /api/v1/purchase/lookup-session/{sessionId} Look up purchase intent ID by Adyen session ID (stored in psp_request_ref). Used when Adyen redirects back with sessionId but no purchaseIntentId in URL. **Permissions:** 🔵 `purchases.read` Query parameters: - `locationId` (number, required) — Location ID for permission validation Path parameters: - `sessionId` (string, required) — Adyen session ID (psp_request_ref) Response 200 — Purchase intent found for session ```ts { status?: "success" data?: { purchaseIntentId?: string // uuid locationId?: number } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/purchases List purchases with automatic BigQuery routing for historical data Query parameters: - `locationId` (number, required) — Location ID to filter purchases (required for permissions) - `includeChildren` (boolean, optional) — Include authorized descendants of locationId. - `locationIds` (string, optional) — Optional comma-separated subset of the resolved location hierarchy. - `startDate` (string, optional) — Start date for date range filter (ISO 8601) - `endDate` (string, optional) — End date for date range filter (ISO 8601) - `userId` (number, optional) — Filter by user ID - `minAmount` (number, optional) — Minimum amount in cents - `maxAmount` (number, optional) — Maximum amount in cents - `minRefundedAmount` (number, optional) — Minimum refunded amount in cents - `maxRefundedAmount` (number, optional) — Maximum refunded amount in cents - `paymentMethod` (string, optional) — Filter by payment method - `psp` (string, optional) — Filter by payment provider (comma-separated: adyen, stripe, swish, invoice, prepaid, gift_card) - `sourceBrand` (string, optional) — Filter by payment method brand (comma-separated, case-insensitive) - `prepaidType` (string, optional) — Filter by prepaid type code stored on the payment event (comma-separated) - `status` (string, optional) — Filter by payment status - `customerEmail` (string, optional) — Filter by customer email (partial match) - `cardLast4` (string, optional) — Filter by last 4 digits of the paying card (exact match) - `itemId` (number, optional) — Filter purchases containing a specific inventory item ID - `tableId` (string, optional) — Filter by table/storefront QR code UUID - `tabId` (string, optional) — Filter by tab UUID - `tabType` (string, optional) — Filter by tab type (comma-separated: takeaway, over_the_counter, table) - `costCenterId` (string, optional) — Filter by cost center (comma-separated cost center ids). Resolved as COALESCE(tabs.cost_center_id, storefront_qr_codes.cost_center_id) — a tab tag beats the table default — the same rule analytics and the SIE export use. - `scopeToUser` (boolean, optional) — Narrow to the sales 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 uses. There is deliberately no parameter for scoping to another staff member: narrowing is only ever to the caller. Requires a staff (retailer user) token. - `page` (number, optional) — Page number for pagination - `limit` (number, optional) — Number of results per page (max 1000, enforced with a 400 error beyond that) - `cursor` (string, optional) — Opaque keyset pagination cursor from a previous response's pagination.nextCursor. Recommended over page/offset for locations with a large purchase history, since deep offset pages get progressively more expensive to compute. When provided, page/offset params are ignored and the total count is not computed (pagination.total is omitted). - `paginate` ("cursor" | "offset", optional) — Pagination mode. 'cursor' opts into keyset pagination: the expensive count query is skipped and pagination.hasMore/nextCursor are returned even on the first page (before any cursor value exists). Defaults to 'offset' (page/total) for backwards compatibility. Response 200 — Purchases retrieved successfully ```ts { status?: "success" data?: { purchases?: Array<{ customFields?: unknown[] metadata?: { … } | null id?: number locationId?: number userId?: number | null amount?: number currency?: string paymentMethod?: string | null paymentProvider?: string | null status?: string createdAt?: string // date-time updatedAt?: string // date-time tabId?: string | null // uuid costCenterId?: number | null costCenterName?: string | null costCenterCode?: string | null costCenter?: { … } | null servedBy?: unknown[] lineItems?: unknown[] }> pagination?: { page: number limit: number total?: number totalPages?: number hasMore: boolean nextCursor?: string | null } } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/purchases/{id} Get a single purchase by ID Query parameters: - `locationId` (number, required) — Location ID for permission validation Path parameters: - `id` (string, required) — Purchase ID Response 200 — Purchase retrieved successfully ```ts { status?: "success" data?: { purchase?: { customFields?: { … }[] metadata?: { customFields?: { … } } | null id?: number locationId?: number userId?: number | null amount?: number currency?: string paymentMethod?: string | null paymentProvider?: string | null status?: string createdAt?: string // date-time updatedAt?: string // date-time tabId?: string | null // uuid costCenterId?: number | null costCenterName?: string | null costCenterCode?: string | null costCenter?: { customFields?: unknown[] metadata?: { … } | null id?: number name?: string code?: string | null } | null servedBy?: { … }[] lineItems?: { … }[] } } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PATCH /api/v1/purchases/{id} Update purchases 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 } | null } } ``` ## POST /api/v1/purchases/{purchaseIntentId}/cash-register/fetch Fetch cash register info from cash-register service for a purchase that has a receipt ID but missing other cash register fields. Used to backfill data for older purchases. **Permissions:** ⚪ `purchases.write` Query parameters: - `locationId` (number, required) — Location ID for permission validation Path parameters: - `purchaseIntentId` (string, required) — Purchase intent UUID Response 200 — Cash register info fetched successfully ```ts { status?: "success" data?: { purchaseIntentId?: string // uuid cashRegister?: { receiptNumber?: number registerId?: string controlUnit?: string | null } | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/purchases/{purchaseIntentId}/cash-register/register Register a purchase with the cash register service (efterregistrering). Used for purchases that were not registered at the time of payment. Location must have an active cash register configuration. **Permissions:** ⚪ `purchases.write` Query parameters: - `locationId` (number, required) — Location ID for permission validation Path parameters: - `purchaseIntentId` (string, required) — Purchase intent UUID Response 200 — Purchase registered with cash register successfully ```ts { status?: "success" data?: { purchaseIntentId?: string // uuid cashRegister?: { receiptNumber?: number registerId?: string controlUnit?: string | null } | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/purchases/{purchaseIntentId}/connect-customer Connect a customer to a purchase by email. Optionally enroll in loyalty and award retroactive points. 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). Query parameters: - `locationId` (number, required) — Location ID for permission validation Path parameters: - `purchaseIntentId` (string, required) — Purchase intent UUID Request body: ```ts { email: string // email name?: string phone?: string enrollInLoyalty?: boolean customFields?: unknown | unknown[] } ``` Response 200 — Customer connected successfully ```ts { status?: "success" data?: { success?: boolean userId?: number isNewUser?: boolean loyaltyEnrolled?: boolean pointsAwarded?: number message?: string } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/purchases/{purchaseIntentId}/email-receipt Send a receipt email copy to a specified email address. Validates the purchase is confirmed, then triggers the receipt email Cloud Function via PubSub. The email uses the same template as automatic storefront receipt emails. Path parameters: - `purchaseIntentId` (string, required) — The purchase intent ID Request body: ```ts { email: string // email } ``` Response 202 — Receipt email send initiated ```ts { status?: "success" data?: { purchaseIntentId: string // uuid email: string } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/purchases/{purchaseIntentId}/order-details Get unified order purchase details by purchase intent ID (matches storefront-web format) Query parameters: - `locationId` (number, required) — Location ID for permission validation Path parameters: - `purchaseIntentId` (string, required) — Purchase intent UUID Response 200 — Order purchase details retrieved successfully ```ts { status?: "success" data?: { orderInfo?: { orderState?: "initial" | "sent_to_kitchen" | "accepted" | "ready_to_deliver" | "delivered" | "cancelled" deliveryKind?: "takeaway" | "table" | "room" orderNumber?: number | null tableName?: string | null paymentFlow?: "instant" | "open_tab" tabId?: string | null // uuid hasBeenScheduled?: boolean desiredPickupTime?: number | null phoneNumber?: string | null } | null purchaseInfo?: { purchasedAtEpochSeconds?: number totalAmountCents?: number | null serviceFeeCents?: number lineItems?: { … }[] vatBreakdown?: { vatPercentage?: number vatAmountCents?: number netAmountCents?: number grossAmountCents?: number } currency?: string } | null tabInfo?: { tabId?: string // uuid remainingAmountCents?: number | null qrCodeId?: string | null // uuid } | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/purchases/{purchaseIntentId}/receipt Get receipt data for email generation with hierarchical line items Query parameters: - `locationId` (number, required) — Location ID for permission validation Path parameters: - `purchaseIntentId` (string, required) — Purchase intent UUID Response 200 — Receipt email data retrieved successfully ```ts { status?: "success" data?: { purchaseIntentId?: string // uuid shopLayoutId?: string | null purchasedAt?: string // date-time currency?: string orderNumber?: number | null location?: { name?: string address?: string | null phone?: string | null vatId?: string | null logoUrl?: string | null storefrontSlug?: string | null language?: string | null } lineItems?: Array<{ measurement?: { … } | null type?: "product" | "tip" title?: string quantity?: number unitPriceCents?: number totalPriceCents?: number appliedDiscountCents?: number | null appliedModifications?: unknown[] children?: unknown[] }> subtotalCents?: number totalDiscountCents?: number vatBreakdown?: Array<{ rate?: number baseAmountCents?: number vatAmountCents?: number }> totalCents?: number serviceFeeCents?: number appliedVouchers?: Array<{ code?: string appliedAmountCents?: number }> customerEmail?: string | null paymentMethod?: string | null paymentBrand?: string | null paymentLast4?: string | null tableName?: string | null deliveryKind?: string | null cashRegister?: { receiptNumber?: number registerId?: string controlUnit?: string | null } | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/purchases/{purchaseIntentId}/receipt/print Render a purchase receipt as Adyen terminal OutputText for printing. Proxies to printer-service. **Permissions:** 🔵 `purchases.read` Query parameters: - `locationId` (number, required) — Location ID for permission validation Path parameters: - `purchaseIntentId` (string, required) — Purchase intent UUID Response 200 — Adyen OutputText payload ready to ship in a Nexo PrintRequest envelope ```ts { status?: "success" data?: { outputText: Array<{ Text: string CharacterStyle?: "Bold" | "Normal" Alignment?: "Left" | "Centred" | "Right" EndOfLineFlag?: boolean }> qrCode?: { url: string } | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/purchases/by-intent/{intentId} Get a single purchase by intent ID (UUID) Query parameters: - `locationId` (number, required) — Location ID for permission validation Path parameters: - `intentId` (string, required) — Purchase intent ID (UUID) Response 200 — Purchase retrieved successfully ```ts { status?: "success" data?: { purchase?: { customFields?: { … }[] metadata?: { customFields?: { … } } | null id?: number locationId?: number userId?: number | null amount?: number currency?: string paymentMethod?: string | null paymentProvider?: string | null status?: string createdAt?: string // date-time updatedAt?: string // date-time tabId?: string | null // uuid costCenterId?: number | null costCenterName?: string | null costCenterCode?: string | null costCenter?: { customFields?: unknown[] metadata?: { … } | null id?: number name?: string code?: string | null } | null servedBy?: { … }[] lineItems?: { … }[] } } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/refunds List refunds Response 200 — Default Response ## GET /api/v1/refunds/{refundId} Get refund Path parameters: - `refundId` (string, required) Response 200 — Default Response ## POST /api/v1/refunds/purchase Process a purchase refund. Validates the purchase belongs to the authenticated user's location, then asks the service that owns the purchase (purchase-service for purchases it captured, payment-service otherwise) to initiate the refund. On success, automatically triggers a refund receipt print job if a default printer group is configured for the location. **Flow:** 1. Validates auth context and location access 2. Verifies the purchase belongs to the location and resolves its refund owner (409 when neither service can refund the tender) 3. Calls the owning service to process the refund (idempotencyKey required for purchase-service) 4. Auto-prints a refund receipt (fire-and-forget) if a printer group exists 5. Returns the refund ID Request body: ```ts { refundMode?: "amount" | "products" idempotencyKey?: string purchaseIntentId: string reason: string feeCoveredBy: "karma" | "seller" lineItems: Array<{ purchaseLineItemId: integer amountCents?: integer quantityCents?: integer }> } ``` Response 200 — Refund processed successfully ```ts { status?: "success" data?: { success: boolean purchaseRefundId: integer | null message: string } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Settlements Tab settlements and payment groupings ## GET /api/v1/settlements/{settlementId} Get settlement details Get settlement details by ID Path parameters: - `settlementId` (string, required) — ID of the settlement to retrieve Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid tabMemberId: string // uuid paymentId?: integer | null state: "in_preparation" | "payment_in_progress" | "settled" | "cancelled" createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/tabs/{tabId}/settlements/active Get or create active settlement Get or create active settlement for a tab member Path parameters: - `tabId` (string, required) — ID of the tab to get settlement for Request body: ```ts { tabMember: { id?: integer email?: string // email phone?: string identifier?: string } } ``` Response 200 — Default Response ```ts { status: "success" data: { tabSettlementId: string // uuid state: "in_preparation" | "payment_in_progress" | "settled" | "cancelled" isNew: boolean } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Terminals Payment terminal management ## GET /api/v1/terminals List payment terminals Get all payment terminals for a location **Permissions:** 🔵 `terminals.read` Query parameters: - `locationId` (string, required) — Location ID to fetch terminals for Response 200 — Terminals fetched successfully ```ts { status?: "success" data?: Array<{ id: string // uuid externalId: string locationId: integer createdAt: string // date-time updatedAt: string // date-time title: string type: "p400plus" | "s1f2" | "s1u2" | "s1e2l" | "v400cplus" | "v400m" | "any" | null status?: "idle" | "busy" | "error" statusDetails?: string[] statusError?: string probeId?: string // uuid checkedAt?: string // date-time }> meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/terminals/status-diagnostics Record a POS terminal status delivery anomaly Request body: ```ts { outcome: "http_fallback" | "status_timeout" | "http_error" | "stale_event" | "terminal_not_found" surface: "quick_pay" | "pay_drawer" terminalIds: string[] probeIds?: string[] connectionState?: string durationMs?: number clientLocationId?: integer } ``` Response 202 — Default Response ```ts { status: "success" data: { accepted: boolean } meta?: Record } ``` ## GET /api/v1/terminals/statuses Get terminal statuses Request real-time connection status for all terminals at a location, or one terminal **Permissions:** 🔵 `terminals.read` Query parameters: - `locationId` (string, required) — Location ID to fetch terminal statuses for - `terminalId` (string, optional) — Optional terminal ID to check only that terminal - `terminalIds` (string, optional) — Comma-separated IDs of up to four Quick Pay terminals - `waitForStatus` ("true" | "false", optional) — Wait for probe results and include them in the response Response 200 — Terminal statuses fetched successfully ```ts { status?: "success" data?: { terminals: Array<{ id: string // uuid externalId: string locationId: integer createdAt: string // date-time updatedAt: string // date-time title: string type: … status?: "idle" | "busy" | "error" statusDetails?: unknown[] statusError?: string probeId?: string // uuid checkedAt?: string // date-time }> message: string } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Placing an order (/docs/place-an-order) `POST /api/v1/public/orders/create` places an order at a location in one request: no tab to pre-create, no guest lookup, no payload assembly. A new tab is opened, a kitchen order is created, and an anonymous guest is generated unless you identify one. The API key needs the `inventory.create` permission for the location. ## The simplest order [#the-simplest-order] ```bash curl -X POST "$COMMON_API/api/v1/public/orders/create" \ -H "X-API-Key: $KARMA_API_KEY" -H 'Content-Type: application/json' \ -d '{ "location_id": 1234, "items": [{ "id": 456, "quantity_cents": 100 }] }' ``` ```json { "status": "success", "data": { "orderId": "e01dcd36-cdec-41e5-8708-1b57d943367d", "tabId": "59e4ce7d-b801-4323-af9d-ba0ca4bd757a", "totalAmountCents": 9900 } } ``` `orderId` is the kitchen order, `tabId` the tab it was placed on. Keep the `tabId` to add more items to the same tab before payment. ## Common scenarios [#common-scenarios] **Identify the guest.** Pass `ordering_member` with a `user_id`, an `email` or a `phone`, in that order of priority. An email or phone that matches an existing Karma account reuses it; otherwise a guest is created. `name` is used for new guests. ```json { "location_id": 1234, "ordering_member": { "email": "guest@example.com", "name": "Alex" }, "items": [{ "id": 456, "quantity_cents": 100 }] } ``` **Variants, a voucher, takeaway.** ```json { "location_id": 1234, "order_type": "TAKEAWAY", "items": [ { "id": 456, "quantity_cents": 100, "variants": [{ "id": 12 }, { "id": 34 }] }, { "id": 789, "quantity_cents": 100 } ], "vouchers": ["WELCOME10"] } ``` **Add to an existing tab.** Use the `tabId` from an earlier response: ```json { "location_id": 1234, "order_destination": "TAB", "order_destination_id": "59e4ce7d-b801-4323-af9d-ba0ca4bd757a", "items": [{ "id": 789, "quantity_cents": 100 }] } ``` **Fractional quantities.** `quantity_cents` is the quantity times 100: `100` is one unit, `50` is half, `200` is two. Use it for weight-based or bulk items. **Price override.** `price_cents` on an item replaces the catalogue price for this order only. ## Request reference [#request-reference] | Field | Required | Description | | ------------------------ | -------- | --------------------------------------------------------- | | `location_id` | **yes** | Location to place the order at | | `items` | **yes** | At least one item | | `items[].id` | **yes** | Inventory item id | | `items[].quantity_cents` | **yes** | Quantity × 100 | | `items[].variants` | no | Variant selections, `[{ "id": 12 }]` | | `items[].price_cents` | no | Price override in cents | | `items[].coursing_order` | no | Course number; `0` sends immediately | | `ordering_member` | no | `user_id`, `email` or `phone`, plus `name` for new guests | | `vouchers` | no | Voucher codes to apply | | `order_destination` | no | `NONE` (default, a fresh tab), `TAB`, `QR` or `KD` | | `order_destination_id` | no | UUID of the destination when it is not `NONE` | | `order_type` | no | `OTC` (default), `TAKEAWAY` or `TABLE` | ## Errors [#errors] | Status | Code | When | | ------ | -------------------------- | ----------------------------------------------------------------------------------------- | | 400 | `INVALID_INPUT` | Missing required fields or a bad type. The response `details.hint` names what is missing. | | 401 | `MISSING_API_KEY` | No `X-API-Key` header | | 403 | `INSUFFICIENT_PERMISSIONS` | The key lacks `inventory.create` | | 403 | `SCOPE_VIOLATION` | The key is not scoped to this location | | 429 | `RATE_LIMIT_EXCEEDED` | See [rate limits](/docs/rate-limits) | Once the order exists, the location's `order.created` webhook fires, and `tab.payment_completed` follows when it is paid. See [Receiving webhooks](/docs/webhooks). # Alerts System notifications and warnings ## GET /api/v1/alerts List alerts List alerts with optional filtering **Permissions:** 🔵 `alerts.read` Query parameters: - `locationId` (number, optional) — Filter by location ID - `severity` ("info" | "warning" | "error" | "critical", optional) — Filter by severity - `status` ("active" | "acknowledged" | "resolved", optional) — Filter by status - `alertType` (string, optional) — Filter by alert type - `page` (number, optional) — Page number - `limit` (number, optional) — Items per page Response 200 — Default Response ```ts { status: "success" data: { alerts: Array<{ id: string locationId: number alertType: string severity: "info" | "warning" | "error" | "critical" title: string message?: string | null details?: { … } | null stackTrace?: string | null context?: { … } | null latestStatus: "active" | "acknowledged" | "resolved" resolvedAt?: string | null // date-time resolvedBy?: number | null acknowledgedAt?: string | null // date-time acknowledgedBy?: number | null 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/alerts/{id} Get alert Get a single alert by ID **Permissions:** 🔵 `alerts.read` Path parameters: - `id` (string, required) — Alert ID (UUID) Response 200 — Default Response ```ts { status: "success" data: { id: string locationId: number alertType: string severity: "info" | "warning" | "error" | "critical" title: string message?: string | null details?: Record | null stackTrace?: string | null context?: Record | null latestStatus: "active" | "acknowledged" | "resolved" resolvedAt?: string | null // date-time resolvedBy?: number | null acknowledgedAt?: string | null // date-time acknowledgedBy?: number | null createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PATCH /api/v1/alerts/{id} Update alert status Update alert status (acknowledge or resolve) **Permissions:** 🟠 `alerts.update` Path parameters: - `id` (string, required) — Alert ID (UUID) Request body: ```ts { status: "acknowledged" | "resolved" } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string locationId: number alertType: string severity: "info" | "warning" | "error" | "critical" title: string message?: string | null details?: Record | null stackTrace?: string | null context?: Record | null latestStatus: "active" | "acknowledged" | "resolved" resolvedAt?: string | null // date-time resolvedBy?: number | null acknowledgedAt?: string | null // date-time acknowledgedBy?: number | null createdAt: string // date-time updatedAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # API Keys Manage API keys, their permissions, and usage ## GET /api/v1/api-keys List API keys List API keys with optional filtering Query parameters: - `locationId` (number, optional) — Filter by location ID - `companyId` (number, optional) — Filter by company ID - `scopeType` ("LOCATION" | "COMPANY" | "GLOBAL", optional) — Filter by scope type - `isActive` (boolean, optional) — Filter by active status - `page` (number, optional) — Page number - `limit` (number, optional) — Items per page Response 200 — Default Response ```ts { status: "success" data: { keys: Array<{ id: string // uuid name: string rateLimitTier: "STANDARD" | "PREMIUM" | "UNLIMITED" | "INTERNAL" scopeType: "LOCATION" | "COMPANY" | "GLOBAL" scopeIds: unknown[] createdByUserId: number createdAt: string // date-time isActive: boolean isRevoked?: boolean rateLimitTiers?: unknown[] expiresAt?: string | null // date-time plainTextKey?: string permissions?: unknown[] }> pagination: { page: number limit: number total: number totalPages: number } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/api-keys Create API key Create a new API key (returns plain text key - store securely!) Request body: ```ts { name: string rateLimitTier: "STANDARD" | "PREMIUM" | "UNLIMITED" | "INTERNAL" scopeType: "LOCATION" | "COMPANY" | "GLOBAL" scopeIds?: number[] permissionIds: number[] expiresAt?: string // date-time } ``` Response 201 — Default Response ```ts { status: "success" data: { id: string // uuid name: string rateLimitTier: "STANDARD" | "PREMIUM" | "UNLIMITED" | "INTERNAL" scopeType: "LOCATION" | "COMPANY" | "GLOBAL" scopeIds: number[] createdByUserId: number createdAt: string // date-time isActive: boolean isRevoked?: boolean rateLimitTiers?: Array<{ tier: string requestsPerHour: number | null }> expiresAt?: string | null // date-time plainTextKey?: string permissions?: Array<{ id?: number key?: string resource?: string action?: string }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/api-keys/{id} Get API key Get a single API key by ID Path parameters: - `id` (string, required) — API key ID Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid name: string rateLimitTier: "STANDARD" | "PREMIUM" | "UNLIMITED" | "INTERNAL" scopeType: "LOCATION" | "COMPANY" | "GLOBAL" scopeIds: number[] createdByUserId: number createdAt: string // date-time isActive: boolean isRevoked?: boolean rateLimitTiers?: Array<{ tier: string requestsPerHour: number | null }> expiresAt?: string | null // date-time plainTextKey?: string permissions?: Array<{ id?: number key?: string resource?: string action?: string }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/api-keys/{id} Update API key Update an existing API key Path parameters: - `id` (string, required) — API key ID Request body: ```ts { name?: string isActive?: boolean permissionIds?: integer[] } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string // uuid name: string rateLimitTier: "STANDARD" | "PREMIUM" | "UNLIMITED" | "INTERNAL" scopeType: "LOCATION" | "COMPANY" | "GLOBAL" scopeIds: number[] createdByUserId: number createdAt: string // date-time isActive: boolean isRevoked?: boolean rateLimitTiers?: Array<{ tier: string requestsPerHour: number | null }> expiresAt?: string | null // date-time plainTextKey?: string permissions?: Array<{ id?: number key?: string resource?: string action?: string }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/api-keys/{id} Delete API key Delete an API key (permanent - cannot be undone) Path parameters: - `id` (string, required) — API key ID Response 200 — API key successfully deleted ```ts { status?: "success" data?: { deleted?: boolean } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/api-keys/{id}/permissions Get API key permissions Get permissions assigned to an API key Path parameters: - `id` (string, required) — API key ID Response 200 — Default Response ```ts { status: "success" data: { permissions?: Array<{ id?: number key?: string resource?: string action?: string }> } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/api-keys/{id}/usage Get API key usage Get usage metrics for an API key Path parameters: - `id` (string, required) — API key ID Response 200 — Default Response ```ts { status: "success" data: { keyId: string // uuid requestCount: number errorCount: number rateLimit: number rateLimitUsage: number } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/api-keys/usage Get aggregated API key usage Response 200 — Default Response # CWI Company-Wide Items (CWI) management via EAN ## GET /api/v1/cwi/items List CWI items for company List all CWI items (items with EAN) for the authenticated company. Supports pagination and optional filtering by location. **Permissions:** 🔵 `cwi.read` Query parameters: - `page` (number, optional) — Page number - `limit` (number, optional) — Items per page - `locationId` (number, optional) — Filter by location ID Response 200 — List of CWI items ```ts { status: "success" data: { items: Array<{ id: number ean?: string | null title: string fullPrice: number discount: number description?: string | null companyId: number locationId?: number | null category?: string | null preferences?: unknown[] allergenCodes?: unknown[] createdAt: string // date-time }> pagination: { page: number limit: number total: number totalPages: number } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/cwi/items Create or update a CWI item by EAN Create or update a CWI item by EAN. If an item with the given EAN already exists for the company, it is updated. Otherwise, a new item is created. Returns 201 for creation, 200 for update. **Discount validation:** Must be between 50 and 90, and must end in 0 or 5 (e.g. 50, 55, 60, 65, 70, 75, 80, 85, 90). **Allergen codes:** Use two-letter codes (e.g. `AW` = Gluten, `AE` = Egg, `AM` = Milk). See the allergenCodes field for the full list. **Permissions:** ⚪ `cwi.write` Request body: ```ts { ean: string title: string fullPrice: number discount: number description?: string category?: "DRINK" | "GROCERY" | "FULL_MEAL" | "BREAD" | "SWEETS" | "SNACK" preferences?: "GLUTEN_FREE" | "PORK" | "BEEF" | "CHICKEN" | "VEGAN" | "VEGETARIAN" | "FISH_PREF" | "SHELLFISH_PREF" | "LACTOSE_FREE"[] allergenCodes?: "AC" | "AE" | "AF" | "AM" | "AN" | "AP" | "AS" | "AU" | "AW" | "AY" | "BC" | "BM" | "GB" | "GO" | "GS" | "ML" | "NC" | "NL" | "NM" | "NR" | "SA" | "SC" | "SH" | "SM" | "SO" | "SP" | "SR" | "ST" | "SW" | "UM" | "UW" | "NONE"[] } ``` Response 200 — Item updated successfully ```ts { status: "success" data: { id: number ean?: string | null title: string fullPrice: number discount: number description?: string | null companyId: number locationId?: number | null category?: string | null preferences?: string[] allergenCodes?: string[] createdAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` Response 201 — Item created successfully ```ts { status: "success" data: { id: number ean?: string | null title: string fullPrice: number discount: number description?: string | null companyId: number locationId?: number | null category?: string | null preferences?: string[] allergenCodes?: string[] createdAt: string // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/cwi/items/{ean} Delete a CWI item by EAN Soft-delete a CWI item by EAN. Marks the item as deleted. The item can no longer be found via the list endpoint. **Permissions:** 🔴 `cwi.delete` Path parameters: - `ean` (string, required) — EAN / article number of the item to delete Response 200 — Item deleted successfully ```ts { status: "success" data: { message?: string ean?: string } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Helpers High-level helper endpoints that combine multiple operations into a single call (e.g. find-or-create item and add surplus) ## POST /api/v1/locations/{locationId}/helpers/items Upsert an inventory item by external id (find-or-create) Create or update a catalog item keyed by an external system's own product id (`externalId`). Resolves an existing item by `metadata.externalId` (or the legacy `integration_item_id` column) scoped to the location; updates it if found, creates it otherwise. Separate from `/helpers/surplus/add` so external POS/waste systems can manage their catalog independently of surplus stock. Legacy karma-api Semafor `/items` mapping: `item_id` → `externalId`/`integrationItemId`, `title`(+`producer`/`weight`) → `title`, `base_price` → `basePriceCents`, `image_url` → `imageUrl`/`externalImageUrl`, `gtin` → `gtin`. Path parameters: - `locationId` (integer, required) — Location the item belongs to Request body: ```ts { externalId: string title?: string basePriceCents?: integer description?: string imageUrl?: string externalImageUrl?: string ingredients?: string gtin?: string category?: string allergens?: string[] integrationId?: integer integrationItemId?: string } ``` Response 200 — Default Response ```ts { status?: string data?: { item?: { id?: integer created?: boolean } } meta?: Record } ``` Response 201 — Default Response ```ts { status?: string data?: { item?: { id?: integer created?: boolean } } meta?: Record } ``` ## GET /api/v1/locations/{locationId}/helpers/surplus List all surplus entries for a location (all dates) Returns all surplus entries for the location — past, active, and future — enriched with each item's `externalId` so external systems can reconcile against their own product IDs. Unlike the standard surplus list (which shows only active/recent entries), this endpoint covers all dates. Filter with `from` and `to` to narrow the window. Each entry includes a `status` field: `active` (selling now), `upcoming` (future date), or `expired` (sell window has passed). Query parameters: - `from` (string, optional) — ISO date (YYYY-MM-DD). Only return entries with valid_from on or after this date. - `to` (string, optional) — ISO date (YYYY-MM-DD). Only return entries with valid_from on or before this date. Path parameters: - `locationId` (integer, required) Response 200 — Default Response ```ts { status?: string data?: { surplusEntries?: Array<{ id?: integer itemId?: integer locationId?: integer ministock?: integer validFrom?: string validUntil?: string | null discountPercentage?: integer | null overridePriceCents?: integer | null computedPriceCents?: integer | null note?: string | null status?: "active" | "upcoming" | "expired" createdAt?: string updatedAt?: string item?: { … } }> } meta?: Record } ``` ## POST /api/v1/locations/{locationId}/helpers/surplus/add Find-or-create an item and add it as surplus (single call) Helper endpoint for external POS systems. Pass either an existing `itemId` or a full item definition (with `externalId` for idempotent find-or-create). The endpoint creates the item in inventory if it does not yet exist, then upserts the surplus entry for the given date. **Date formats accepted:** - `date` — single selling day (midnight → store closing time, resolved from schedule) - `validFrom` + `validUntil` — explicit ISO datetime range **Upsert behaviour:** If a surplus entry already exists for this item on the same `validFrom`, the quantity (and other fields) are replaced with the new values. Path parameters: - `locationId` (integer, required) — Location to add surplus for Request body: ```ts { itemId?: integer item?: { externalId: string title?: string description?: string imageUrl?: string basePriceCents?: integer category?: string allergens?: string[] } quantity: integer portionAmountMicros?: integer discountPercentage?: integer overridePriceCents?: integer note?: string date?: string validFrom?: string validUntil?: string } ``` Response 200 — Default Response ```ts { status?: string data?: { surplusEntry?: { id?: integer itemId?: integer locationId?: integer ministock?: integer validFrom?: string validUntil?: string | null discountPercentage?: integer | null overridePriceCents?: integer | null note?: string | null createdAt?: string updatedAt?: string } item?: { id?: integer created?: boolean } } meta?: Record } ``` Response 201 — Default Response ```ts { status?: string data?: { surplusEntry?: { id?: integer itemId?: integer locationId?: integer ministock?: integer validFrom?: string validUntil?: string | null discountPercentage?: integer | null overridePriceCents?: integer | null note?: string | null createdAt?: string updatedAt?: string } item?: { id?: integer created?: boolean } } meta?: Record } ``` ## POST /api/v1/locations/{locationId}/helpers/surplus/add-bulk Bulk add surplus entries (find-or-create items) Add up to 500 surplus entries in a single call. Each item uses the same find-or-create semantics as the single `/helpers/surplus/add` endpoint — pass an `externalId` and item details; Karma looks up or creates the inventory item automatically. Processing is sequential and **partial success** is supported: if one item fails, the rest still process. Check `data.errors` for per-item failures. For migrating from the legacy karma-api Semafor integration: map `integration_item_id` → `item.externalId`, `num` → `quantity`, `expiry_date` → `date`, `base_price` → `item.basePriceCents`. Path parameters: - `locationId` (integer, required) — Location to add surplus for Request body: ```ts { items: Array<{ itemId?: integer item?: { externalId: string title?: string description?: string imageUrl?: string basePriceCents?: integer category?: string allergens?: unknown[] } quantity: integer portionAmountMicros?: integer discountPercentage?: integer overridePriceCents?: integer note?: string date?: string validFrom?: string validUntil?: string }> } ``` Response 207 — Default Response ```ts { status?: "success" | "partial" data?: { results?: Array<{ index?: integer surplusId?: integer itemId?: integer itemCreated?: boolean surplusCreated?: boolean }> errors?: Array<{ index?: integer error?: string }> } meta?: Record } ``` # Other ## GET /api/v1/booking-waitlist/by-code/{code} Path parameters: - `code` (string, required) Response 200 — Default Response ## POST /api/v1/booking-waitlist/by-code/{code}/accept Path parameters: - `code` (string, required) Response 200 — Default Response ## POST /api/v1/booking-waitlist/by-code/{code}/decline Path parameters: - `code` (string, required) Response 200 — Default Response ## DELETE /api/v1/booking-waitlist/by-code/{code}/leave Path parameters: - `code` (string, required) Response 200 — Default Response ## GET /api/v1/bookings/{id}/pre-order Path parameters: - `id` (string, required) Response 200 — Default Response ## POST /api/v1/bookings/{id}/pre-order Path parameters: - `id` (string, required) Response 200 — Default Response ## GET /api/v1/bookings/by-code/{code} Path parameters: - `code` (string, required) Response 200 — Default Response ## GET /api/v1/bookings/by-code/{code}/members Path parameters: - `code` (string, required) Response 200 — Default Response ## POST /api/v1/bookings/by-code/{code}/members Path parameters: - `code` (string, required) Response 200 — Default Response ## GET /api/v1/invoice-recipients/{recipientId}/fortnox Path parameters: - `recipientId` (string, required) Response 200 — Default Response ## DELETE /api/v1/invoice-recipients/{recipientId}/fortnox Path parameters: - `recipientId` (string, required) Response 200 — Default Response ## POST /api/v1/invoice-recipients/{recipientId}/fortnox/create Path parameters: - `recipientId` (string, required) Response 200 — Default Response ## POST /api/v1/invoice-recipients/{recipientId}/fortnox/sync Path parameters: - `recipientId` (string, required) Response 200 — Default Response ## POST /api/v1/invoices/{invoiceId}/push-to-fortnox Path parameters: - `invoiceId` (string, required) Response 200 — Default Response ## GET /api/v1/invoices/purchase-status/{purchaseIntentId} Path parameters: - `purchaseIntentId` (string, required) Response 200 — Default Response ## GET /api/v1/locations/{locationId}/billing-contact Path parameters: - `locationId` (string, required) Response 200 — Default Response ```ts { status?: string data?: { email?: string | null } meta?: Record } ``` ## PATCH /api/v1/locations/{locationId}/billing-contact Path parameters: - `locationId` (string, required) Request body: ```ts { email: string } ``` Response 200 — Default Response ## GET /api/v1/locations/{locationId}/report-emails Path parameters: - `locationId` (string, required) Response 200 — Default Response ```ts { status?: string data?: { emails?: string[] } meta?: Record } ``` ## PATCH /api/v1/locations/{locationId}/report-emails Path parameters: - `locationId` (string, required) Request body: ```ts { emails: string[] } ``` Response 200 — Default Response ## GET /api/v1/merchant/locations/{locationId}/integrations/fortnox/auth-url Path parameters: - `locationId` (string, required) Response 200 — Default Response ## GET /api/v1/merchant/locations/{locationId}/integrations/fortnox/customers Path parameters: - `locationId` (string, required) Response 200 — Default Response ## POST /api/v1/merchant/locations/{locationId}/integrations/fortnox/customers/import Path parameters: - `locationId` (string, required) Response 200 — Default Response ## DELETE /api/v1/merchant/locations/{locationId}/integrations/fortnox/disconnect Path parameters: - `locationId` (string, required) Response 200 — Default Response ## GET /api/v1/merchant/locations/{locationId}/integrations/fortnox/posting-options Path parameters: - `locationId` (string, required) Response 200 — Default Response ## PUT /api/v1/merchant/locations/{locationId}/integrations/fortnox/settings Path parameters: - `locationId` (string, required) Response 200 — Default Response ## GET /api/v1/merchant/locations/{locationId}/integrations/fortnox/status Path parameters: - `locationId` (string, required) Response 200 — Default Response ## POST /api/v1/print-jobs/terminal-pre-check Request body: ```ts { locationId: number tabId: string // uuid terminalId: string // uuid content: { tableName: string totalAmount: number lineItems: Array<{ name: string price: number modifications: unknown[] }> } } ``` Response 200 — Default Response ## GET /api/v1/printer-groups/{printerGroupId}/jobs/{jobId}/preview Path parameters: - `printerGroupId` (string, required) - `jobId` (string, required) Response 200 — Default Response ## POST /api/v1/purchases/{purchaseIntentId}/picked-up Path parameters: - `purchaseIntentId` (string, required) Response 200 — Default Response ## GET /api/v1/purchases/refund-reasons Query parameters: - `locationId` (number, required) Response 200 — Default Response ## POST /api/v1/refunds/reasons Request body: ```ts { description: string } ``` Response 200 — Default Response ## DELETE /api/v1/refunds/reasons/{reasonId} Path parameters: - `reasonId` (string, required) Response 200 — Default Response ## PUT /api/v1/refunds/reasons/built-in/{reasonKey} Path parameters: - `reasonKey` (string, required) Request body: ```ts { visible: boolean } ``` Response 200 — Default Response ## GET /api/v1/refunds/settings Response 200 — Default Response ## PUT /api/v1/refunds/settings Request body: ```ts { refundAmountsEnabled: boolean refundProductsEnabled: boolean partialRefundsEnabled: boolean } ``` Response 200 — Default Response ## PATCH /api/v1/retailer/users/{id}/metadata Path parameters: - `id` (integer, required) Request body: ```ts { metadata: Record } ``` Response 200 — Default Response ## POST /api/v1/tabs/{id}/anonymous-members Path parameters: - `id` (string, required) Response 200 — Default Response ## POST /api/v1/tabs/{tabId}/checkout-discounts/quote Path parameters: - `tabId` (string, required) Response 200 — Default Response ## POST /api/v1/tabs/{tabId}/members/{memberId}/charge-card-on-file Path parameters: - `tabId` (string, required) - `memberId` (string, required) Response 200 — Default Response ## GET /api/v1/tabs/{tabId}/recent-purchase-intent Path parameters: - `tabId` (string, required) Response 200 — Default Response ## POST /api/v1/tabs/close-all Response 200 — Default Response ## PUT /api/v1/terminals/{id} Path parameters: - `id` (string, required) Response 200 — Default Response ## DELETE /api/v1/terminals/{id} Path parameters: - `id` (string, required) Response 200 — Default Response ## POST /api/v1/terminals Response 200 — Default Response # Public Simplified endpoints for external integrations — place orders without complex setup ## POST /api/v1/public/orders/create Creates an order at a location. A new tab is opened per call unless an existing destination is provided. Optionally associate the order with a guest via user_id, email, or phone. Request body: ```ts { location_id: number ordering_member?: { user_id?: number email?: string // email phone?: string name?: string } items: Array<{ id: number quantity_cents: number variants?: { … }[] price_cents?: number coursing_order?: number }> vouchers?: string[] order_destination?: "NONE" | "TAB" | "QR" | "KD" order_destination_id?: string order_type?: "TAKEAWAY" | "OTC" | "TABLE" } ``` Response 201 — Order created ```ts { status?: "success" data?: { orderId?: string tabId?: string totalAmountCents?: number } } ``` # Webhooks Webhook subscriptions and event notifications ## GET /api/v1/webhooks/logs List webhook logs List webhook delivery logs with optional filtering Query parameters: - `page` (number, optional) — Page number - `limit` (number, optional) — Items per page - `subscriptionId` (string, optional) — Filter by subscription ID - `event` (string, optional) — Filter by event type - `status` ("pending" | "success" | "failed" | "retrying", optional) — Filter by delivery status - `resourceId` (string, optional) — Filter by resource ID Response 200 — Default Response ```ts { status: "success" data: { logs: Array<{ id: string subscriptionId: string event: string resource: string resourceId: string status: "pending" | "success" | "failed" | "retrying" attempts: number responseCode: number | null errorMessage: string | null payload: { … } deliveredAt: string | null // date-time createdAt: string // date-time lastAttemptAt: string | null // date-time nextRetryAt: string | null // date-time }> pagination: { page: number limit: number total: number totalPages: number } } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/webhooks/subscriptions List webhook subscriptions List webhook subscriptions with optional filtering **Permissions:** 🔵 `webhooks.read` Query parameters: - `page` (number, optional) — Page number - `limit` (number, optional) — Items per page - `active` (boolean, optional) — Filter by active status - `event` (string, optional) — Filter by event type Response 200 — Default Response ```ts { status: "success" data: { subscriptions: Array<{ id: string apiKeyId: string | null companyId: number | null createdByUserId: number | null name: string | null locationIds: unknown[] | null url: string // uri events: unknown[] active: boolean description: string | null metadata: { … } | null createdAt: string // date-time updatedAt: string // date-time lastTriggeredAt: string | null // date-time disabledReason: string | null disabledAt: 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/webhooks/subscriptions Create webhook subscription Create a new webhook subscription **Permissions:** 🟢 `webhooks.create` Request body: ```ts { name?: string url: string // uri events: string[] secret: string active?: boolean description?: string metadata?: Record locationIds?: number[] } ``` Response 201 — Default Response ```ts { status: "success" data: { id: string apiKeyId: string | null companyId: number | null createdByUserId: number | null name: string | null locationIds: number[] | null url: string // uri events: string[] active: boolean description: string | null metadata: Record | null createdAt: string // date-time updatedAt: string // date-time lastTriggeredAt: string | null // date-time disabledReason: string | null disabledAt: string | null // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/webhooks/subscriptions/{id} Get webhook subscription Get a single webhook subscription by ID **Permissions:** 🔵 `webhooks.read` Path parameters: - `id` (string, required) — Webhook subscription ID Response 200 — Default Response ```ts { status: "success" data: { id: string apiKeyId: string | null companyId: number | null createdByUserId: number | null name: string | null locationIds: number[] | null url: string // uri events: string[] active: boolean description: string | null metadata: Record | null createdAt: string // date-time updatedAt: string // date-time lastTriggeredAt: string | null // date-time disabledReason: string | null disabledAt: string | null // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/webhooks/subscriptions/{id} Update webhook subscription Update an existing webhook subscription **Permissions:** 🟠 `webhooks.update` Path parameters: - `id` (string, required) — Webhook subscription ID Request body: ```ts { name?: string | null url?: string // uri events?: string[] secret?: string active?: boolean description?: string metadata?: Record locationIds?: number[] | null } ``` Response 200 — Default Response ```ts { status: "success" data: { id: string apiKeyId: string | null companyId: number | null createdByUserId: number | null name: string | null locationIds: number[] | null url: string // uri events: string[] active: boolean description: string | null metadata: Record | null createdAt: string // date-time updatedAt: string // date-time lastTriggeredAt: string | null // date-time disabledReason: string | null disabledAt: string | null // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/webhooks/subscriptions/{id} Delete webhook subscription Delete a webhook subscription **Permissions:** 🔴 `webhooks.delete` Path parameters: - `id` (string, required) — Webhook subscription ID Response 204 — Subscription successfully deleted ```ts unknown ``` ## GET /api/v1/webhooks/subscriptions/{id}/stats Get webhook subscription stats Get delivery statistics for a webhook subscription **Permissions:** 🔵 `webhooks.read` Path parameters: - `id` (string, required) — Webhook subscription ID Response 200 — Default Response ```ts { status: "success" data: { totalDeliveries: number successCount: number failedCount: number retryingCount: number successRate: number dailyStats: Array<{ date: string // date success: number failed: number }> lastDeliveredAt: string | null // date-time } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Rate limits (/docs/rate-limits) Limits apply per API key, per UTC clock hour. The tier is set on the key when it is issued. | Tier | Requests per hour | | --------- | ----------------- | | Standard | 1,000 | | Premium | 10,000 | | Unlimited | 100,000 | Every response carries the current state of the window: | Header | Meaning | | ----------------------- | ----------------------------------------------- | | `X-RateLimit-Limit` | Requests allowed in the current hour | | `X-RateLimit-Remaining` | Requests left in the current hour | | `X-RateLimit-Reset` | Unix timestamp (seconds) when the window resets | Once the limit is reached, requests fail with HTTP 429 until the window resets: ```json { "status": "error", "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Rate limit exceeded. Limit: 1000 requests per hour." } } ``` Read `X-RateLimit-Remaining` and pause before it reaches zero rather than reacting to 429s. Use cursor pagination with a large `limit` instead of many small pages, and subscribe to [webhooks](/docs/webhooks) instead of polling for changes. If your integration needs more than the Standard tier, contact [hello@karma.life](mailto:hello@karma.life) with the API key name. # Reading sales data (/docs/sales-data) Sales come out of `GET /api/v1/purchases`. This page explains which rows are sales, which amount fields are authoritative, how VAT, discounts, modifiers and refunds are represented, and how to run a nightly export that survives late corrections. For direct warehouse access, use the [BigQuery guide](/docs/bigquery): its snake-case column names and view availability differ from this REST response. ## The shape [#the-shape] A **purchase** is one payment. It has a `status`, an `amount` (total, in cents), a `currency`, timestamps and a list of **line items**. Each line item is one thing on the receipt: a product, a tip, or an amount paid toward a split bill. All money is in **integer minor units** and all prices are **VAT-inclusive**. ## Which purchases are sales? [#which-purchases-are-sales] Only `status === "confirmed"`. `pending` is in flight; `canceled` and `failed` never took payment. Filter server-side with the `status` query parameter. ## Which line items are products? [#which-line-items-are-products] | `type` | Meaning | Include in a product-sales export? | | --------- | ------------------------------------------------------ | ---------------------------------- | | `product` | A sellable catalogue item | **Yes** | | `tip` | A gratuity line | No | | `amount` | An abstract amount paid toward a split or partial bill | No | Discounts are not separate line items; a line's discount is its `appliedTotalDiscountAmountCents`. On tabs with a ledger account it holds every discount on the line, given at payment (such as a voucher) or before it (at the till or by an integration's price lock). On other tabs it holds the payment-time discount; a discount given on the item before payment is already in the line's price and has no field of its own. ## Prices, quantity and VAT [#prices-quantity-and-vat] | Field | What it is | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `finalAmountCents` | **Authoritative per-unit price**, VAT-inclusive, after every discount | | `totalAmountCents` | **Total of the entire line before its discount**, not a per-unit amount: on ledger tabs the price before every discount, on other tabs with any discount given before payment already in it. May be null on some legacy purchases; reconcile those rows separately | | `appliedTotalDiscountAmountCents` | The line's discount on the whole line as a positive number (see above). Already taken off `finalAmountCents`, not off `totalAmountCents` | | `quantity` | Whole-unit count | | `quantityCents` | `quantity × 100`; divide by 100 for fractional or weight-based items | | `vatRateBasisPoints` | VAT rate in basis points: `1200` is 12%, `2500` is 25% | | `vatAmountCents` | VAT **per unit**, already contained in `finalAmountCents` | | `totalVatAmountCents` | VAT for the whole line | For current finalized lines, the paid line amount is `totalAmountCents - appliedTotalDiscountAmountCents`. Do not multiply the line total by quantity or subtract the discount again. Legacy/null totals and fractional quantities need a separate check against the purchase payment total. ## Modifiers and bundles [#modifiers-and-bundles] Modifiers appear two ways: * **Inline**, in `appliedModifications`: `[{ title, priceAdjustmentCents, variantRef? }]`. On tabs outside the tab ledger, payment-time discounts and vouchers also appear here with a negative `priceAdjustmentCents`; purchases on ledger tabs carry no discount entries here. Read a line's discount from `appliedTotalDiscountAmountCents`, never from these entries. These entries carry no article number. * **As child line items**, when a variant or add-on maps to its own catalogue item. The child points at its parent through `parentLineItemId`. Because add-ons can appear as separate child lines, summing every line can over- or under-count a bundle depending on how the product is modelled. Reconcile your per-purchase line sum against the purchase `amount` as a check. ## Matching a line to your own catalogue [#matching-a-line-to-your-own-catalogue] `articleNo` is a Karma-internal reference used for fiscal cash-register registration. Despite the name it is not your SKU and will not match your catalogue. Use `originItemId`, the catalogue item the line was sold from, and join it to the item, where your own identifier lives in the item's `metadata`. ## Refunds [#refunds] Refund amounts are stored as positive values, the magnitude refunded. Negate them for a ledger. A refund is valid once it is `confirmed`. ## When is a sale final? [#when-is-a-sale-final] There is no single "finalised" flag: * The payment is final at `status === "confirmed"`. * Fee and settlement data can be written asynchronously after capture, minutes to a couple of hours later. * Late corrections and refunds can arrive afterwards. Export purchases that have been confirmed for a few hours, and re-poll on `updatedAt` to catch late changes. ## Exporting a day's sales [#exporting-a-days-sales] For a nightly job, page through the confirmed purchases for the date with cursor pagination. `limit` may go up to 1,000, and `paginate=cursor` skips the expensive total count. ```bash curl "$COMMON_API/api/v1/purchases?locationId=1234&status=confirmed&startDate=2026-09-23T00:00:00Z&endDate=2026-09-24T00:00:00Z&paginate=cursor&limit=500" \ -H "X-API-Key: $KARMA_API_KEY" ``` The response carries `data.purchases` and `data.pagination`. While `pagination.hasMore` is true, repeat the request with `cursor=` and everything else unchanged. Add `includeChildren=true` to cover a location's descendants in one pass. Three habits keep the export honest: 1. **Run it a few hours after close** so fee and settlement data has landed, and run it again the next night with `updatedAt` as your watermark to pick up refunds and corrections. 2. **Store what you fetched** keyed by purchase id, so a job that fails halfway resumes instead of starting over, and a re-fetched purchase overwrites rather than duplicates. 3. **Mind the [rate limit](/docs/rate-limits)**: one request per 500 purchases is cheap; one per purchase is not. For product lines with current totals, calculate the post-discount line amount as `totalAmountCents - appliedTotalDiscountAmountCents` and group by `vatRateBasisPoints` for VAT breakdowns. Reconcile separately with the purchase payment total: it can include tips, unallocated amount payments, and other adjustments, and an article export may intentionally omit non-goods lines. # Shared prices (/docs/shared-prices) A shared price is a named definition with an ordered set of pricing rules. Many items can link to the same definition. Updating it updates every linked item, including selections at child locations. Item VAT remains configured on each item. Individual item pricing still works: use it when an item should have its own amount and modifiers instead of a shared definition. ## Access and units [#access-and-units] Use the Common API base URL for your environment and an `X-API-Key` with access to the location. Listing requires `inventory.read`, creation requires `inventory.create`, and editing or assigning requires `inventory.update`. The location needs `grouped_pricing`, available on Connected/Growth and Intelligence. Send `X-Location-Id` for the location whose inventory you are editing, and the same `locationId` in pricing requests. The examples use placeholders; replace them with actual IDs. All pricing rule amounts are **currency units**, not cents: `25` means 25 SEK for a SEK location. ## Create a definition [#create-a-definition] ```bash curl "$COMMON_API/api/v1/prices?locationId=$PARENT_LOCATION_ID" \ -H "X-API-Key: $KARMA_API_KEY" \ -H "X-Location-Id: $PARENT_LOCATION_ID" \ -H 'Content-Type: application/json' \ -d '{"name":"Soda pricing","inheritToChildren":true,"pricings":[{"name":"BASE","price":25,"order":0}]}' ``` The response is HTTP 201 with the standard envelope: ```json {"status":"success","data":{"id":"","version":1},"meta":{}} ``` The first rule must be an unconditional `BASE`. A definition may contain up to 100 rules. Other rules can carry `scheduleId`, `channelTypes`, `validFrom`, `validTo` and `metadata`. Schedule IDs must be accessible to the owner; create schedules through the Schedules API before referencing them. An example second rule for a 10% takeaway discount is: ```json {"name":"PERCENT","price":0,"order":1,"channelTypes":["TAKEAWAY"],"metadata":{"percentage":-10}} ``` Create “Soda prices East” and “Soda prices West” as separate definitions. They are independent prices, not overrides of the base definition. ## Discover existing prices [#discover-existing-prices] `GET /api/v1/prices?locationId=&limit=50&offset=0&search=Soda` returns `data.items` and `data.hasMore`. Increase offset by the requested limit until `hasMore` is false. Limit is at most 100. Each definition includes its owner, `inherited`, rules, and current `version`. A child sees its local prices and ancestor prices with `inheritToChildren: true`. Parent prices are read-only there. Children can create their own local definitions. ## Assign to owned items [#assign-to-owned-items] Find exact IDs with `GET /api/v1/inventory?locationId=&search=Soda&page=1&limit=50`. Follow the returned pagination. Assign each owned item with: Inventory also supports `titleFilter` and `pricingName` (with `titleMatch` or `pricingMatch` set to `contains` or `eq`), `source=local|inherited`, and `minPrice`/`maxPrice` in currency units. Use `sort=title|calculatedPrice|pricing|source` with `order=asc|desc`. Price filtering and sorting use the effective current price for the requested `channel`, including inherited selections and active rules, before pagination. ```bash curl -X PUT "$COMMON_API/api/v1/inventory/$ITEM_ID" \ -H "X-API-Key: $KARMA_API_KEY" \ -H "X-Location-Id: $LOCATION_ID" \ -H 'Content-Type: application/json' \ -d '{"pricingGroupId":""}' ``` `POST /api/v1/inventory` also accepts `pricingGroupId` alongside the required item creation fields. Do not send an individual `pricings` array when linking a shared price. ## Assign at a child location [#assign-at-a-child-location] For an inherited item (or POS-managed item), use the override endpoint and the **child** location: ```bash curl -X PUT "$COMMON_API/api/v1/inventory/$ITEM_ID/overrides?locationId=$CHILD_LOCATION_ID" \ -H "X-API-Key: $KARMA_API_KEY" \ -H "X-Location-Id: $CHILD_LOCATION_ID" \ -H 'Content-Type: application/json' \ -d '{"pricingGroupId":""}' ``` The parent must permit pricing overrides on the item. Another child can select East without changing either definition or the parent item. The chosen definition stays linked, including future rule and schedule changes. ## Update a definition [#update-a-definition] `PUT /api/v1/prices/?locationId=` accepts the complete `name`, `inheritToChildren`, `pricings` and current `version`. Read the definition first and preserve rules you do not intend to change. A successful update returns a new version. HTTP 409 means the definition changed: read it again and reconcile before retrying. A child cannot edit an ancestor's definition. Omitting `pricingGroupId` from an item update preserves its selection. To detach an owned item, explicitly send `pricingGroupId: null` and its individual `pricings` in the same request. See the Inventory API override contract when clearing child overrides. Inaccessible prices return 404; feature or ownership restrictions return 403. Bulk client workflows should record successes and retry only failed item assignments. Creating a price is not idempotent: after an uncertain response, inspect the list before creating again. ## Kaia [#kaia] From Overview, ask Kaia to create a named price and apply it to specific items. She lists definitions and effective inventory, proposes writes for confirmation, and uses the same Common API rules. Pricing tools are `list_shared_prices`, `list_pricing_items`, `create_shared_price`, `update_shared_price`, and `assign_shared_price`. Assignments report per-item successes and failures. Automatic undo is not available for these operations. # Permissions Permission registry and management ## GET /api/v1/permissions List permissions List all permissions in the system with optional filters **Permissions:** 🔵 `permissions.read` Query parameters: - `category` (string, optional) — Filter by category - `search` (string, optional) — Search by key, name, or description - `page` (number, optional) - `limit` (number, optional) Response 200 — Default Response ```ts { status?: string data?: { permissions?: Array<{ id?: number key?: string name?: string description?: string | null category?: string | null 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/permissions/{id} Get permission Get a single permission by ID **Permissions:** 🔵 `permissions.read` Path parameters: - `id` (number, required) — Permission ID Response 200 — Default Response ```ts { status?: string data?: { id?: number key?: string name?: string description?: string | null category?: string | null createdAt?: string // date-time updatedAt?: string // date-time } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/permissions/matrix Get permission matrix Get all permissions grouped by resource for display in a matrix **Permissions:** 🔵 `permissions.read` Response 200 — Default Response ```ts { status?: string data?: { resources?: Array<{ resource?: string permissions?: unknown[] }> } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Retailer Users Retailer user management and invitations ## DELETE /api/v1/retailer/invitations/{id} Delete invitation Delete a pending invitation. Only unaccepted invitations can be deleted. **Permissions:** 🔴 `users.delete` Path parameters: - `id` (number, required) — Invitation ID Response 200 — Default Response ```ts { status?: string data?: { success?: boolean invitationId?: number } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/retailer/invitations/{id}/resend Resend invitation Resend an invitation email with a new token and extended expiration **Permissions:** 🟢 `users.create` Path parameters: - `id` (number, required) — Invitation ID Response 200 — Default Response ```ts { status?: string data?: { success?: boolean invitationId?: number expiresAt?: string // date-time } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/retailer/users List users and pending invitations List all users and pending invitations for the current location. Pending invitations have isInvitation=true and id=null. **Permissions:** 🔵 `users.read` Query parameters: - `roleId` (number, optional) — Filter by role ID - `isActive` (boolean, optional) — Filter by active status - `search` (string, optional) — Search by email or name - `page` (number, optional) - `limit` (number, optional) - `metadata` (string, optional) — JSON-encoded jsonb-containment filter on `retailer_users.metadata`. Example: `?metadata={"someKey":"value"}`. The dotted shorthand `?metadata.someKey=value` is also accepted. When set, pending invitations are excluded from the result (they have no metadata). Response 200 — Default Response ```ts { status?: string data?: { users?: Array<{ id?: number | null email?: string // email name?: string | null imageUrl?: string | null roleId?: number | null roleName?: string | null isActive?: boolean locationIds?: unknown[] permissionOverrides?: { … } | null maxDiscountPercent?: number | null maxDiscountAmountCents?: integer | null createdAt?: string // date-time updatedAt?: string // date-time lastLoginAt?: string | null // date-time isInvitation?: boolean invitationId?: number | null invitationExpiresAt?: string | null // date-time customFields?: unknown[] metadata?: { … } | null }> pagination?: { page: number limit: number total: number totalPages: number } } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/retailer/users Create user Invite a new user to one or more locations **Permissions:** 🟢 `users.create` Request body: ```ts { email: string // email name?: string | null roleId: number locationIds: number[] permissionOverrides?: { grants?: number[] denials?: number[] } | null phone?: string | null accessCode?: string | null } ``` Response 201 — Default Response ```ts { status?: string data?: { id?: number | null email?: string // email name?: string | null imageUrl?: string | null roleId?: number | null roleName?: string | null isActive?: boolean locationIds?: number[] permissionOverrides?: { grants?: number[] denials?: number[] } | null maxDiscountPercent?: number | null maxDiscountAmountCents?: integer | null createdAt?: string // date-time updatedAt?: string // date-time lastLoginAt?: string | null // date-time isInvitation?: boolean invitationId?: number | null invitationExpiresAt?: string | null // date-time 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 } | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/retailer/users/{id} Get user Get a single user by ID **Permissions:** 🔵 `users.read` Path parameters: - `id` (number, required) — User ID Response 200 — Default Response ```ts { status?: string data?: { id?: number | null email?: string // email name?: string | null imageUrl?: string | null roleId?: number | null roleName?: string | null isActive?: boolean locationIds?: number[] permissionOverrides?: { grants?: number[] denials?: number[] } | null maxDiscountPercent?: number | null maxDiscountAmountCents?: integer | null createdAt?: string // date-time updatedAt?: string // date-time lastLoginAt?: string | null // date-time isInvitation?: boolean invitationId?: number | null invitationExpiresAt?: string | null // date-time 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 } | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/retailer/users/{id} Update user Update an existing user. Setting maxDiscountPercent or maxDiscountAmountCents also requires discount_limits.manage; users cannot change their own limits. **Permissions:** 🟠 `users.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` (number, required) — User ID Request body: ```ts { name?: string | null roleId?: number isActive?: boolean locationIds?: number[] permissionOverrides?: { grants?: number[] denials?: number[] } | null maxDiscountPercent?: number | null maxDiscountAmountCents?: integer | null phone?: string | null imageUrl?: string | null birthday?: string | null // date gender?: "m" | "f" accessCode?: string | null customFields?: unknown | unknown[] } ``` Response 200 — Default Response ```ts { status?: string data?: { id?: number | null email?: string // email name?: string | null imageUrl?: string | null roleId?: number | null roleName?: string | null isActive?: boolean locationIds?: number[] permissionOverrides?: { grants?: number[] denials?: number[] } | null maxDiscountPercent?: number | null maxDiscountAmountCents?: integer | null createdAt?: string // date-time updatedAt?: string // date-time lastLoginAt?: string | null // date-time isInvitation?: boolean invitationId?: number | null invitationExpiresAt?: string | null // date-time 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 } | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/retailer/users/{id} Delete user Deactivate a user (soft delete) **Permissions:** 🔴 `users.delete` Path parameters: - `id` (number, required) — User ID Response 200 — Default Response ```ts { status?: string data?: { success?: boolean id?: number } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/retailer/users/{id}/activities Get user activities Get activity log for a specific user. Shows recent actions performed by the user. **Permissions:** 🔵 `users.read` Query parameters: - `page` (number, optional) - `limit` (number, optional) - `actionTypes` (string, optional) — Comma-separated list of action types to filter by - `startDate` (string, optional) — Filter activities after this date - `endDate` (string, optional) — Filter activities before this date Path parameters: - `id` (number, required) — User ID Response 200 — Default Response ```ts { status?: string data?: { activities?: Array<{ id?: number action?: string actionType?: string entityType?: string | null entityId?: number | null changes?: { … } | null metadata?: { … } | null ipAddress?: string | null userAgent?: string | null locationId?: number | null createdAt?: string // date-time }> pagination?: { page: number limit: number total: number totalPages: number } } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/retailer/users/{id}/location-access Get user location access Get all locations a user has access to with hierarchy information **Permissions:** 🔵 `users.read` Path parameters: - `id` (number, required) — User ID Response 200 — Default Response ```ts { status?: string data?: { locationAccess?: Array<{ locationId?: number locationName?: string parentLocationId?: number | null locationType?: string | null logoImageUrl?: string | null isExplicit?: boolean roleId?: number | null roleName?: string | null createdAt?: string | null // date-time parentLocationName?: string | null }> } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/retailer/users/{id}/location-access Grant location access Grant access to a location for a user. Current user must have access to the target location. **Permissions:** ⚪ `locations.invite` Path parameters: - `id` (number, required) — Target user ID Request body: ```ts { locationId: number roleId?: number } ``` Response 201 — Default Response ```ts { status?: string data?: { success?: boolean locationId?: number } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/retailer/users/{id}/location-access/{locationId} Revoke location access Revoke access to a location for a user. Users can revoke their own access (leave location) or with locations.invite permission can revoke others' access. **Permissions:** ⚪ `locations.invite (for others) or own profile` Path parameters: - `id` (number, required) — Target user ID - `locationId` (number, required) — Location ID to revoke access from Response 200 — Default Response ```ts { status?: string data?: { success?: boolean locationId?: number } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/retailer/users/{id}/location-access/available Get available locations for granting access Get locations the current user can grant to the target user (locations current user has that target user does not) **Permissions:** ⚪ `locations.invite` Path parameters: - `id` (number, required) — Target user ID Response 200 — Default Response ```ts { status?: string data?: { availableLocations?: Array<{ locationId?: number locationName?: string parentLocationId?: number | null locationType?: string | null logoImageUrl?: string | null parentLocationName?: string | null }> } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/retailer/users/{id}/reset-password Send password reset email Send a password reset email to a user. This is an admin action that triggers the standard forgot password flow. **Permissions:** 🟠 `users.update` Path parameters: - `id` (number, required) — Target user ID Response 200 — Default Response ```ts { status?: string data?: { success?: boolean email?: string } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/retailer/users/me Get current authenticated user with full profile, current location, accessible locations, and pending invitations Response 200 — Default Response ```ts { status?: string data?: { user?: { id?: number email?: string // email name?: string | null imageUrl?: string | null language?: string | null languageId?: number | null phone?: string | null phoneVerified?: boolean emailVerified?: boolean birthday?: string | null // date gender?: "m" | "f" timezone?: string companyId?: number roleId?: number | null roleName?: string | null updatedPassword?: boolean isSharedAccount?: boolean createdAt?: string // date-time accessCode?: string | null maxDiscountPercent?: number | null maxDiscountAmountCents?: integer | null } currentLocation?: { id?: number uuid?: string | null name?: string website?: string | null logoImageUrl?: string | null hasChildren?: boolean currency?: { code?: string symbol?: string isPrefix?: boolean } | null country?: { id?: number alpha2?: string } | null company?: { id?: number name?: string } | null timezone?: { id?: number timezone?: string } | null role?: string | null permissionSet?: string[] permissionDenials?: string[] betaFlags?: { … }[] features?: { … }[] migrationEligibility?: { eligible?: boolean environment?: string country?: string | null blockedReason?: string | null } | null } | null accessibleLocations?: Array<{ id?: number name?: string logoImageUrl?: string | null parentLocationId?: number | null locationType?: string | null isRootCompany?: boolean }> pendingInvitations?: Array<{ id?: number email?: string locationIds?: unknown[] roleId?: number roleName?: string | null permissionOverrides?: { … } | null invitedBy?: { … } | null customMessage?: string | null expiresAt?: string // date-time acceptedAt?: string | null // date-time createdAt?: string // date-time }> } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/retailer/users/me Update current user profile Update the current authenticated user's profile information including name, phone, image, birthday, gender, timezone, and language. 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 { name?: string | null phone?: string | null imageUrl?: string | null birthday?: string | null // date gender?: "m" | "f" timezone?: string languageId?: number | null accessCode?: string | null customFields?: unknown | unknown[] } ``` Response 200 — Default Response ```ts { status?: string data?: { id?: number email?: string // email name?: string | null imageUrl?: string | null phone?: string | null phoneVerified?: boolean emailVerified?: boolean birthday?: string | null gender?: "m" | "f" timezone?: string language?: string | null languageId?: number | null companyId?: number roleId?: number | null roleName?: string | null updatedPassword?: boolean isSharedAccount?: boolean createdAt?: string // date-time accessCode?: string | null } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## POST /api/v1/retailer/users/me/location Set active location Update the current user's active location. The user must have access to the specified location. Request body: ```ts { locationId: number } ``` Response 200 — Default Response ```ts { status?: string data?: { success?: boolean locationId?: number token?: string } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/retailer/users/me/password Update current user password Update the current authenticated user's password. Sets updated_password to true. Request body: ```ts { password: string } ``` Response 200 — Default Response ```ts { status?: string data?: { success?: boolean message?: string } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Roles Role management with permission assignment ## GET /api/v1/roles List roles List all roles in a company with optional filters Note: KARMELEON role is automatically filtered from results **Permissions:** 🔵 `roles.read` Query parameters: - `page` (number, optional) - `limit` (number, optional) Response 200 — Default Response ```ts { status?: string data?: { roles?: Array<{ id?: number name?: string description?: string | null locationId?: number | null permissionIds?: unknown[] isGlobal?: boolean 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 } } ``` ## POST /api/v1/roles Create role Create a new role in the company **Permissions:** 🟢 `roles.create` Request body: ```ts { name: string description?: string | null permissionIds: number[] } ``` Response 201 — Default Response ```ts { status?: string data?: { id?: number name?: string description?: string | null locationId?: number | null permissionIds?: number[] isGlobal?: boolean createdAt?: string // date-time updatedAt?: string // date-time } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/roles/{id} Get role Get a single role by ID **Permissions:** 🔵 `roles.read` Path parameters: - `id` (number, required) — Role ID Response 200 — Default Response ```ts { status?: string data?: { id?: number name?: string description?: string | null locationId?: number | null permissionIds?: number[] isGlobal?: boolean createdAt?: string // date-time updatedAt?: string // date-time } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## PUT /api/v1/roles/{id} Update role Update an existing role Note: Cannot update system roles or KARMELEON role **Permissions:** 🟠 `roles.update` Path parameters: - `id` (number, required) — Role ID Request body: ```ts { name?: string description?: string permissionIds?: number[] } ``` Response 200 — Default Response ```ts { status?: string data?: { id?: number name?: string description?: string | null locationId?: number | null permissionIds?: number[] isGlobal?: boolean createdAt?: string // date-time updatedAt?: string // date-time } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## DELETE /api/v1/roles/{id} Delete role Delete a role Note: Cannot delete system roles, KARMELEON role, or roles in use by users **Permissions:** 🔴 `roles.delete` Path parameters: - `id` (number, required) — Role ID Response 200 — Default Response ```ts { status?: string data?: { success?: boolean id?: number } meta?: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Staff ## POST /api/v1/loyalty/staff-link-payment-method Staff-assisted card link with verbal consent Links a payment card to a user account using verbal consent captured by a staff member. This endpoint: 1. Extracts card data from the purchase 2. Links the card to the specified user with verbal consent 3. Records a consent event with the staff member's retailerUserId 4. Auto-enrolls the user in the location's loyalty program if not already enrolled 5. Awards retroactive points for the triggering purchase **Use case**: When a guest doesn't scan the QR code, staff can look up the user and link their card manually after verbal confirmation. **Permissions:** 🟢 `loyalty.create` Request body: ```ts { purchaseIntentId: string userId: number locationId: number consentMethod: "verbal" retailerUserId: number } ``` Response 200 — Default Response ```ts { status: "success" data: { cardLinked: boolean enrolled: boolean pointsAwarded: number } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` ## GET /api/v1/loyalty/user-search Search users by name, email, or phone (staff) Search for users matching a query string. Used by staff during the fallback enrollment flow. Query parameters: - **q**: Search term (minimum 2 characters) — matched against email, name, and phone - **purchaseIntentId**: Optional — if provided, the tab member for that purchase is auto-suggested Results are sorted by relevance. Maximum 10 results returned. **Permissions:** 🔵 `users.read` Query parameters: - `q` (string, required) — Search term (name, email, or phone) - `purchaseIntentId` (string, optional) — Optional purchase intent ID for tab member auto-suggest Response 200 — Default Response ```ts { status: "success" data: { users: Array<{ id: number email?: string | null firstName?: string | null lastName?: string | null phone?: string | null }> autoSuggested: { userId?: number firstName?: string | null lastName?: string | null email?: string | null autoSuggested?: true } | null } meta: { requestId: string timestamp: string // date-time executionTimeMs: number } } ``` # Surplus entry added Webhook event: `surplus.added` Fired when a venue adds surplus (New Save) inventory. Drives consumer push notifications. Delivered payload: ```ts { id: string // uuid event: "surplus.added" resource: "surplus" resourceId: string timestamp: string // date-time apiVersion: "v1" data: { locationId: integer locationName: string entryId: integer addedAt: string // date-time } } ``` # Surplus Surplus inventory — food items pushed to Karma for end-of-day sale at a discount ## GET /api/v1/locations/{locationId}/surplus List surplus entries for a location Path parameters: - `locationId` (integer, required) Response 200 — Default Response ```ts { status?: string data?: { surplusEntries?: Array<{ id?: integer itemId?: integer locationId?: integer ministock?: number ministockCents?: number | null measuredStockAtoms?: integer | null portionMeasurement?: { … } | null validFrom?: string validUntil?: string | null discountPercentage?: integer | null overridePriceCents?: integer | null computedPriceCents?: integer | null note?: string | null recurringScheduleId?: integer | null createdAt?: string updatedAt?: string item?: { … } }> } } ``` ## POST /api/v1/locations/{locationId}/surplus Create a surplus entry Path parameters: - `locationId` (integer, required) Request body: ```ts { itemId: integer ministock: integer portionAmountMicros?: integer validFrom: string validUntil?: string discountPercentage?: integer overridePriceCents?: integer note?: string } ``` Response 201 — Default Response ```ts { status?: string data?: { surplusEntry?: { id?: integer itemId?: integer locationId?: integer ministock?: number ministockCents?: number | null measuredStockAtoms?: integer | null portionMeasurement?: { unit: string amountMicros: integer } | null validFrom?: string validUntil?: string | null discountPercentage?: integer | null overridePriceCents?: integer | null computedPriceCents?: integer | null note?: string | null recurringScheduleId?: integer | null createdAt?: string updatedAt?: string item?: { id?: integer name?: string description?: string | null imageUrl?: string | null basePriceCents?: integer | null } } } meta?: { requestId?: string timestamp?: string executionTimeMs?: number } } ``` ## PUT /api/v1/locations/{locationId}/surplus/{id} Update a surplus entry Path parameters: - `locationId` (integer, required) - `id` (integer, required) Request body: ```ts { ministock?: integer measuredStockAtoms?: integer expectedMeasuredStockAtoms?: integer portionAmountMicros?: integer validFrom?: string validUntil?: string | null discountPercentage?: integer | null overridePriceCents?: integer | null note?: string | null } ``` Response 200 — Default Response ```ts { status?: string data?: { surplusEntry?: { id?: integer itemId?: integer locationId?: integer ministock?: number ministockCents?: number | null measuredStockAtoms?: integer | null portionMeasurement?: { unit: string amountMicros: integer } | null validFrom?: string validUntil?: string | null discountPercentage?: integer | null overridePriceCents?: integer | null computedPriceCents?: integer | null note?: string | null recurringScheduleId?: integer | null createdAt?: string updatedAt?: string item?: { id?: integer name?: string description?: string | null imageUrl?: string | null basePriceCents?: integer | null } } } meta?: { requestId?: string timestamp?: string executionTimeMs?: number } } ``` ## DELETE /api/v1/locations/{locationId}/surplus/{id} Delete a surplus entry Path parameters: - `locationId` (integer, required) - `id` (integer, required) Response 204 — Default Response ## GET /api/v1/locations/{locationId}/surplus/active Get today's active surplus items (public) Path parameters: - `locationId` (integer, required) Response 200 — Default Response ```ts { status?: string data?: { surplusEntries?: Array<{ id?: integer itemId?: integer locationId?: integer ministock?: number ministockCents?: number | null measuredStockAtoms?: integer | null portionMeasurement?: { … } | null validFrom?: string validUntil?: string | null discountPercentage?: integer | null overridePriceCents?: integer | null computedPriceCents?: integer | null note?: string | null recurringScheduleId?: integer | null createdAt?: string updatedAt?: string item?: { … } }> } } ``` ## POST /api/v1/locations/{locationId}/surplus/bulk Bulk create surplus entries (returns partial results) Path parameters: - `locationId` (integer, required) Request body: ```ts { items: Array<{ itemId: integer ministock: integer portionAmountMicros?: integer validFrom: string validUntil?: string discountPercentage?: integer overridePriceCents?: integer note?: string }> } ``` Response 200 — Default Response ```ts { status?: string data?: { created?: Array<{ id?: integer itemId?: integer locationId?: integer ministock?: number ministockCents?: number | null measuredStockAtoms?: integer | null portionMeasurement?: { … } | null validFrom?: string validUntil?: string | null discountPercentage?: integer | null overridePriceCents?: integer | null computedPriceCents?: integer | null note?: string | null recurringScheduleId?: integer | null createdAt?: string updatedAt?: string item?: { … } }> conflicts?: Array<{ itemId?: integer itemName?: string }> errors?: Array<{ itemId?: integer message?: string }> } meta?: { requestId?: string timestamp?: string executionTimeMs?: number } } ``` ## GET /api/v1/locations/{locationId}/surplus/recurring List recurring surplus schedules for a location Path parameters: - `locationId` (integer, required) Response 200 — Default Response ```ts { status?: string data?: { schedules?: Array<{ id?: integer locationId?: integer itemId?: integer ministock?: integer portionMeasurement?: { … } | null discountPercentage?: integer | null overridePriceCents?: integer | null note?: string | null weekdays?: unknown[] publishTime?: string timezone?: string startDate?: string endDate?: string | null paused?: boolean itemName?: string | null createdAt?: string updatedAt?: string }> } meta?: { requestId?: string timestamp?: string executionTimeMs?: number } } ``` ## POST /api/v1/locations/{locationId}/surplus/recurring Create recurring surplus schedules (one per item) Path parameters: - `locationId` (integer, required) Request body: ```ts { schedules: Array<{ itemId: integer ministock: integer portionAmountMicros?: integer discountPercentage?: integer | null overridePriceCents?: integer | null note?: string | null weekdays: integer[] publishTime: string startDate?: string endDate?: string | null }> } ``` Response 201 — Default Response ```ts { status?: string data?: { created?: Array<{ id?: integer locationId?: integer itemId?: integer ministock?: integer portionMeasurement?: { … } | null discountPercentage?: integer | null overridePriceCents?: integer | null note?: string | null weekdays?: unknown[] publishTime?: string timezone?: string startDate?: string endDate?: string | null paused?: boolean itemName?: string | null createdAt?: string updatedAt?: string }> errors?: Array<{ itemId?: integer message?: string }> } meta?: { requestId?: string timestamp?: string executionTimeMs?: number } } ``` ## PUT /api/v1/locations/{locationId}/surplus/recurring/{id} Update a recurring surplus schedule Path parameters: - `locationId` (integer, required) - `id` (integer, required) Request body: ```ts { ministock?: integer portionAmountMicros?: integer discountPercentage?: integer | null overridePriceCents?: integer | null note?: string | null weekdays?: integer[] publishTime?: string startDate?: string endDate?: string | null paused?: boolean } ``` Response 200 — Default Response ```ts { status?: string data?: { schedule?: { id?: integer locationId?: integer itemId?: integer ministock?: integer portionMeasurement?: { unit: string amountMicros: integer } | null discountPercentage?: integer | null overridePriceCents?: integer | null note?: string | null weekdays?: integer[] publishTime?: string timezone?: string startDate?: string endDate?: string | null paused?: boolean itemName?: string | null createdAt?: string updatedAt?: string } } meta?: { requestId?: string timestamp?: string executionTimeMs?: number } } ``` ## DELETE /api/v1/locations/{locationId}/surplus/recurring/{id} Delete a recurring surplus schedule Path parameters: - `locationId` (integer, required) - `id` (integer, required) Response 204 — Default Response ## GET /api/v1/locations/{locationId}/surplus/stats Surplus sales stats (GMV + units sold) for a location on a date Query parameters: - `date` (string, optional) Path parameters: - `locationId` (integer, required) Response 200 — Default Response ```ts { status?: string data?: { date?: string gmvCents?: integer itemsSold?: integer } meta?: { requestId?: string timestamp?: string executionTimeMs?: number } } ``` ## GET /api/v1/locations/{locationId}/surplus/timeline Resolved opening, pickup and per-entry selling windows for one day Query parameters: - `date` (string, optional) — YYYY-MM-DD in the venue timezone; defaults to today Path parameters: - `locationId` (integer, required) Response 200 — Default Response ```ts { status?: string data?: { date?: string timezone?: string openingPeriods?: Array<{ startsAt?: string endsAt?: string }> pickupPeriods?: Array<{ startsAt?: string endsAt?: string }> visibility?: { evaluatedNow?: boolean lifecycleOpen?: boolean migrated?: boolean } entries?: Array<{ visible?: boolean | null blockedBy?: string | null id?: integer itemId?: integer itemName?: string | null ministock?: integer validFrom?: string validUntil?: string | null recurringScheduleId?: integer | null hasExplicitStart?: boolean sells?: unknown[] }> } } ``` # Pushing surplus from your POS (/docs/surplus-from-pos) Surplus is food that would otherwise go to waste at the end of a service. By pushing it to Karma you sell it, reduce waste, and keep Karma's stock in sync with your own, all from your own system. ## How it works [#how-it-works] One helper endpoint does everything: ``` POST /api/v1/locations/{locationId}/helpers/surplus/add ``` 1. **Find or create the item.** Pass an `externalId` (your SKU or PLU) with the item details. Karma looks the item up by `externalId` at this location and creates it if it does not exist. 2. **Set the sell window.** Pass a `date` (midnight until the store's closing time) or an explicit `validFrom` and `validUntil`. 3. **Upsert the surplus entry.** If you already pushed this item for the day, the quantity is **replaced**. Pushing `quantity: 3` at 15:00 means three are left, not three more. You need an API key scoped to the location with permission to create surplus. ## Quick example [#quick-example] ```bash curl -X POST "$COMMON_API/api/v1/locations/1234/helpers/surplus/add" \ -H "X-API-Key: $KARMA_API_KEY" -H 'Content-Type: application/json' \ -d '{ "item": { "externalId": "sku-pasta-bolognese", "title": "Pasta Bolognese", "description": "Classic slow-cooked bolognese with tagliatelle", "imageUrl": "https://your-cdn.example.com/pasta-bolognese.jpg", "basePriceCents": 12900, "category": "FOOD", "allergens": ["GLUTEN", "MILK"] }, "quantity": 5, "discountPercentage": 50, "date": "2026-09-24" }' ``` The first push for an item on a day returns 201; later pushes return 200. ```json { "status": "success", "data": { "surplusEntry": { "id": 88421, "itemId": 56789, "locationId": 1234, "ministock": 5, "validFrom": "2026-09-24T00:00:00.000Z", "validUntil": "2026-09-24T21:00:00.000Z", "discountPercentage": 50, "overridePriceCents": null }, "item": { "id": 56789, "created": true } } } ``` ## Request reference [#request-reference] | Field | Required | Description | | ----------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `itemId` | either | An existing Karma item id. Mutually exclusive with `item`. | | `item` | either | Item definition for find-or-create. | | `item.externalId` | with `item` | Your stable product identifier. Used for the idempotent lookup. | | `item.title` | with `item` | Name shown to customers. | | `item.basePriceCents` | with `item` | Full, undiscounted price in cents. | | `item.description`, `item.imageUrl` | no | Description and a publicly reachable image URL. | | `item.category` | no | A category tag key configured for your account, e.g. `FOOD`, `BAKERY`, `BEVERAGE`. | | `item.allergens` | no | Allergen tag keys, e.g. `GLUTEN`, `MILK`, `NUTS`, `EGG`, `FISH`. | | `quantity` | **yes** | Units available. Replaces any existing quantity for this item and day. | | `discountPercentage` | no | Percentage off `basePriceCents`, up to 90. The floor is the location's surplus floor, 50 by default. Mutually exclusive with `overridePriceCents`. | | `overridePriceCents` | no | Fixed surplus price in cents instead of a percentage. | | `note` | no | Internal note, not shown to customers. | | `date` | either | `YYYY-MM-DD`. Sell window is midnight to closing time. | | `validFrom`, `validUntil` | either | Explicit sell window, date or datetime. | ## Scenarios [#scenarios] **End-of-day push.** A job at 19:00 pushes everything above a threshold: ```typescript const today = new Date().toISOString().slice(0, 10) for (const item of remaining.filter((i) => i.quantity >= 2)) { await fetch(`${COMMON_API}/api/v1/locations/1234/helpers/surplus/add`, { method: 'POST', headers: { 'X-API-Key': KARMA_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ item: { externalId: item.sku, title: item.name, basePriceCents: item.priceCents }, quantity: item.quantity, discountPercentage: 50, date: today, }), }) } ``` **Updating quantity during the day.** You pushed 10 at open; 4 sold through Karma and 3 in store. Push `quantity: 3` with the same `externalId` and `date`. Karma replaces the number. **An item you already know.** Skip find-or-create and pass `itemId` with `overridePriceCents` and a `note`. **A lunch-only window.** Pass `validFrom: "2026-09-24"` and `validUntil: "2026-09-24T14:00:00.000Z"` instead of `date`. ## Bulk upload [#bulk-upload] For nightly syncs of many products, `POST .../helpers/surplus/add-bulk` takes up to **500** entries in `items[]`, each with the same shape as a single request. Items are processed in order, and errors are per item: one bad entry does not abort the rest. The response is 207 with `results[]` and `errors[]`; when anything failed, `status` is `"partial"`. ```json { "status": "partial", "data": { "results": [{ "index": 0, "surplusId": 88421, "itemId": 56789, "itemCreated": true, "surplusCreated": true }], "errors": [{ "index": 1, "error": "Provide either date or validFrom" }] } } ``` ## Listing, updating and removing [#listing-updating-and-removing] `GET .../helpers/surplus` lists everything you have pushed with each entry's `item.externalId` and a computed `status` of `upcoming`, `active` or `expired`. Filter with `from` and `to`. To change an entry, `PUT /api/v1/locations/{locationId}/surplus/{id}` with `ministock` (the new quantity) and optionally `discountPercentage`, `overridePriceCents`, `note`, `validUntil`. To remove it, `DELETE` the same path; it returns 204 and the item disappears from the app immediately. An entry whose quantity reaches zero through sales becomes inactive on its own; you only delete surplus you decide not to sell after all. ## Good habits [#good-habits] * **Keep `externalId` stable.** It is the mapping between your product and Karma's item. A new `externalId` creates a duplicate item. * **Push quantities, not deltas.** Always send the absolute number remaining. * **Subscribe to `surplus.added`** if another system needs to know when surplus goes live. See the Surplus API events. # Variant custom fields (/docs/variant-custom-fields) Variant groups and their linked inventory items have separate custom fields: * The group's top-level `customFields` belong to object type `variant_groups`. * Each item-based variant's `customFields` belong to object type `items`, identified by that selection's `itemId`. Selections referencing the same item share its stored values at the same location. * Customer answers collected during checkout are order-line snapshots, submitted as `guest_field_answers`. They do not overwrite stored group or inventory values. ## Stored group attributes [#stored-group-attributes] Create definitions through `/api/v1/locations/{locationId}/object-types/variant_groups/fields`. Read values in the group's normal GET response. Set values using top-level `customFields` in `POST /api/v1/variant-groups` or `PUT /api/v1/variant-groups/{id}`. These writes are atomic with the group, like reservation custom fields. Use `fieldId` or a stable `key` from the effective definitions. `expectedVersion` is optional; include the last-read `version` to reject stale edits with `409 VERSION_CONFLICT`. A new value starts at version `0`. See [Inline values](/docs/custom-fields#values-on-normal-create-and-update-calls). ## Stored values directly on a variant [#stored-values-directly-on-a-variant] Read `variants[].customFields` and `variants[].metadata.customFields` in a group response, or fetch the selected option directly: ```bash curl "$COMMON_API/api/v1/variants/34?locationId=1234" \ -H "X-API-Key: $KARMA_API_KEY" ``` Patch its linked item's values without replacing the group's selections: ```bash curl -X PATCH "$COMMON_API/api/v1/variants/34?locationId=1234" \ -H "X-API-Key: $KARMA_API_KEY" -H 'Content-Type: application/json' \ -d '{"customFields":[{"key":"engraving","expectedVersion":0,"value":"Alex"}]}' ``` Replace `engraving` with your actual item-field key. Omit `expectedVersion` only when you intend to overwrite the latest value. Each change accepts either `fieldId` or `key`, and either `value` or `reset: true`. Omitted fields and an empty array preserve existing values. `value: null` clears an optional field; resetting removes the local override and restores an inherited item value. The response contains `id` (the variant ID), `itemId`, `locationId`, `customFields`, and `metadata.customFields`. It is a field projection; use the linked item's normal GET for full inventory details. Legacy selections with `itemId: null` have no linked form or fields and return 422 on the direct field route. The API key needs `variant_groups.read` and `inventory.read`; writes also need `inventory.update` and the field's `apiWrite` channel. Only API-readable fields are returned. The requested location must have access to both the group and the linked item, including inheritance rules. Responses are private and must not be cached across callers. Group create/update requests can also include `variants[].customFields` alongside each selection's `itemId`. Those changes and the group edit share one transaction. When selections share an item, include its changes once or submit identical changes. A PUT of `variants` replaces the selection list; retain every selection you still need. Neither a new variant ID nor a new group resets the linked item's existing field versions. Reads batch and deduplicate item references. Writes resolve the item schema once per group write and fetch existing field versions together per item. Invalid values and stale versions roll back the native edit and custom field changes together. ## Customer answers when ordering [#customer-answers-when-ordering] Use `POST /api/v1/orders/create` to submit customer answers. The simplified `POST /api/v1/public/orders/create` endpoint currently does not forward them. ## 1. Configure the fields [#1-configure-the-fields] Create definitions through the [Objects and fields API](/docs/custom-fields#definitions), using `/api/v1/locations/{locationId}/object-types/items/fields`. Enable `channels.guestInput` for fields customers should fill in; `guestDisplay` controls display and does not enable input. Set `required` and validation in the definition as needed. Definitions apply to items at the location and inherit down the location tree. To limit an input to a particular item, use a condition on a native item property. For example, this definition asks for an engraving only when the linked item's title is `Engraved cup`: ```json { "key": "engraving", "label": "Engraving", "type": "text", "required": true, "config": { "maxLength": 40, "condition": { "field": "native.title", "equals": "Engraved cup" } }, "channels": { "staff": true, "table": false, "tableDefault": false, "guestDisplay": false, "guestInput": true, "apiRead": true, "apiWrite": false, "analytics": true, "aiSource": false } } ``` This title condition uses an exact match; review it if the item is renamed. `apiWrite` governs editing stored object values through the values API. Customer answers use the guest-input form instead and are saved with the order line, without changing the inventory item's stored values. ## 2. Discover the selected variant's form [#2-discover-the-selected-variants-form] Read the group with `GET /api/v1/variant-groups/{id}`. Each selected option has a variant `id` and, for item-based variants, an `itemId` pointing to its inventory item. A legacy variant with `itemId: null` has no linked item form. For example, if variant `34` has `itemId: 789`, fetch the linked item at the ordering location: ```bash curl "$COMMON_API/api/v1/inventory/789?locationId=1234" \ -H "X-API-Key: $KARMA_API_KEY" ``` Read `data.guestInput`, which contains `schemaToken` and `fields`. Use this form rather than the item's `customFields` display values or a variant-group ID. An illustrative fragment of the item response is: ```json { "data": { "id": 789, "guestInput": { "schemaToken": "", "fields": [ { "id": "2c6f83c9-993f-4c39-a75d-b78cc89f0c10", "key": "loc1234.engraving", "label": "Engraving", "description": "", "group": "", "type": "text", "required": true, "revision": 1, "config": { "maxLength": 40 } } ] } } } ``` Render each field using its label, type, required flag and config. Native item conditions have already been evaluated by the server. If `config.condition` is returned, its `field` is another field's **key**; show the dependent input only when the other answer matches `equals`. For a multiselect, matching means membership; for a taxonomy, a descendant also matches its ancestor. Omit answers to hidden fields. The token is tied to the location, linked inventory item and effective form. Load the parent item's form separately and load a form for each selected linked item; do not reuse the parent's token for a variant. ## 3. Submit answers on the selected variant [#3-submit-answers-on-the-selected-variant] The API key needs `inventory.read` to load item forms, `variant_groups.read` to read groups, and `inventory.create` to place the order, with access to the ordering location. Use the **variant ID** in `variants[].id`. Place the linked item's token and answers in that selection's `guest_field_answers`. The parent item's answers belong in `items[].guest_field_answers` instead. These request properties use snake\_case; `schemaToken` and `fieldId` use camelCase. For parent item `456` and selected variant `34`, submit the following to `POST /api/v1/orders/create`. Replace the placeholder token and field UUID with values from variant `34`'s linked item form. This example assumes the parent item has no guest input fields; otherwise include its own submission too. ```json { "locationId": 1234, "order_channel": "ONLINE", "order_type": "TAKEAWAY", "order_destination": "NONE", "order_destination_id": null, "order_flow": "OPEN_TAB", "tab_member": { "identifier": "d7372e2f-abcd-486e-a6b7-e5ad1e401f81" }, "vouchers": [], "items": [ { "id": 456, "quantity_cents": 100, "comments": [], "variants": [ { "id": 34, "quantity_cents": 100, "guest_field_answers": { "schemaToken": "", "answers": [ { "fieldId": "2c6f83c9-993f-4c39-a75d-b78cc89f0c10", "value": "Alex" } ] } } ] } ] } ``` `fieldId` must be the form field's UUID, not its key or label. Submit only `{ "schemaToken", "answers" }`; each answer contains only `{ "fieldId", "value" }`. Labels, revisions and display values are generated by the server. Answers are stored as snapshots on the corresponding order lines. Use a fresh `tab_member.identifier` for a new anonymous guest, or supply the guest identity supported by the [Ordering API](/docs/ordering/orders). `order_flow: "OPEN_TAB"` leaves payment for later; choose the flow appropriate to your integration. ## Value formats and validation [#value-formats-and-validation] Use the [documented wire types](/docs/custom-fields#definitions): text and dates are strings, numbers are decimal **strings**, booleans are JSON booleans, currency is `{ "amount": "80", "currency": "SEK" }`, and select/taxonomy/multiselect values use option IDs from the form's `config.options`. Relations, formulas and rollups are not guest input types. Each submission replaces the whole form's answers. Include every visible required field; omitted optional visible fields become `null`. `false`, `"0"` and `null` are distinct values. When a form has fields, submit its token even if all inputs are optional and `answers` is empty. A form with no fields can be omitted. The limit is 100 answers per submission. | Status | Code | Recovery | | ------ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 409 | `FIELD_FORM_CHANGED` | Reload the linked item's form at the same location, let the customer review the inputs, and retry with the new token. A token from another item or location also fails. | | 422 | `FIELD_VALIDATION` | Correct missing required values, invalid types or option IDs, duplicate field IDs, hidden/unknown fields, or a missing form submission. Follow the error message. | Field validation happens within the order transaction. A rejected submission does not create a partial order. # Receiving webhooks (/docs/webhooks) Karma pushes events to an HTTPS endpoint you control. Subscribe once, then handle events as they happen instead of polling for changes. ## The envelope [#the-envelope] Every event arrives as a `POST` with the same outer shape. Only `data` differs between event types — its schema is documented on each event's page. ```json { "id": "9f1c…", "event": "order.created", "resource": "order", "resourceId": "3b7e…", "timestamp": "2026-08-02T09:41:17.000Z", "apiVersion": "v1", "data": { } } ``` | Field | Description | | ------------------------- | --------------------------------------------------------------------------------- | | `id` | Unique id for this delivery group. Stable across retries — use it to deduplicate. | | `event` | Event name, e.g. `order.created`. | | `resource` / `resourceId` | The entity the event is about. | | `timestamp` | When the event occurred, ISO 8601 UTC. | | `apiVersion` | Envelope version. Currently always `v1`. | | `data` | Event-specific payload. | ## Custom fields and metadata [#custom-fields-and-metadata] HTTP webhooks include the object's API-readable custom fields in two forms: * `customFields`: values with their field ID, stable key, label, group, type, display value, version and status. * `metadata.customFields`: the same values keyed by the field's stable key. Other existing metadata is preserved. For inventory, booking and order events these properties live directly under `data`. Tab events that already contain a `tab` reference put them under `data.tab`; other tab events put them under `data`. Existing `location`, `tab`, `item`, `purchase`, `table` and `guest` reference blocks are enriched too, including `items[].item`. A booking with a `purchaseId` also gets a `purchase` reference. A staff `user` reference is **not** a guest profile. Only fields enabled for API reading are included, respecting inherited restrictions and the subscription's API-key permissions and location access. Company-owned subscriptions without an API key receive API-readable fields for their matched location. Unset fields are omitted; explicitly cleared values remain `null`. Deleted or inaccessible records have empty field projections. Decimal and money values retain the API's exact representation. Values are captured when outbound dispatch is prepared, which can be later than the event timestamp. They are **not sale-time snapshots**. Once saved for a delivery, the values stay unchanged across retries, even if the fields are edited afterwards. The signature covers the entire payload, including fields. These subscriber-specific values are not added to browser realtime broadcasts. This enrichment does not introduce new event types or emit an event merely because a custom field was edited. In particular, there is currently no standalone public purchase webhook; purchase references are enriched within existing events. ## Verifying deliveries [#verifying-deliveries] Each request carries an `X-Webhook-Signature` header: an HMAC-SHA256 of the raw request body, keyed with your subscription's signing secret. Compute the HMAC over the **raw** body bytes, before any JSON parsing, and compare using a constant-time comparison. Reject any request whose signature does not match. ## Responding [#responding] Respond `2xx` within **30 seconds** to acknowledge delivery. Anything else — including a timeout — is treated as a failure and retried. Acknowledge first, process afterwards: do the minimum work needed to persist the event, return `2xx`, and handle the rest asynchronously. Slow handlers are the most common cause of duplicate deliveries. ## Retries, ordering and duplicates [#retries-ordering-and-duplicates] * **Retries** — failed deliveries are retried with backoff. Your endpoint must be idempotent. * **Duplicates** — at-least-once delivery. The same `id` may arrive more than once; deduplicate on it. * **Ordering** — not guaranteed. Use `timestamp` to order events, and don't assume `order.updated` arrives after `order.created`. ## Subscribing [#subscribing] Create and manage subscriptions through the Webhooks endpoints in the API reference. A subscription is a URL, the set of events it should receive, and the location scope it applies to.