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 |
|---|---|---|
callbackUrl → Hookdeck 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:
- Headers include
X-Cata-Signature: sha256=<hex>,X-Cata-Event: order.paid,X-Cata-Delivery-ID: <uuid>,X-Cata-Timestamp: <epoch>. - The body matches the schema in §3 — same
uuid, same line items, same totals. - Recompute HMAC-SHA256 over the raw body using your saved secret; the result equals the value after
sha256=in the signature header. - If you returned 2xx, Cata reports
dispatched: true. If you returned 4xx/5xx, Cata reportsdispatched: false, reason: "callback returned HTTP <code>". If Cata couldn't reach you at all (DNS/TLS/connection refused/timeout),reasonis"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 (ACCEPTED → IN PROGRESS → READY → DRIVER PICKED UP → COMPLETED, 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:
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[].modifiersis[]even when there are none (always present, never null).subtotalis the sum ofitemSubTotalacross items, before any discount.totalPay = subtotal + serviceCharge + deliveryFee + packagingFee − discountTotal(free orders returnpaymentwith 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:
itemOnlyPriceexcludes modifiers;itemPriceincludes them. UseitemOnlyPriceif you want to display the base product price separately from modifier upcharges.itemPrice == itemOnlyPrice + modifierOnlyPrice(always; this invariant is set Cata-side).modifierOptionIdis the Cata-side modifier ID. If your POS uses different IDs, map them viasync_productsfirst.
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: truesignals "treat the line as a combo with choice sections insidesubItems[]".sectionItemCode+sectionNamedescribe which choice section each sub-item belongs to. Sub-items with the samesectionItemCodeare merged into one group on the POS side. POS adapters that model combos as nested choice groups (e.g. Revel'sproducts_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
itemOnlyPriceis the combo base price (carried on the parent cart line, not multiplied across sub-items). Each sub-item getssurcharge: 0by default; per-sub-item upcharges go onsurcharge. - 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:
subtotalstays at the pre-discount sum;discountTotalis the amount deducted;totalPayis 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/discountFromPromotionas 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
deliveryFeeandpackagingFeeare convenience fields;additionalFees[]carries the detailed breakdown (each line hasdisplayName+feeCode+feeAmount). additionalFees[]is the canonical list. If your POS surface needs more fee categories than the top-level fields offer, parseadditionalFees[].customer.addressis populated whendeliveryMethod == "DELIVERY"; forPICKUP/EATINit'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:
deliveryMethodaccepts exactly three values:PICKUP/DELIVERY/EATIN. Anything else is rejected upstream by Cata before dispatch reaches your receiver.tableNois set forEATIN, typically empty forPICKUP/DELIVERY. It's a free-form string — your POS gets whatever the operator entered (numeric, alphanumeric, etc.).- No
deliveryFee, nopackagingFee, nocustomer.address—EATINorders 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.