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:
- Hot-swappable adapters. Bump
@versionin the JS, runcmd/deploy-scripts, and the new adapter is live without a Go redeploy. Useful when a POS vendor changes a field shape mid-week. - Provider-isolated changes. Adding Atlas Kitchen support means adding
scripts/adapters/atlas-kitchen/*.jsand one HMAC middleware. Zero changes to the receiver / dispatcher / standard service. The compiler can't catch a typo in the JS, butcmd/deploy-scriptsvalidates 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→ CANCELLEDfrom any non-terminal state and also fromCOMPLETED(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 indomain.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 SQLUPDATE … WHERE order_status = expectedpattern. - Partner-pushable filter: UNPAID, PAID, REFUNDED can never be pushed by external callers. Same.
- Persistence target:
pos_intgr_orders.order_statuscolumn. 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):
- Read the contract at Adapter Script Contracts — input shape (
input.outlet.settings,input.payload,input.eventetc.) and required output shape per topic. - Write
scripts/adapters/<provider>/<topic>.jswith the JSDoc header (@provider,@topic,@version). Throw on malformed input; return the documented output shape. - Document settings + payload field map at
docs/guides/adapters/<provider>.md. - For inbound only — write a per-provider HMAC middleware at
internal/middleware/<provider>_hmac.go. ReadswebhookSecretfromoutlet_providers.settings. StashesoutletUUID, parsed settings, and body bytes in the request context for the shared handler. - 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), )) - Deploy with
go run cmd/deploy-scripts/main.go. - Smoke-test with
script/demo/test-order-dispatch.sh(outbound) and a copy ofscript/demo/test-inbound-revel.shadapted 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¶
- Order Dispatch — Built-In Transformer — outbound walkthrough using Revel as the worked example.
- Order Status Updates — Built-In Transformer — inbound walkthrough using Revel.
- How Order Dispatch Works — the dispatch-specific Standard API ↔ Built-In Transformer comparison (overlaps with this page on the outbound side; this page expands to cover both directions).
- Adapter Script Contracts — JS input/output contracts per topic.
- POS Adapter Architecture — the broader 7-topic adapter framework.