Skip to content

Order Dispatch — Vendor Integration Walkthrough

End-to-end walkthrough for vendors integrating Cata as a delivery / channel source. This page covers the standard webhook path. If you're new to order dispatch, read How Order Dispatch Works first — it explains the two paths (webhook vs Cata-built transformer) and helps you pick.

There are two integration shapes — pick whichever matches your situation:

  • Webhook receiver — your POS exposes an HTTPS endpoint; Cata POSTs the order JSON there with an HMAC signature. You verify the signature and create the order in your system. Most partners use this. Covered fully below.
  • Cata-built POS adapter — for POS systems we've already integrated (e.g. Revel), the restaurant operator just enters their POS credentials in Cata's admin. No webhook implementation needed; Cata calls your POS's existing API directly. See Built-In Transformer Walkthrough.

How it works

High-level flow

flowchart LR
    User([👤 Customer])
    App[Cata App]
    Backend[Cata Backend]
    PIS[POS Integration Service]
    POS[(Vendor POS)]

    User -->|orders & pays| App
    App -->|order paid| Backend
    Backend -->|dispatch| PIS
    PIS -->|"POST + HMAC"| POS
    POS -->|"200 OK"| PIS

Sequence — one paid order, end to end

sequenceDiagram
    autonumber
    participant U as 👤 Customer
    participant Cata as Cata Backend
    participant PIS as POS Integration Service
    participant V as Vendor Webhook

    U->>Cata: pay
    Cata->>PIS: POST /api/v1/orders/dispatch (Cata order JSON)
    PIS->>PIS: persist + look up your webhook
    PIS->>V: POST {your callbackUrl} <br/>X-Cata-Signature: sha256=… <br/>X-Cata-Event: order.paid <br/>X-Cata-Delivery-ID: …
    V->>V: verify HMAC, create order in POS
    V-->>PIS: 200 OK { externalOrderId? }
    PIS-->>Cata: { dispatched: true }
    Cata-->>U: order confirmed

A 2xx response from your endpoint tells Cata the order was accepted. Anything non-2xx marks the dispatch as failed (today there is no automatic retry — that is on the V2 roadmap).


Setup checklist for a vendor

Before any orders flow, the following needs to be in place. Most are one-time per integration.

Step Done by Where
Outlet registered in Cata Cata onboarding / vendor admin POST /api/v1/outlets/sync
Vendor webhook URL registered against the outlet Vendor (you) POST /api/v1/webhooks/register (returns secret once — store it)
HMAC verification implemented in your receiver Vendor (you) Your codebase
Smoke test through Hookdeck or similar Vendor (you) + Cata This page §6

The rest of this doc walks each step. Replace {tenant} with your tenant subdomain and $KEY with the partner API key issued to you.

export BASE="https://{tenant}.sgp1.samba-technologies.xyz/service/pos-integration"
export KEY="<your-partner-api-key>"

1. Confirm the outlet exists

Every dispatch is scoped to an outlet (a single store/branch). The outlet is typically registered by the Cata onboarding team or by your own POS as part of a sync. List what already exists for your tenant:

curl -sS "$BASE/api/v1/outlets" -H "X-Api-Key: $KEY" | jq '.stores[] | {uuid, name}'

The response is shaped { "stores": [...], "total": N }; each item carries uuid and name. (For the richer outlet record — externalId, address, status, timezone, etc. — fetch a single outlet via GET /api/v1/outlets/{outletId}.)

Pick the outlet you want to receive orders for. Save the UUID:

export OUTLET="<outlet-uuid>"

If the outlet is missing, ask your Cata account manager to register it (or sync via POST /api/v1/outlets/sync from your POS if you also implement product sync).


2. Register your webhook

Tell Cata which URL to POST orders to, and which events you want to receive. This is a per-outlet registration — repeat it for each outlet you operate, or scope a single webhook URL to many outlets by registering it once per outlet UUID.

2.1 — Decide three things first

What Example
provider A short slug identifying your integration (Cata uses it to scope the webhook). One provider value per outlet — re-registering with the same provider for the same outlet replaces the existing record. "your-pos", "jamezz", "acme-kiosk"
callbackUrl The HTTPS endpoint on your side that will receive the order POST. Must be reachable from the public internet (not localhost). For local dev use a tunnel like Hookdeck or ngrok. https://api.your-pos.com/webhooks/cata/orders
events Which events you want delivered. Today only order.paid is supported — additional events (e.g. order.cancelled) will be added without breaking this contract. ["order.paid"]

