Orders — Dispatch to External POS¶
Overview¶
The Orders API enables kds-management-service to dispatch order events (e.g. order.paid) to external POS systems through a two-stage JS pipeline.
kds-management-service
│
▼
pos-integration-service (Order API)
│
▼
DispatchPipeline (internal/connectors/dispatch_pipeline.go)
│
├─ 1. Standard script (always) → scripts/adapters/_standard/order_dispatch.js
│ builds the Cata-standard webhook envelope (URL, headers, body=null)
│
├─ 2. Transformer script (optional) → scripts/adapters/<provider>/order_dispatch.js
│ runs only when the request supplies builtInTransformer="<provider>";
│ may inherit standard.url or supply its own (e.g. provider's POS API)
│
└─ 3. Outbound POST
• no transformer → original payload bytes + Cata HMAC headers
• transformer ran → transformer's body & auth headers, no HMAC
No provider-specific Go code. Per-provider behaviour lives entirely in JS scripts deployed to centraldb.adapter_scripts. Adding a new POS = writing a transformer script and deploying it.
Dispatch Flow¶
kds-management-service pos-integration-service DispatchPipeline External POS
│ │ │ │
│ POST /api/v1/orders/dispatch │ │ │
│ { outletId, event, order, │ │ │
│ builtInTransformer? } │ │ │
│─────────────────────────────>│ │ │
│ │ 1. Persist order (status=pending) │ │
│ │ 2. Build OrderEvent + delegate │ │
│ │───────────────────────────────────>│ │
│ │ │ 3. Lookup webhook │
│ │ │ 4. Run standard script (JS) │
│ │ │ 5. Optionally chain │
│ │ │ transformer (JS) │
│ │ │ 6. POST │
│ │ │─────────────────────────────>│
│ │ │ 2xx │
│ │ │<─────────────────────────────│
│ │ 7. Update order status=dispatched │ │
│ { orderId, dispatched: true} │ │ │
│<─────────────────────────────│ │ │
Pipeline Interface¶
// Dispatcher orchestrates the order-dispatch pipeline (standard script,
// optional per-provider transformer, HMAC injection, outbound POST).
type Dispatcher interface {
Dispatch(ctx context.Context, tenantID string, storeDBID int64, event OrderEvent, builtInTransformer string) (*DispatchResult, error)
}
The OrderDispatchService always delegates to a single *connectors.DispatchPipeline. The pipeline:
- Looks up an active webhook for
(store, event)(used by the standard script to fillwebhook.callbackUrl). - Runs the standard script (
@provider _standard, @topic order_dispatch) — falls back to a hardcoded equivalent when the row is absent incentraldb.adapter_scripts(fresh install). - If
builtInTransformerwas supplied on the request, looks up(builtInTransformer, order_dispatch)incentraldb.adapter_scripts. Unknown name → returnsErrUnknownTransformer→ controller maps to HTTP 400. - Loads
outlet_providers.settingsfor the outlet (only when a transformer is going to run) and feeds it intoinput.outlet.settingsso the transformer can read credentials. - Whichever script produced the final spec, the pipeline POSTs once. On the no-transformer path it injects
X-Cata-Signature(HMAC-SHA256),X-Cata-Event,X-Cata-Delivery-ID,X-Cata-Timestampover the original payload bytes.
The whole pipeline runs under a 30-second budget (connectors.DefaultOrderDispatchTimeout) covering both script executions and the outbound HTTP call.
Dispatch Endpoint¶
POST /api/v1/orders/dispatch¶
Called by kds-management-service (internal service-to-service).
Request:
{
"outletId": "store-uuid-123",
"event": "order.paid",
"order": { ... },
"builtInTransformer": "revel"
}
The order object is validated before dispatch. See Order Payload Schema below for full details.
builtInTransformer is optional and additive. When omitted, the dispatch runs the standard webhook flow (Cata Order JSON + HMAC headers to the registered callback URL). When set to a deployed transformer name (e.g. "revel"), the named per-provider transformer rewrites the body / headers / URL into the POS-specific shape before POSTing. Unknown values return HTTP 400.
Response (200):
{
"code": 200,
"isSuccess": true,
"message": "order dispatched",
"orderId": "generated-uuid",
"dispatched": true
}
If webhook not registered or inactive → still persists the order, returns dispatched: false with reason.
Response (400) — validation error:
{
"code": 400,
"isSuccess": false,
"message": "validation error",
"detail": "order validation failed: items[0].plu is required"
}
Order Payload Schema¶
The order field in the dispatch request must conform to the following structure. The payload is validated but dispatched as-is (original bytes preserved for HMAC signature integrity).
Pricing Conventions¶
- All prices are tax-inclusive — no separate tax fields on items or modifiers
- Discounts are at order level only —
discountTotalrepresents the total discount applied; no per-item discount fields deliveryMethoddoubles as the order type / channel for pricing — determines which price tier was used (PICKUP,DELIVERY,EATIN)
Required Fields¶
| Field | Type | Validation |
|---|---|---|
uuid |
string | Non-empty |
storeUuid |
string | Non-empty |
deliveryMethod |
string | "PICKUP", "DELIVERY", or "EATIN" |
currency |
string | Non-empty (e.g. "AED", "SGD") |
items |
array | At least 1 item |
totalPay |
number | >= 0 |
status |
string | Must be "PAID" |
Optional Fields¶
| Field | Type | Description |
|---|---|---|
orderRefNo |
string | Order reference number |
dailyQueueNo |
string | Daily queue number |
storeName |
string | Store display name |
isPreorder |
boolean | Whether this is a pre-order |
orderSource |
string | "APP", "WEB" |
customer |
object | Customer info (fullName, email, phone, address) |
subtotal |
number | Total before discounts |
discountTotal |
number | Total discount amount |
serviceCharge |
number | Service charge |
deliveryFee |
number | Delivery fee |
deliveryFeeDiscount |
number | Discount on delivery fee |
packagingFee |
number | Packaging fee |
additionalFees |
array | Extra fees (displayName, feeCode, feeAmount) |
expectedPickupAt |
string | ISO 8601 datetime |
tableNo |
string | Table number (for dine-in) |
createdAt |
string | ISO 8601 datetime |
Item Fields¶
| Field | Type | Required | Validation |
|---|---|---|---|
itemUuid |
string | no | |
plu |
string | yes | Non-empty (SKU/itemCode) |
name |
string | no | |
quantity |
integer | yes | > 0 |
itemOnlyPrice |
number | no | Price without modifiers |
modifierOnlyPrice |
number | no | Sum of modifier prices |
itemPrice |
number | yes | >= 0 (item + modifiers per unit) |
itemSubTotal |
number | yes | >= 0 (qty * itemPrice) |
notes |
string | no | |
isBundle |
boolean | no | |
isVariant |
boolean | no | |
modifiers |
array | no | See modifier fields |
subItems |
array | no | Bundle components |
Modifier Fields¶
| Field | Type | Required | Validation |
|---|---|---|---|
modifierHeaderId |
string | no | |
modifierOptionId |
string | yes | Non-empty |
modifierHeaderName |
string | no | |
modifierOptionCode |
string | no | |
modifierOptionName |
string | no | |
price |
number | no | |
tax |
number | no | |
quantity |
integer | yes | > 0 |
Payment Fields¶
Optional — can be omitted for free orders (e.g. 100% discount). If provided, amount and method are required.
| Field | Type | Required | Validation |
|---|---|---|---|
amount |
number | yes (if payment present) | >= 0 |
tip |
number | no | |
method |
string | yes (if payment present) | Non-empty (e.g. "CARD", "CASH", "ONLINE") |
Example Payload¶
{
"outletId": "store-uuid-123",
"event": "order.paid",
"order": {
"uuid": "ord-550e8400-e29b-41d4-a716-446655440000",
"orderRefNo": "ORD-2024-001",
"dailyQueueNo": "A12",
"storeUuid": "store-uuid-123",
"storeName": "Downtown Branch",
"deliveryMethod": "DELIVERY",
"status": "PAID",
"currency": "AED",
"isPreorder": false,
"orderSource": "APP",
"customer": {
"uuid": "cust-uuid-789",
"fullName": "Jane Doe",
"email": "jane@example.com",
"phone": "+971501234567",
"address": "123 Main St, Dubai"
},
"items": [
{
"itemUuid": "item-uuid-001",
"plu": "SKU-BURGER-01",
"name": "Classic Burger",
"quantity": 2,
"itemOnlyPrice": 35.00,
"modifierOnlyPrice": 5.00,
"itemPrice": 40.00,
"itemSubTotal": 80.00,
"notes": "No onions",
"modifiers": [
{
"modifierHeaderId": "mh-cheese",
"modifierOptionId": "mo-extra-cheese",
"modifierHeaderName": "Cheese",
"modifierOptionCode": "EXTRA-CHEESE",
"modifierOptionName": "Extra Cheese",
"price": 5.00,
"quantity": 1
}
]
},
{
"itemUuid": "item-uuid-002",
"plu": "SKU-BUNDLE-01",
"name": "Family Meal Deal",
"quantity": 1,
"itemOnlyPrice": 99.00,
"modifierOnlyPrice": 0,
"itemPrice": 99.00,
"itemSubTotal": 99.00,
"isBundle": true,
"subItems": [
{
"itemUuid": "item-uuid-003",
"itemCode": "SKU-FRIES-01",
"name": "Large Fries",
"surcharge": 0,
"modifiers": []
},
{
"itemUuid": "item-uuid-004",
"itemCode": "SKU-DRINK-01",
"name": "Soft Drink",
"surcharge": 2.00,
"modifiers": [
{
"modifierHeaderId": "mh-size",
"modifierOptionId": "mo-large",
"modifierHeaderName": "Size",
"modifierOptionCode": "LARGE",
"modifierOptionName": "Large",
"price": 2.00,
"quantity": 1
}
]
}
]
}
],
"subtotal": 179.00,
"discountTotal": 10.00,
"serviceCharge": 5.00,
"deliveryFee": 8.00,
"deliveryFeeDiscount": 3.00,
"packagingFee": 2.00,
"totalPay": 181.00,
"payment": {
"amount": 181.00,
"tip": 10.00,
"method": "CARD"
},
"additionalFees": [
{
"displayName": "Platform Fee",
"feeCode": "PLATFORM",
"feeAmount": 1.50
}
],
"tableNo": "",
"createdAt": "2024-12-01T14:30:00Z"
}
}
Persistence¶
All dispatched orders are persisted in the orders table for audit trail and retry:
| Column | Purpose |
|---|---|
uuid |
Order UUID from KDS |
store_id |
Target outlet |
event_type |
e.g. order.paid |
payload |
Full order JSON from KDS |
status |
pending → dispatched or failed |
attempts |
Dispatch attempt count |
last_error |
Error message on failure |
dispatched_at |
When successfully dispatched |
HMAC Signing (no-transformer path)¶
These headers are added by Go after the standard script runs and only when no builtInTransformer was specified on the request. They are NOT added on the transformer path — the transformer is responsible for the POS's native auth (e.g. Revel's API-AUTHENTICATION header).
| Header | Value |
|---|---|
Content-Type |
application/json |
X-Cata-Signature |
sha256=<hex(HMAC-SHA256(secret, body))> |
X-Cata-Event |
order.paid |
X-Cata-Delivery-ID |
<uuid> (idempotency key) |
X-Cata-Timestamp |
<unix timestamp> |
External POS verifies by recomputing HMAC with the secret received during webhook registration.
Retry (V1)¶
No automatic retry in V1. Failed dispatches are persisted with status=failed and last_error. Future enhancements:
POST /api/v1/orders/{orderId}/retry— manual retry- Job queue for automatic retry with backoff
- Webhook delivery log (separate table per attempt)
Inbound: Order Status Update¶
POST /api/v1/orders/status-update¶
Standard API for external systems (or connectors) to update order status in Cata. Partners who follow Cata's spec (e.g. Jamezz) call this directly. Partners with their own format (e.g. Revel) go through a connector (Hookdeck/n8n) that transforms their payload into this standard format.
Jamezz (follows spec) ──────────────────→ POST /api/v1/orders/status-update
Revel (own format) → Hookdeck/n8n ──→ POST /api/v1/orders/status-update
Authentication: OAuth Bearer token or API key (see Authentication).
Request:
{
"orderUuid": "ord-550e8400-e29b-41d4-a716-446655440000",
"outletId": "store-uuid-123",
"status": "ACCEPTED",
"externalOrderId": "revel-order-12345",
"estimatedReadyAt": "2026-04-10T15:00:00Z",
"reason": "",
"updatedAt": "2026-04-10T14:35:00Z"
}
| Field | Type | Required | Description |
|---|---|---|---|
orderUuid |
string | yes | Cata order UUID (from dispatch payload) |
outletId |
string | yes | Outlet UUID the order belongs to |
status |
string | yes | New status (see valid transitions below) |
externalOrderId |
string | no | External POS order reference |
estimatedReadyAt |
string | no | ISO 8601 — estimated ready time |
reason |
string | no | Reason for status change (required for CANCELLED) |
updatedAt |
string | no | ISO 8601 — when the status changed at the source |
Response (200):
{
"code": 200,
"isSuccess": true,
"message": "status updated",
"orderUuid": "ord-550e8400-e29b-41d4-a716-446655440000",
"previousStatus": "PENDING",
"currentStatus": "ACCEPTED"
}
Response (400) — invalid transition:
{
"code": 400,
"isSuccess": false,
"message": "invalid status transition",
"detail": "cannot transition from COMPLETED to ACCEPTED"
}
Response (404) — order not found:
{
"code": 404,
"isSuccess": false,
"message": "order not found"
}
Status Transition Diagram¶
PENDING → ACCEPTED → PREPARING → READY → COMPLETED
any → CANCELLED
| From | Allowed To |
|---|---|
PENDING |
ACCEPTED, CANCELLED |
ACCEPTED |
PREPARING, CANCELLED |
PREPARING |
READY, CANCELLED |
READY |
COMPLETED, CANCELLED |
COMPLETED |
— (terminal) |
CANCELLED |
— (terminal) |
Special case: COMPLETED → COMPLETED = ACK only (no-op). Some POS systems send a late completion update after Cata has already auto-completed.
Order Status Values¶
| Status | Description |
|---|---|
PENDING |
Order dispatched to POS, awaiting acknowledgement |
ACCEPTED |
POS acknowledged and accepted the order |
PREPARING |
Kitchen is preparing the order |
READY |
Order is ready for pickup/delivery |
COMPLETED |
Order fulfilled and completed |
CANCELLED |
Order cancelled (reason required) |
Loyalty API (Proxy to loyalty-service)¶
External systems (Jamezz, Uber, QR apps) call these endpoints to earn or redeem loyalty points. This service authenticates the request and proxies to the internal loyalty-service.
External caller → POS Integration Service (auth + validate) → loyalty-service (points logic)
POST /api/v1/loyalty/earn¶
Grant loyalty points for a completed order.
Authentication: OAuth Bearer token or API key.
Request:
{
"orderUuid": "ord-550e8400-e29b-41d4-a716-446655440000",
"outletId": "store-uuid-123",
"customerUuid": "cust-uuid-789",
"customerPhone": "+971501234567",
"currency": "AED",
"totalAmount": 181.00,
"orderSource": "APP",
"earnedAt": "2026-04-10T14:30:00Z"
}
| Field | Type | Required | Description |
|---|---|---|---|
orderUuid |
string | yes | Order reference |
outletId |
string | yes | Outlet where the order was placed |
customerUuid |
string | conditional | Customer ID (either uuid or phone required) |
customerPhone |
string | conditional | Customer phone (either uuid or phone required) |
currency |
string | yes | Currency code |
totalAmount |
number | yes | Order total (used for points calculation) |
orderSource |
string | no | "APP", "WEB", "QR", "POS" |
earnedAt |
string | no | ISO 8601 — when the order was completed |
Response (200):
{
"code": 200,
"isSuccess": true,
"message": "points earned",
"orderUuid": "ord-550e8400-e29b-41d4-a716-446655440000",
"pointsEarned": 18,
"totalPoints": 245
}
POST /api/v1/loyalty/redeem¶
Redeem loyalty points for a discount.
Authentication: OAuth Bearer token or API key.
Request:
{
"orderUuid": "ord-550e8400-e29b-41d4-a716-446655440000",
"outletId": "store-uuid-123",
"customerUuid": "cust-uuid-789",
"customerPhone": "+971501234567",
"pointsToRedeem": 50,
"currency": "AED"
}
| Field | Type | Required | Description |
|---|---|---|---|
orderUuid |
string | yes | Order reference |
outletId |
string | yes | Outlet where redemption occurs |
customerUuid |
string | conditional | Customer ID (either uuid or phone required) |
customerPhone |
string | conditional | Customer phone (either uuid or phone required) |
pointsToRedeem |
integer | yes | Number of points to redeem |
currency |
string | yes | Currency code |
Response (200):
{
"code": 200,
"isSuccess": true,
"message": "points redeemed",
"orderUuid": "ord-550e8400-e29b-41d4-a716-446655440000",
"pointsRedeemed": 50,
"discountAmount": 5.00,
"remainingPoints": 195
}
Response (400) — insufficient points:
{
"code": 400,
"isSuccess": false,
"message": "insufficient points",
"detail": "customer has 45 points, requested 50"
}
GET /api/v1/loyalty/balance?customerUuid={uuid}¶
Check customer point balance.
Authentication: OAuth Bearer token or API key.
Response (200):
{
"code": 200,
"isSuccess": true,
"customerUuid": "cust-uuid-789",
"totalPoints": 245,
"tier": "GOLD"
}
Authentication¶
All external-facing endpoints (/orders/status-update, /loyalty/*) require authentication.
V1 (April): API Key authentication.
| Header | Description |
|---|---|
X-API-Key |
Partner-specific API key, validated against central.api_keys table |
X-Tenant-ID |
Tenant identifier (from subdomain or header) |
V2 (May): OAuth client_credentials flow added on top.
| Header | Description |
|---|---|
Authorization: Bearer <token> |
Short-lived JWT from POST /oauth/token |
X-API-Key |
Remains as fallback for simple integrations |
Internal service-to-service endpoints (/orders/dispatch) use a separate auth mechanism (shared secret or service mesh).