Skip to content

How Built-In Transformers Work

A complement to the Standard API for vendors whose POS systems can't conform to Cata's contract directly. If your POS already speaks Cata's standard shape, you want Order Dispatch and Order Status Updates instead — those are the partner-conforming flows. This page explains how the alternative works and when each path applies.

TL;DR

A Built-In Transformer is a JavaScript script that lives centrally and runs inside a sandboxed JS engine on the Cata side. It sits between the Cata Standard API contract and a POS vendor whose API or webhook shape doesn't conform to that contract.

Standard API (vendor conforms):
  Cata Order ──► [vendor-built receiver] ──► vendor's POS

Built-In Transformer (Cata builds the adapter):
  Cata Order ──► [Cata-built JS adapter] ──► vendor's POS
                  ↑
                  same JS sandbox, same Cata backend services

Same architecture works in reverse for inbound:

Standard API:
  vendor's POS ──► [vendor's transformer] ──► POST /api/v1/orders/{orderId}/status (Cata-shape)

Built-In Transformer:
  vendor's POS ──► POST /api/v1/inbound/{provider}/order-status (vendor-native)
                                  ↓
                                 [Cata-built JS adapter] ──► OrderStatusService.UpdateStatus

The decision is: who writes the transformation code? With Standard API, the vendor does. With Built-In Transformer, Cata does, in JS, deployed centrally.


When does Built-In Transformer apply?

Situation Path
Vendor has engineering resources and is willing to build a Cata-conforming receiver / transformer on their side Standard API
Vendor's API and/or webhook payload is fixed (legacy POS, limited dev resources, multi-tenant SaaS that won't customize) Built-In Transformer — Cata writes the adapter
You're an operator deploying a Cata-supported POS (Revel today) Built-In Transformer — operator just enters credentials in outlet_providers.settings
Vendor would conform but for a single field shape mismatch Push back — Standard API plus a small vendor patch is cheaper to maintain than a forever-Cata-owned adapter

In production today: Deliverect uses Standard API (they wrote their own transformer); Revel uses Built-In Transformer (Cata wrote the adapter); Atlas Kitchen and Lightspeed are planned as Built-In Transformer targets.


Architecture comparison — Standard API vs Built-In Transformer

Outbound (Cata → POS, on order.paid)

                       STANDARD API                              BUILT-IN TRANSFORMER
                       ────────────                              ────────────────────

Caller                 POST /api/v1/orders/dispatch              POST /api/v1/orders/dispatch
                       (no `builtInTransformer` field)           { "builtInTransformer": "revel", ... }
                              ↓                                          ↓
Cata persists order    pos_intgr_orders row created               pos_intgr_orders row created
                       (provider = NULL)                          (provider = "revel")
                              ↓                                          ↓
Standard JS script     ✓ runs                                    ✓ runs
                       returns { url:webhook, body:null,          (output captured but overridden in step below)
                       headers:{…} }
                              ↓                                          ↓
Per-provider transformer    ✗ skipped                             ✓ runs (revel/order_dispatch.js)
                                                                  reads input.standard, rewrites
                                                                  body to Revel's cart-submit shape,
                                                                  swaps URL, adds Revel auth headers
                              ↓                                          ↓
HMAC injection (Go)    ✓ X-Cata-Signature, X-Cata-Event,         ✗ skipped — auth lives in the script
                       X-Cata-Delivery-ID, X-Cata-Timestamp
                              ↓                                          ↓
HTTP POST              vendor's webhook URL                       vendor's POS API directly
                       body = original Cata Order JSON            body = vendor-native shape
                              ↓                                          ↓
Vendor processes       Their receiver verifies HMAC,              Their POS just receives a normal
                       parses Cata Order, transforms              create-order request
                       to native shape, calls their own
                       create-order handler

Inbound (POS → Cata, on order.finalized)

                       STANDARD API                              BUILT-IN TRANSFORMER
                       ────────────                              ────────────────────

Caller                 POST /api/v1/orders/{orderId}/status      POST /api/v1/inbound/{provider}/order-status
                       (Cata-shape body, X-Api-Key auth)         (vendor-native body, provider HMAC auth)
                              ↓                                          ↓
Auth                   X-Api-Key (partner key)                   Per-provider HMAC middleware
                                                                  (Revel = SHA1, others vary)
                              ↓                                          ↓
Outlet resolution      orderId in URL → pos_intgr_orders row    X-{Provider}-Establishment-Id header →
                       → tenantdb.stores                          stores JOIN outlet_providers (Q1 lookup)
                              ↓                                          ↓
Body interpretation    Cata-shape — vendor pre-transformed        Vendor-native — load JS script and
                       on their side                              run in the JS sandbox
                              ↓                                          ↓
Translation              N/A — body IS Cata-shape                 JS returns { status, posOrderId, timestamp }
                                                                  OR { skip:true, reason } (silent ack)
                              ↓                                          ↓
Order lookup           pos_intgr_orders.uuid                     pos_intgr_orders.(external_id, provider)
                       (the orderId from URL)                     (the posOrderId from JS output)
                              ↓                                          ↓