You'll also need: - The outlet UUID from §1 ($OUTLET). - Your partner API key ($KEY). - A way to copy and securely store one-time secrets (the response includes a secret shown only at registration).

2.2 — Send the registration request

curl -sS -X POST "$BASE/api/v1/webhooks/register" \
  -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
  -d "{
    \"outletId\": \"$OUTLET\",
    \"provider\": \"your-pos\",
    \"callbackUrl\": \"https://your-pos.example.com/webhooks/cata/orders\",
    \"events\": [\"order.paid\"]
  }" | jq .

2.3 — Read the response and save the secret

{
  "code": 201,
  "isSuccess": true,
  "message": "webhook registered",
  "webhook": {
    "id": 12,
    "outletId": "...",
    "provider": "your-pos",
    "callbackUrl": "https://your-pos.example.com/webhooks/cata/orders",
    "events": ["order.paid"],
    "secret": "whsec_AbCd1234EfGh5678...",
    "isActive": true
  }
}
Field What to do with it
id Note it — you'll use it for lifecycle calls (update, delete, rotate-secret)
secret Save it immediately into your secret store (env var, Vault, etc.) — it is shown only this once and you'll need it for HMAC verification on every incoming dispatch (§4)
isActive Should be true. If false, contact Cata — your tenant or outlet might be in a partial state

Lose the secret = re-register

The secret is displayed only at registration time and is not retrievable afterwards. If you lose it, you have two options: rotate (§2.5) or delete and re-register. Plan your secret-storage workflow before sending the registration request.

2.4 — Verify the registration

curl -sS "$BASE/api/v1/webhooks" -H "X-Api-Key: $KEY" | jq '.webhooks[] | select(.outletId=="'"$OUTLET"'")'

Should show exactly the row you just registered. The secret field is intentionally redacted on listings — only the original 201 response carries it.

2.5 — Webhook lifecycle (list / get / update / rotate / delete)

Once you have the webhookId from §2.3, all operations are scoped by ID:

export WEBHOOK_ID=12

# Get a single webhook (secret redacted)
curl -sS "$BASE/api/v1/webhooks/$WEBHOOK_ID" -H "X-Api-Key: $KEY" | jq .

# Update callback URL or events list (secret stays the same)
curl -sS -X PUT "$BASE/api/v1/webhooks/$WEBHOOK_ID" \
  -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
  -d '{
    "callbackUrl": "https://api.your-pos.com/v2/webhooks/cata/orders",
    "events": ["order.paid"],
    "isActive": true
  }' | jq .

# Rotate the signing secret — invalidates the old one immediately
# Response includes the NEW secret once. Save it before you ack the response.
curl -sS -X POST "$BASE/api/v1/webhooks/$WEBHOOK_ID/rotate-secret" -H "X-Api-Key: $KEY" | jq .

# Soft-delete the registration — Cata stops dispatching to this URL
curl -sS -X DELETE "$BASE/api/v1/webhooks/$WEBHOOK_ID" -H "X-Api-Key: $KEY" | jq .

After a delete, dispatches for the outlet fall back to whatever else is configured (POS adapter if any, otherwise dispatched: false).

2.6 — Common registration errors

HTTP Body What's wrong
400 "outletId is required" / "provider is required" / "callbackUrl is required" / "events is required" One of the four required fields is missing — check the request body
400 "unsupported event: <name>" An event in your events array isn't on the allow-list. Today only order.paid is supported
400 "outlet not found" outletId doesn't match any outlet in your tenant — check §1
401 "missing api key" / "invalid api key" API key issue — verify with GET /api/v1/auth/whoami

3. The dispatch payload

When a customer pays, Cata POSTs the order to your callbackUrl. Headers:

Header Example Meaning
Content-Type application/json Always JSON
X-Cata-Event order.paid Which event fired
X-Cata-Delivery-ID c2a31e9b-... Unique per delivery — use for idempotency on your side
X-Cata-Timestamp 1761581000 Unix epoch seconds at dispatch time
X-Cata-Signature sha256=8c7e... HMAC-SHA256 of the raw body, hex-encoded, prefixed sha256=

