Order Status Updates — Built-In Transformer (Cata-built POS adapter)¶
If you're new to order status updates, read Order Status Updates first — it covers the plain Cata-shape flow that any partner who can conform to the Cata contract should use. This page is the hands-on walkthrough for the built-in transformer path — for vendors that send their own native webhook shape and don't (or won't) reshape it for us.
When a POS vendor's webhook payload is fixed in their format and they won't transform on their side, Cata transforms in-house: a per-provider JS transformer rewrites the vendor's native webhook payload into the Cata-standard status-update shape, and the same OrderStatusService backend applies the transition. From the vendor's perspective they're hitting a per-provider URL with their native body; from our side, the standard state-machine, idempotency, and optimistic-concurrency guarantees are inherited unchanged.
POST /api/v1/inbound/{provider}/order-status // ← per-provider route
X-{Provider}-Event-Type: <event name>
X-{Provider}-Signature: <provider HMAC>
X-{Provider}-...: any other headers the provider's webhook scheme requires
<vendor-native body>
When to use it¶
| Situation | Path |
|---|---|
| Partner conforms to Cata's standard status contract | Plain — POST /api/v1/orders/{orderId}/status with Cata-shape body. See Order Status Updates. |
POS-native webhook shape that Cata can't ask the vendor to change (e.g. Revel's order.finalized payload) |
Built-in transformer — Cata writes the JS, vendor sends their native shape unchanged. This page. |
Path 1 is the partner-facing API for any vendor who chooses to integrate; Path 2 is for the long tail of POS systems whose webhook formats are non-negotiable. The two paths funnel into the same backend service — OrderStatusService.UpdateStatus — so semantics (state machine, idempotency, transitions) are identical regardless of which entry point the request takes.
What lives where¶
| Piece | Where | Purpose |
|---|---|---|
| Per-provider JS transformer | central.adapter_scripts row (<provider>, order_status_update) |
Reads the vendor-native payload from input.payload, returns either { status, posOrderId, timestamp } (apply transition) or { skip: true, reason } (silent ack — not a CATA order). |
| Per-outlet credentials & settings | tenantdb.outlet_providers.settings (JSON column) |
Webhook secret, dining-option IDs, and any other provider-specific config the JS reads. |
| HMAC verification | Per-provider Go middleware (e.g. internal/middleware/revel_hmac.go) |
Different providers use different HMAC algorithms and header names; each provider gets its own middleware that reads its secret from outlet_providers.settings.webhookSecret and verifies signatures. |
| Order lookup | pos_intgr_orders.(external_id, provider) composite index |
The vendor's webhook carries their POS-side order ID; the transformer returns it as posOrderId, the receiver resolves it to a Cata orderId via this index. |
| Status transition | OrderStatusService.UpdateStatus (shared with Path 1) |
State-machine validation, partner-pushable filter, optimistic concurrency, idempotency — all inherited unchanged from the plain path. |
Provider status¶
| Provider | Status | Script | Webhook onboarding |
|---|---|---|---|
| Revel | 🟢 Sandbox-validated (current v1.0.0) — order.finalized → COMPLETED, dining-option filter for non-CATA orders |
scripts/adapters/revel/order_status_update.js |
Email Revel support to register the webhook URL + secret for order.finalized events. Revel doesn't expose a self-service admin for this. |
| Atlas Kitchen | To do | — | TBD |
| Lightspeed | To do | — | TBD |
| Deliverect | N/A — Deliverect partners conform to the Cata standard webhook contract (Path 1) | — | — |
The rest of this page walks through Revel as the worked example.
Walkthrough — using the Revel built-in transformer¶
Step 1. Confirm the script is deployed¶
The transformer script lives in central.adapter_scripts keyed by (provider, topic). Deploy from source:
go run cmd/deploy-scripts/main.go --dry-run # what would happen
go run cmd/deploy-scripts/main.go # write to DB (UPSERT keyed by provider+topic)
You should see at least:
revel / order_status_update v1.0.0 (active)
Without that row, requests to /api/v1/inbound/revel/order-status return HTTP 500 (no active script).
Step 2. Configure outlet provider settings¶
The Revel HMAC middleware reads webhookSecret from outlet_providers.settings to verify signatures, and the JS script reads the dining-option IDs to filter non-CATA orders. Both keys live on the same outlet-provider record that order_dispatch already uses.
-- Add these settings to the outlet_provider row for the outlet you're enabling.
-- Keys NOT to touch: apiKey, apiSecret, baseUrl, establishmentId — those drive
-- order_dispatch (Path 2 outbound) and are managed via PUT /outlet-providers/{outletId}.
UPDATE tenantdb.outlet_providers
SET settings = JSON_SET(
COALESCE(settings, JSON_OBJECT()),
'$.webhookSecret', '<random-secret-you-generate>',
'$.pickupDiningOption', <numeric Revel dining-option ID for PICKUP>,
'$.deliveryDiningOption', <numeric Revel dining-option ID for DELIVERY>,
'$.eatinDiningOption', <numeric Revel dining-option ID for EAT-IN>
)
WHERE store_id = (SELECT id FROM tenantdb.stores WHERE uuid = '<YOUR-OUTLET-UUID>')
AND provider_slug = 'revel';
Notes:
webhookSecretis the HMAC-SHA1 key. Both Cata and Revel must agree on this value byte-for-byte; you set it here, then send the same value in your email request to Revel (step 4).- Dining-option IDs are required so the JS can identify CATA orders vs in-store orders. Revel fires
order.finalizedfor every closed order at the establishment, including in-store dine-in orders that never went through CATA. The script uses these IDs to short-circuit non-CATA orders with a{skip: true}sentinel — the receiver acks them with200 OKand does no lookup. (Note: even without the filter, lookup misses would still return200 OKrather than404— vendors don't retry on4xx, so the design avoids error noise either way. The filter is about keeping log volume sane and not querying for orders we'll never find.) At least one of the three keys must be set. - 401 responses on inbound webhooks usually mean either the secret doesn't match or the outlet isn't mapped to
provider_slug='revel'. The middleware logs the distinction (auth: secret not configuredvsauth: signature mismatch) but presents both as 401 externally.
Step 3. Verify stores.external_id matches what Revel will send¶
Revel sends the establishment ID in the X-Revel-Establishment-Id header. The receiver resolves the outlet via:
SELECT s.id, s.uuid
FROM tenantdb.stores s
JOIN tenantdb.outlet_providers op ON op.store_id = s.id
WHERE s.external_id = ? -- from the inbound header
AND op.provider_slug = ? -- "revel"
So stores.external_id must equal the numeric establishment ID that Revel will send. Verify:
SELECT id, uuid, external_id, name
FROM tenantdb.stores
WHERE uuid = '<YOUR-OUTLET-UUID>';
If external_id doesn't match the Revel establishment ID, set it:
UPDATE tenantdb.stores
SET external_id = '<numeric Revel establishment id>'
WHERE uuid = '<YOUR-OUTLET-UUID>';
Schema rough-edge.
stores.external_idwas originally a Cata-internal location code in some tenants. It's overloaded today as both that AND the POS establishment ID. If a tenant has both meanings on the same value (e.g. they synced from another system first), this needs alignment before Revel webhooks will route correctly. Tracked separately as a follow-up.
Step 4. Register the webhook URL with Revel (email-based)¶
Revel doesn't expose a self-service webhook admin UI. You email Revel support with:
| Field | Value |
|---|---|
| Webhook event | order.finalized |
| Callback URL | https://<tenant>.sgp.samba-technologies.xyz/service/pos-integration/api/v1/inbound/revel/order-status |
| HMAC secret | The same webhookSecret value you wrote into outlet_providers.settings in step 2 |
| Algorithm | HMAC-SHA1 (Revel's mandate — cannot be upgraded to SHA256) |
| Establishment ID | The Revel establishment whose orders should trigger callbacks |
Revel typically takes 1-3 business days to configure on their side. Once they confirm, every closed order at the registered establishment will fire a webhook to your callback URL.
Step 5. Verify locally before going live¶
A one-shot smoke test exercises all five branches against a running local server:
export SECRET="<same value as outlet_providers.settings.webhookSecret>"
export ESTABLISHMENT_ID="<same value as stores.external_id>"
export DINING_OPTION="<one of pickup/delivery/eatinDiningOption>"
export POS_ORDER_ID="<external_id of an existing dispatched order>"
./script/demo/test-inbound-revel.sh all
Expected output:
✔ PASS happy path — HTTP 200, prev=PAID, current=COMPLETED
✔ PASS skip non-CATA dining option — HTTP 200 ack with skip reason
✔ PASS unknown order silent ack — HTTP 200 ack — vendors don't retry
✔ PASS bad signature rejected — HTTP 401
✔ PASS wrong event type rejected — HTTP 400
To get a POS_ORDER_ID for the happy-path scenario, dispatch an order first via ./script/demo/test-order-dispatch.sh transformer "$OUTLET" revel and capture the externalOrderId from the response.
What lands at the receiver for each scenario:
| Scenario | What the receiver does |
|---|---|
happy |
Resolves outlet → loads script → JS returns {status: "COMPLETED", posOrderId, timestamp} → looks up the dispatched order → applies PAID → COMPLETED via OrderStatusService |
skip |
Resolves outlet → loads script → JS returns {skip: true, reason: "non-CATA dining option N"} → 200 silent ack (no transition, no DB write) |
not-found |
Resolves outlet → JS returns a status but the posOrderId doesn't exist in pos_intgr_orders → 200 silent ack (Q5-locked: vendors don't retry on 4xx) |
bad-sig |
HMAC mismatch → 401 (vendor sees auth failure, won't retry) |
missing-event |
X-Revel-Event-Type ≠ order.finalized → 400 (boundary rejection before any DB hits) |
Step 6. Cut over to real Revel webhooks¶
Once the email registration in step 4 is confirmed and the smoke test passes:
- Trigger a real Revel order at the registered establishment and watch your local logs:
go run main.go 2>&1 | grep '\[revel-hmac\]\|\[inbound:revel\]' - Verify the order's
order_statusflipped in the DB:SELECT uuid, external_id, order_status, order_status_updated_at FROM tenantdb.pos_intgr_orders WHERE provider = 'revel' ORDER BY id DESC LIMIT 5; - Confirm the customer-facing app reflects the COMPLETED state (this is the user-visible end of the round-trip).
Watch the first few real callbacks for 401 responses — usually means webhookSecret mismatch between Cata and what Revel registered. The middleware logs which case (secret not configured vs signature mismatch) so operators can tell.
Going further: building a transformer for a new provider¶
Adding a new POS = writing a JS transformer + an HMAC middleware + registering one route. No changes to the shared inbound handler, the JS sandbox runtime, the OrderStatusService, or the Path 1 surface. This is exactly what the "POS adapter work must not modify the Standard API" rule enables.
- Read the contract. Adapter Script Contracts →
order_status_updatedescribes the input shape (input.outlet.settings,input.payload) and the two valid output shapes ({status, posOrderId, timestamp}for transition;{skip: true, reason}for non-CATA orders). - Write
scripts/adapters/<provider>/order_status_update.jswith@provider,@topic,@versionJSDoc tags. Throw on genuinely malformed payloads (5xx → vendor retries); return the skip sentinel for orders the script can't map. - Add a per-provider HMAC middleware at
internal/middleware/<provider>_hmac.gomatching the provider's signature scheme. ReadswebhookSecretfromoutlet_providers.settings. Stashes outlet UUID, settings, and body bytes in the request context for the shared handler to pick up. - Register a route in
main.go:mux.Handle("POST /api/v1/inbound/<provider>/order-status", middleware.Chain( inboundOrderStatusController.Handle("<provider>"), middleware.Logger, middleware.Recovery, middleware.TenantContext, middleware.<Provider>HMAC(deps), )) - Document settings + payload field map at
docs/guides/adapters/<provider>.md— which fields the JS reads from the vendor's webhook body, what the auth scheme is, and how the operator registers the URL with the vendor. - Deploy with
go run cmd/deploy-scripts/main.goand smoke-test with a copy ofscript/demo/test-inbound-revel.shadapted for the new provider's HMAC scheme.
See also¶
- Order Status Updates (Plain) — the partner-conforming Cata-shape flow.
- Adapter Script Contracts — the JS input/output contract per topic.
- Order Dispatch — Built-In Transformer — the symmetric outbound counterpart (Cata → POS).
- Provider — Revel — full Revel field map + sandbox open questions.