Skip to content

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:

  1. Looks up an active webhook for (store, event) (used by the standard script to fill webhook.callbackUrl).
  2. Runs the standard script (@provider _standard, @topic order_dispatch) — falls back to a hardcoded equivalent when the row is absent in centraldb.adapter_scripts (fresh install).
  3. If builtInTransformer was supplied on the request, looks up (builtInTransformer, order_dispatch) in centraldb.adapter_scripts. Unknown name → returns ErrUnknownTransformer → controller maps to HTTP 400.
  4. Loads outlet_providers.settings for the outlet (only when a transformer is going to run) and feeds it into input.outlet.settings so the transformer can read credentials.
  5. 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-Timestamp over 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 onlydiscountTotal represents the total discount applied; no per-item discount fields
  • deliveryMethod doubles 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 pendingdispatched 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).