Body — Cata standard order shape:

{
  "uuid": "ord-abc-123",
  "orderRefNo": "ORD-2026-0042",
  "dailyQueueNo": "T07",
  "storeUuid": "...",
  "storeName": "Downtown Cafe",
  "deliveryMethod": "PICKUP",
  "status": "PAID",
  "currency": "SGD",
  "isPreorder": false,
  "items": [
    {
      "plu": "BURGER-01",
      "name": "Classic Burger",
      "quantity": 2,
      "itemOnlyPrice": 12.50,
      "modifierOnlyPrice": 2.00,
      "itemPrice": 14.50,
      "itemSubTotal": 29.00,
      "modifiers": [
        {
          "modifierHeaderId": "MH-CHEESE",
          "modifierOptionId": "MO-EXTRA",
          "modifierHeaderName": "Cheese",
          "modifierOptionName": "Extra Cheese",
          "price": 2.00,
          "quantity": 1
        }
      ]
    }
  ],
  "subtotal": 29.00,
  "discountTotal": 0,
  "serviceCharge": 2.90,
  "deliveryFee": 0,
  "totalPay": 31.90,
  "payment": {
    "amount": 31.90,
    "tip": 0,
    "method": "CARD",
    "reference": "ORD-2026-0042"
  },
  "createdAt": "2026-04-27T15:00:26Z"
}

payment is optional — orders that are 100% discounted have totalPay: 0 and no payment block.


4. Verify the HMAC signature

The signature lives in X-Cata-Signature and is computed as:

HMAC-SHA256(secret = <your webhook secret>, payload = <raw request body>)

…hex-encoded and prefixed with sha256=. Compute the same on your side over the raw, unmodified body and compare with constant-time equality. Always reject the request if the signatures do not match — never trust the body otherwise.

Reference implementations

import crypto from 'crypto';
import express from 'express';

const app = express();
app.use('/webhooks/cata/orders', express.raw({ type: '*/*' })); // we need raw bytes

app.post('/webhooks/cata/orders', (req, res) => {
  const sig = req.header('X-Cata-Signature') || '';
  const expected = 'sha256=' +
    crypto.createHmac('sha256', process.env.CATA_WEBHOOK_SECRET)
          .update(req.body)
          .digest('hex');

  // crypto.timingSafeEqual throws RangeError on different lengths,
  // so length-check first to keep a malformed signature at 401, not 500.
  const sigBuf = Buffer.from(sig);
  const expectedBuf = Buffer.from(expected);
  if (sigBuf.length !== expectedBuf.length ||
      !crypto.timingSafeEqual(sigBuf, expectedBuf)) {
    return res.sendStatus(401);
  }

  const order = JSON.parse(req.body.toString('utf8'));
  // process order, idempotent on req.header('X-Cata-Delivery-ID')
  return res.status(200).json({ externalOrderId: 'pos-12345' });
});
import hmac, hashlib, os
from flask import Flask, request, abort, jsonify

app = Flask(__name__)

