Skip to content

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 PlainPOST /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.finalizedCOMPLETED, 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:

  • webhookSecret is 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.finalized for 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 with 200 OK and does no lookup. (Note: even without the filter, lookup misses would still return 200 OK rather than 404 — vendors don't retry on 4xx, 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 configured vs auth: 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_id was 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-Typeorder.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:

  1. 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\]'
    
  2. Verify the order's order_status flipped 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;
    
  3. 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.

  1. Read the contract. Adapter Script Contracts → order_status_update describes 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).
  2. Write scripts/adapters/<provider>/order_status_update.js with @provider, @topic, @version JSDoc tags. Throw on genuinely malformed payloads (5xx → vendor retries); return the skip sentinel for orders the script can't map.
  3. Add a per-provider HMAC middleware at internal/middleware/<provider>_hmac.go matching the provider's signature scheme. Reads webhookSecret from outlet_providers.settings. Stashes outlet UUID, settings, and body bytes in the request context for the shared handler to pick up.
  4. 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),
    ))
    
  5. 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.
  6. Deploy with go run cmd/deploy-scripts/main.go and smoke-test with a copy of script/demo/test-inbound-revel.sh adapted for the new provider's HMAC scheme.

See also