Apply transition       OrderStatusService.UpdateStatus()         OrderStatusService.UpdateStatus()
                              ↓                                          ↓
                       SAME backend code from this point          SAME backend code from this point
                       — state machine, idempotency, optimistic concurrency, partner-pushable filter

The key insight: everything below OrderStatusService.UpdateStatus() is identical. The two paths only differ at the boundary — auth, body shape, and who's responsible for translation.


What lives where

Piece Standard API Built-In Transformer
Auth scheme HMAC-SHA256 (outbound) / X-Api-Key (inbound) Per-provider — typically HMAC-SHA1 with vendor headers; varies
Body shape on the wire Cata Standard Vendor-native
Who writes transformation code Vendor (in their own repo) Cata, in JS, in scripts/adapters/{provider}/{topic}.js
Where the code runs Vendor's server Cata's JS sandbox
How the code is updated Vendor releases cmd/deploy-scripts upserts into central.adapter_scripts; hot-swappable via @version bump
Per-tenant secrets Stored on the vendor's side outlet_providers.settings.webhookSecret (per-outlet, per-tenant)
Per-outlet config Vendor manages outlet_providers.settings JSON — read by the JS via input.outlet.settings
Backend service Same — OrderStatusService.UpdateStatus, OrderDispatchService.Dispatch Same — OrderStatusService.UpdateStatus, OrderDispatchService.Dispatch
State machine Same kds-management-mirrored transitions Same kds-management-mirrored transitions

Why a JS sandbox (and not Go structs per provider)?

The legacy kds-management-service integrates Revel via hard-coded Go structs and handlers. Adding a new POS means adding new Go files, recompiling, and redeploying. That worked when there was one POS. It doesn't scale to a long tail.

Built-In Transformer trades static type safety for two properties:

  1. Hot-swappable adapters. Bump @version in the JS, run cmd/deploy-scripts, and the new adapter is live without a Go redeploy. Useful when a POS vendor changes a field shape mid-week.
  2. Provider-isolated changes. Adding Atlas Kitchen support means adding scripts/adapters/atlas-kitchen/*.js and one HMAC middleware. Zero changes to the receiver / dispatcher / standard service. The compiler can't catch a typo in the JS, but cmd/deploy-scripts validates the script header (@provider, @topic, @version) and the integration tests against a smoke fixture catch shape regressions.

The trade-off: typo'd field names in JS surface as runtime errors rather than compile errors. Mitigation: every new script gets a unit-test fixture that exercises the happy path against a real POS sample payload before being marked active.


What's the same regardless of path

The Cata side enforces the same contracts on every order regardless of how it arrived:

  • State machine: identical transition graph regardless of path: PAID → ACCEPTED → IN PROGRESS → READY → DRIVER PICKED UP → COMPLETED, plus → CANCELLED from any non-terminal state and also from COMPLETED (late-cancellation). The visual state-machine diagrams (Pickup flow, Delivery flow, full transition reference table) live in Order Status Updates — the rules apply equally to the Built-In Transformer path. Validation runs in domain.CanTransitionStatus.
  • Idempotency: sending the same status twice returns 200 without modifying transitionedAt. Both paths.
  • Optimistic concurrency: two writers racing on the same order get exactly one winner; the loser sees 409 concurrent modification (Standard API) or a 200 ack with the rejection reason (Built-In Transformer — vendors don't action 409 well). Same underlying SQL UPDATE … WHERE order_status = expected pattern.
  • Partner-pushable filter: UNPAID, PAID, REFUNDED can never be pushed by external callers. Same.
  • Persistence target: pos_intgr_orders.order_status column. Same.

Vendor never sees these mechanisms — they're invisible behind the boundary. But internal code reading the DB sees one consistent dataset regardless of which path produced it.


How to add a new provider's adapter

The recipe is the same for outbound (order_dispatch) and inbound (order_status_update):

  1. Read the contract at Adapter Script Contracts — input shape (input.outlet.settings, input.payload, input.event etc.) and required output shape per topic.
  2. Write scripts/adapters/<provider>/<topic>.js with the JSDoc header (@provider, @topic, @version). Throw on malformed input; return the documented output shape.
  3. Document settings + payload field map at docs/guides/adapters/<provider>.md.
  4. For inbound only — write a per-provider HMAC middleware at internal/middleware/<provider>_hmac.go. Reads webhookSecret from outlet_providers.settings. Stashes outletUUID, parsed settings, and body bytes in the request context for the shared handler.
  5. For inbound only — 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),
    ))
    
  6. Deploy with go run cmd/deploy-scripts/main.go.
  7. Smoke-test with script/demo/test-order-dispatch.sh (outbound) and a copy of script/demo/test-inbound-revel.sh adapted for the new provider's HMAC scheme (inbound).

Step 4 + 5 are inbound-only because outbound is fully script-driven — the Go pipeline doesn't need to know which provider ran. Inbound needs Go-level auth verification before the JS even sees the body, so each provider needs a thin Go shim.


See also