@app.post('/webhooks/cata/orders')
def cata_orders():
    body = request.get_data()  # raw bytes
    sig = request.headers.get('X-Cata-Signature', '')
    expected = 'sha256=' + hmac.new(
        os.environ['CATA_WEBHOOK_SECRET'].encode(),
        body, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(sig, expected):
        abort(401)

    order = request.get_json()
    # process order, idempotent on X-Cata-Delivery-ID
    return jsonify(externalOrderId='pos-12345'), 200
func handler(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    mac := hmac.New(sha256.New, []byte(os.Getenv("CATA_WEBHOOK_SECRET")))
    mac.Write(body)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    if !hmac.Equal([]byte(r.Header.Get("X-Cata-Signature")), []byte(expected)) {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }
    // unmarshal body, process, idempotent on X-Cata-Delivery-ID
    w.WriteHeader(200)
    _, _ = w.Write([]byte(`{"externalOrderId":"pos-12345"}`))
}

Returning an externalOrderId

Optional but useful — return a JSON body with externalOrderId (your POS's order reference) in the 200 response and Cata will store it on the order for later reconciliation:

{ "externalOrderId": "pos-12345" }

5. Idempotency, retries, and timeouts

Topic Today Notes
Idempotency Use X-Cata-Delivery-ID as your dedup key Cata sends the same delivery ID at most once today; once retries land it could repeat
Retries None. A non-2xx response marks the order failed Retry / DLQ on the V2 roadmap
Timeout 30 s on the dispatch HTTP call Respond fast — async work after the 200 if needed
Signature replay protection X-Cata-Timestamp provided Reject deliveries older than your accepted skew (e.g. 5 min) for stronger replay protection

Stay below 30 seconds end-to-end on your endpoint. If your POS is slow to acknowledge, accept the order, return 200, and reconcile asynchronously on your side.


6. Simulate and trigger an order.paid

You don't need to wait for a real customer to pay in the Cata App to test your integration. The dispatch endpoint accepts your partner API key, so you can fire a synthetic order.paid against your own outlet and watch it land at your registered webhook.

6.1 — Wire your callbackUrl for inspection

Three setups, in increasing order of "looks like real production":

Setup What you see When to use
callbackUrlHookdeck source Captured request inspected in the Hookdeck UI; no code on your side Earliest probe — confirms the dispatch reaches you and the body shape
callbackUrl → Hookdeck → local dev (hookdeck listen 9090) Same capture in Hookdeck UI AND the request hits your localhost so your code runs Iteratively building the receiver
callbackUrl → your real staging endpoint Real signature verification, real downstream POS calls Final pre-production check

For all three, register the webhook (§2) with the appropriate callbackUrl, then trigger:

6.2 — Trigger a synthetic order.paid

Save the following as simulate-order-paid.sh and adjust the values to your test data:

#!/usr/bin/env bash
set -euo pipefail

: "${BASE:?export BASE=https://{tenant}.sgp1.samba-technologies.xyz/service/pos-integration}"
: "${KEY:?export KEY=<your partner API key>}"
: "${OUTLET:?export OUTLET=<outlet UUID>}"

REF="ord-test-$(date +%s)-$RANDOM"
NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"

curl -i -X POST "$BASE/api/v1/orders/dispatch" \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: $KEY" \
  -d "$(cat <<EOF
{
  "outletId": "$OUTLET",
  "event": "order.paid",
  "order": {
    "uuid": "$REF",
    "orderRefNo": "$REF",
    "dailyQueueNo": "T01",
    "storeUuid": "$OUTLET",
    "storeName": "Test Outlet",
    "deliveryMethod": "PICKUP",
    "status": "PAID",
    "currency": "SGD",
    "isPreorder": false,
    "items": [
      {
        "plu": "BURGER-01",
        "name": "Classic Burger",
        "quantity": 2,
        "itemOnlyPrice": 12.50,
        "modifierOnlyPrice": 2.00,
        "itemPrice": 14.50,
        "itemSubTotal": 29.00,
        "modifiers": [
          {
            "modifierHeaderId": "MH-CHEESE",
            "modifierOptionId": "MO-EXTRA",
            "modifierHeaderName": "Cheese",
            "modifierOptionName": "Extra Cheese",
            "price": 2.00,
            "quantity": 1
          }
        ]
      }
    ],
    "subtotal": 29.00,
    "discountTotal": 0,
    "serviceCharge": 2.90,
    "deliveryFee": 0,
    "totalPay": 31.90,
    "payment": {
      "amount": 31.90,
      "tip": 0,
      "method": "CARD",
      "reference": "$REF"
    },
    "createdAt": "$NOW"
  }
}
EOF
)"

Each invocation generates a fresh uuid and orderRefNo so you don't accidentally collide with previous test dispatches.

6.3 — Expected response from the dispatch endpoint

{
  "code": 200,
  "isSuccess": true,
  "message": "order dispatched",
  "orderId": "<server-generated-uuid>",
  "dispatched": true
}

dispatched: true confirms Cata routed the request to your webhook AND got 2xx back. If you see dispatched: false, see the troubleshooting matrix below.

6.4 — Expected behaviour at your webhook

A POST should arrive at your callbackUrl within seconds, with the headers and body documented in §3. Verify, in order:

  1. Headers include X-Cata-Signature: sha256=<hex>, X-Cata-Event: order.paid, X-Cata-Delivery-ID: <uuid>, X-Cata-Timestamp: <epoch>.
  2. The body matches the schema in §3 — same uuid, same line items, same totals.
  3. Recompute HMAC-SHA256 over the raw body using your saved secret; the result equals the value after sha256= in the signature header.
  4. If you returned 2xx, Cata reports dispatched: true. If you returned 4xx/5xx, Cata reports dispatched: false, reason: "callback returned HTTP <code>". If Cata couldn't reach you at all (DNS/TLS/connection refused/timeout), reason is "http request failed: <Go transport error>" instead.

6.5 — Troubleshooting

The reason strings below come straight from internal/connectors/dispatch_pipeline.go and internal/service/order_dispatch_service.go — match yours exactly.

Dispatch response What it means What to check
{ dispatched: true } Cata received HTTP 2xx from your endpoint within 30 s. Order marked dispatched. If your downstream POS work hasn't actually happened yet, you may have ack'd before doing the work — that's fine and recommended for latency, just make sure your background job actually runs.
{ dispatched: false, reason: "no dispatch route configured for this outlet/provider" } No webhook is registered for (outletId, event) AND no provider adapter is deployed. The order was persisted in pending status but no delivery happened. Re-run §2.4 to confirm a webhook row exists for this outlet; check that outletId matches; check events includes order.paid.
{ dispatched: false, reason: "callback returned HTTP 4xx" } or "...HTTP 5xx" Cata reached your endpoint and got a non-2xx status back. The order is marked failed. No automatic retry today — you have one shot. Check your endpoint logs for the failed request (the X-Cata-Delivery-ID header is unique per attempt). Common causes: HMAC verification rejection (you should return 401), input validation failure (return 400), unhandled exception (return 5xx).
{ dispatched: false, reason: "http request failed: ..." } (Go transport error string) Cata couldn't even open a connection to your callbackUrl. Anything from DNS-resolution failure, TLS handshake failure, connection refused, to timeout (>30 s for the whole exchange) lands here. Test callbackUrl from a public host with curl -i. If TLS fails check certificate validity. If the response is slow, tighten the handler — accept fast (return 200 within seconds) and do downstream POS work asynchronously.
401 missing api key from /orders/dispatch itself The simulator's X-Api-Key is missing/invalid — never reached the routing layer. curl "$BASE/api/v1/auth/whoami" -H "X-Api-Key: $KEY" should return 200. If 401, your key is bad or expired.

7. Push order status updates back to Cata

Once your POS has the order, push fulfilment progress (ACCEPTEDIN PROGRESSREADYDRIVER PICKED UPCOMPLETED, plus CANCELLED along the way) back to Cata so the customer sees real-time status in the Cata App.

Endpoint: POST $BASE/api/v1/orders/{orderId}/status where {orderId} is the order UUID Cata returned to you in step 6's dispatch response.

Detailed walkthrough — including the full status transition diagram, allowed statuses, idempotency + concurrency semantics, and the error matrix — lives in its own page so it stays focused:

Order Status Updates

Alternative — Cata-built POS transformer (no webhook receiver needed)

For POS systems where Cata builds the integration in-house (Revel is the current worked example; Lightspeed and Atlas Kitchen on the roadmap), the partner doesn't host a webhook at all. The internal caller passes builtInTransformer: "<provider>" on the dispatch request, and a Cata-maintained JS script rewrites the body / headers / URL into the POS's native shape before POSTing.

Existing partners who don't pass builtInTransformer are unaffected — they keep getting the standard webhook flow with the unchanged Cata Order JSON + HMAC headers.

Built-In Transformer walkthrough — when to use it, deploying a transformer, configuring outlet_providers.settings, dispatching, and pointing at real POS sandboxes.


Appendix — Order payload examples

Seven representative order payload shapes covering the common scenarios (one per typical fixture in the QA test harness). The exact same JSONs are used for Cata's smoke / QA tests against real POS sandboxes — source-of-truth lives in script/demo/dispatch-test-cases/ so they don't drift from production reality. Mirror what you see here when writing parsers and fixtures.

Forward-compat tip. The schema is additive — new optional fields may appear over time, but existing fields won't be renamed or removed. Treat unknown fields as informational and ignore them.

1. Simple product (no modifiers, no fees, no discount)

The minimum payload: one item, single line, paid in full.

{
  "uuid":           "ord-...",
  "orderRefNo":     "ORD-...",
  "deliveryMethod": "PICKUP",
  "status":         "PAID",
  "currency":       "SGD",
  "isPreorder":     false,
  "items": [{
    "plu":               "1974",
    "name":              "SmallFries1",
    "quantity":          1,
    "itemOnlyPrice":     3.00,
    "modifierOnlyPrice": 0,
    "itemPrice":         3.00,
    "itemSubTotal":      3.00,
    "modifiers": []
  }],
  "subtotal":      3.00,
  "discountTotal": 0,
  "serviceCharge": 0.30,
  "deliveryFee":   0,
  "totalPay":      3.30,
  "payment": { "amount": 3.30, "tip": 0, "method": "CARD", "reference": "ORD-..." },
  "customer": { "fullName": "Jane Doe", "email": "jane@example.com", "phone": "+6591234567" }
}

Notable:

  • items[].modifiers is [] even when there are none (always present, never null).
  • subtotal is the sum of itemSubTotal across items, before any discount.
  • totalPay = subtotal + serviceCharge + deliveryFee + packagingFee − discountTotal (free orders return payment with amount 0).

2. Product with modifiers

Modifiers ride inside each item. The pricing convention is critical:

{
  "items": [{
    "plu":               "1974",
    "name":              "SmallFries1",
    "quantity":          1,
    "itemOnlyPrice":     3.00,
    "modifierOnlyPrice": 1.00,
    "itemPrice":         4.00,
    "itemSubTotal":      4.00,
    "modifiers": [{
      "modifierHeaderId":   "1",
      "modifierOptionId":   "678",
      "modifierHeaderName": "Sauce",
      "modifierOptionName": "Extra Ketchup",
      "modifierOptionCode": "MOD-KETCHUP",
      "price":              1.00,
      "quantity":           1
    }]
  }],
  "...": "..."
}

Notable:

  • itemOnlyPrice excludes modifiers; itemPrice includes them. Use itemOnlyPrice if you want to display the base product price separately from modifier upcharges.
  • itemPrice == itemOnlyPrice + modifierOnlyPrice (always; this invariant is set Cata-side).
  • modifierOptionId is the Cata-side modifier ID. If your POS uses different IDs, map them via sync_products first.

3. Bundle (combo / meal deal)

A bundle is one cart line whose components are grouped into choice sections (e.g. "pick a taco", "pick a drink"). Each sub-item names its section via sectionItemCode (legacy "CPS_<int>" format) and sectionName:

{
  "items": [{
    "plu":           "8315",
    "name":          "Combo Meal",
    "quantity":      1,
    "itemOnlyPrice": 27.00,
    "itemPrice":     27.00,
    "itemSubTotal":  27.00,
    "isBundle":      true,
    "subItems": [
      { "itemUuid": "sub-1", "itemCode": "8295",
        "sectionItemCode": "CPS_284", "sectionName": "Soft Taco",
        "name": "Soft Taco — choice 1", "modifiers": [] },
      { "itemUuid": "sub-2", "itemCode": "8294",
        "sectionItemCode": "CPS_284", "sectionName": "Soft Taco",
        "name": "Soft Taco — choice 2", "modifiers": [] },
      { "itemUuid": "sub-3", "itemCode": "8288",
        "sectionItemCode": "CPS_283", "sectionName": "Hard Taco",
        "name": "Hard Taco", "modifiers": [{ "modifierOptionId": "2424", "price": 0, "quantity": 1, "...": "..." }] }
    ],
    "modifiers": []
  }],
  "...": "..."
}

Notable:

  • isBundle: true signals "treat the line as a combo with choice sections inside subItems[]".
  • sectionItemCode + sectionName describe which choice section each sub-item belongs to. Sub-items with the same sectionItemCode are merged into one group on the POS side. POS adapters that model combos as nested choice groups (e.g. Revel's products_sets[]) parse the integer suffix of "CPS_<int>" for the wire-side group ID. Both fields are optional — bundles without section metadata fall into a single default group.
  • Bundle's itemOnlyPrice is the combo base price (carried on the parent cart line, not multiplied across sub-items). Each sub-item gets surcharge: 0 by default; per-sub-item upcharges go on surcharge.
  • Each sub-item can carry its own modifiers[] independently of the parent.

4. Discount (promotion code)

{
  "items": [{ "plu": "1974", "name": "SmallFries1", "quantity": 1,
              "itemOnlyPrice": 3.00, "itemPrice": 3.00, "itemSubTotal": 3.00, "modifiers": [] }],
  "subtotal":      3.00,
  "discountTotal": 1.00,
  "serviceCharge": 0.30,
  "deliveryFee":   0,
  "totalPay":      2.30,
  "...": "..."
}

Notable:

  • subtotal stays at the pre-discount sum; discountTotal is the amount deducted; totalPay is the final figure.
  • The discount itself is order-level, not per-item — there are no per-item discount fields.

5. Discount (loyalty points redemption)

{
  "items": [{ "plu": "1974", "name": "SmallFries1", "quantity": 1,
              "itemOnlyPrice": 3.00, "itemPrice": 3.00, "itemSubTotal": 3.00, "modifiers": [] }],
  "subtotal":      3.00,
  "discountTotal": 0.50,
  "serviceCharge": 0.30,
  "totalPay":      2.80,
  "...": "..."
}

Notable:

  • Wire shape is identical to case 4. Today, Cata Order JSON does not distinguish between promotion-code and points-redemption discounts — both flow into discountTotal.
  • If your POS reconciles loyalty points separately and you need them split, contact Cata. We can extend the schema additively (e.g. discountFromPoints / discountFromPromotion as new optional fields) without breaking existing parsers.

6. Fees (packaging + delivery)

Delivery orders typically carry packaging and delivery fees; the customer's address is set:

{
  "deliveryMethod": "DELIVERY",
  "items": [{ "plu": "1974", "name": "SmallFries1", "quantity": 1,
              "itemOnlyPrice": 4.00, "itemPrice": 4.00, "itemSubTotal": 4.00, "modifiers": [] }],
  "subtotal":     4.00,
  "deliveryFee":  1.00,
  "packagingFee": 0.50,
  "totalPay":     5.50,
  "additionalFees": [
    { "displayName": "Packaging Fee", "feeCode": "PACKAGING_FEE", "feeAmount": 0.50 },
    { "displayName": "Delivery Fee",  "feeCode": "DELIVERY_FEE",  "feeAmount": 1.00 }
  ],
  "customer": {
    "fullName": "Jane Doe",
    "email":    "jane@example.com",
    "phone":    "+6591234567",
    "address":  "123 Test St, Singapore 012345"
  },
  "...": "..."
}

Notable:

  • Top-level deliveryFee and packagingFee are convenience fields; additionalFees[] carries the detailed breakdown (each line has displayName + feeCode + feeAmount).
  • additionalFees[] is the canonical list. If your POS surface needs more fee categories than the top-level fields offer, parse additionalFees[].
  • customer.address is populated when deliveryMethod == "DELIVERY"; for PICKUP / EATIN it's typically absent or empty.

7. EATIN — dine-in order

Same shape as case 1, but the customer is dining in. tableNo is populated; no delivery fields apply.

{
  "deliveryMethod": "EATIN",
  "tableNo":        "T07",
  "items": [{ "plu": "1974", "name": "SmallFries1", "quantity": 1,
              "itemOnlyPrice": 3.00, "itemPrice": 3.00, "itemSubTotal": 3.00, "modifiers": [] }],
  "subtotal":      3.00,
  "discountTotal": 0,
  "serviceCharge": 0.30,
  "deliveryFee":   0,
  "totalPay":      3.30,
  "...": "..."
}

Notable:

  • deliveryMethod accepts exactly three values: PICKUP / DELIVERY / EATIN. Anything else is rejected upstream by Cata before dispatch reaches your receiver.
  • tableNo is set for EATIN, typically empty for PICKUP / DELIVERY. It's a free-form string — your POS gets whatever the operator entered (numeric, alphanumeric, etc.).
  • No deliveryFee, no packagingFee, no customer.addressEATIN orders skip those entirely.

See also

  • Full API reference for partner-callable endpoints: https://apidocs.cata.sg/
  • HMAC verification details and webhook lifecycle (rotate secret, disable, list): see the Partner H2H tag in the API reference.