Skip to content

Adapter Script Contracts

Every JS adapter script receives a standardized input object and must return a specific output shape per topic. This document defines those contracts.

Universal Input

The Go runtime passes the same input structure to every script, regardless of provider or topic. The script uses whichever fields it needs.

input = {
  // From provider_connections (tenant-level credentials)
  providerSlug: "revel",           // which POS provider
  baseUrl: "https://xyz.revelup.com",
  apiKey: "abc123",
  apiSecret: "secret456",
  webhookUrl: "https://tenant.sgp1.samba-technologies.xyz/.../webhook",
  config: { /* provider_connections.config — provider-specific JSON */ },

  // Topic-specific payload (only present for some topics)
  payload: { /* varies by topic — see below */ }
}

How auth types map to input fields

Auth Type Fields Used Example Providers
api_key apiKey, apiSecret, baseUrl Revel
oauth config.accessToken, config.refreshToken, baseUrl Lightspeed
webhook_only webhookUrl Atlas Kitchen

The script decides which fields to use — the runtime passes all of them.

Runtime Context

Scripts also receive a context object with helper functions:

context = {
  http: {
    get: function(url, headers) { /* returns { statusCode, body, headers } */ },
    post: function(url, body, headers) { /* returns { statusCode, body, headers } */ },
    put: function(url, body, headers) { /* returns { statusCode, body, headers } */ },
    delete: function(url, headers) { /* returns { statusCode, body, headers } */ }
  },
  log: function(message) { /* debug logging */ }
}

HTTP response shape

var resp = context.http.get(url, headers);
resp.statusCode  // number: 200, 401, etc.
resp.body        // object: parsed JSON body (or string if not JSON)

Paginated fetch: context.http.getAll(url, headers)

Fetches all pages from a paginated API automatically. Handles both Revel-style (meta.next) and DRF-style (body.next) pagination.

// Fetches all products across all pages (Go handles pagination concurrently)
var allProducts = context.http.getAll(
  baseUrl + "/resources/Product/?limit=250&offset=0",
  headers
);
// Returns: flat array of ALL objects from ALL pages
// e.g. [{ id: 1, name: "Latte" }, { id: 2, name: "Mocha" }, ...]

Automatically detects result arrays from body.objects, body.results, or body.data.

Parallel batch fetch: context.http.getBatch(urls, headers, concurrency)

Fetches multiple URLs in parallel using a Go worker pool. Much faster than sequential calls.

// Build URLs for modifier groups per product
var urls = [];
for (var i = 0; i < products.length; i++) {
  urls.push(baseUrl + "/resources/ProductModifierClass/?product=" + products[i].id);
}

// Fetch all in parallel (10 concurrent requests)
var responses = context.http.getBatch(urls, headers, 10);
// Returns: array of responses in the SAME ORDER as urls
// responses[0] → result for urls[0], responses[1] → result for urls[1], etc.
// Each response: { statusCode: 200, body: { objects: [...] } }

Parameters: - urls — array of URL strings - headers — shared headers for all requests - concurrency — max parallel requests (1-20, default 10)

Use getAll + getBatch together for fast catalog sync:

// Step 1: Fetch all products (paginated, fast)
var products = context.http.getAll(baseUrl + "/resources/Product/?limit=250", headers);

// Step 2: Fetch modifiers for ALL products in parallel
var modifierUrls = products.map(function(p) {
  return baseUrl + "/resources/ProductModifierClass/?product=" + p.id;
});
var modifierResults = context.http.getBatch(modifierUrls, headers, 10);

// Step 3: Transform (all data in memory, instant)


Topic: test_connection

Purpose: Verify that the saved credentials are valid by making a lightweight API call.

Output

// Must return:
{
  success: true,       // boolean — did the test pass?
  message: "string"    // human-readable result message
}

Behavior

  • Make a single, cheap API call (e.g. list establishments, get account info)
  • Do NOT modify any data on the POS side
  • Return success: false with a clear message if auth fails
  • Throw an error only for unexpected failures (network, script bug)

Example

function transform(input, context) {
  var resp = context.http.get(
    input.baseUrl + "/some/lightweight/endpoint",
    { "Authorization": "Bearer " + input.apiKey }
  );
  if (resp.statusCode === 200) {
    return { success: true, message: "connection successful" };
  }
  if (resp.statusCode === 401 || resp.statusCode === 403) {
    return { success: false, message: "authentication failed: invalid credentials" };
  }
  return { success: false, message: "unexpected response: HTTP " + resp.statusCode };
}

Topic: list_remote_outlets

Purpose: Fetch the list of locations/outlets from the POS system for the dropdown UI.

Output

// Must return an array:
[
  {
    id: "string",        // REQUIRED — POS location ID (will become external_id)
    name: "string",      // REQUIRED — human-readable name for dropdown
    address: "string"    // optional — displayed as secondary text in dropdown
  }
]

Behavior

  • Fetch all locations accessible by the credentials
  • Handle pagination if the POS API paginates (fetch all pages)
  • id must be a string (convert numbers with String(id))
  • Return empty array [] if no locations found (not an error)
  • Throw an error if the API call fails (auth, network)

Example

function transform(input, context) {
  var resp = context.http.get(
    input.baseUrl + "/api/locations",
    { "Authorization": "Bearer " + input.apiKey }
  );
  if (resp.statusCode !== 200) {
    throw new Error("Failed to list outlets: HTTP " + resp.statusCode);
  }
  return resp.body.results.map(function(loc) {
    return {
      id: String(loc.id),
      name: loc.name,
      address: loc.address || ""
    };
  });
}

Topic: sync_outlet

TODO — See pos-adapter-guidelines.md for the Cata target format.

Output

Cata outlet format as defined in the guidelines. Input includes input.payload with the POS location data.


Topic: sync_products

TODO — See pos-adapter-guidelines.md for the Cata target format.

Output

Cata products array as defined in the guidelines. Input includes input.payload with the POS product catalog.


Topic: order_dispatch

Purpose: Build the outbound HTTP request that delivers a paid order to its destination. The dispatch flow is a two-stage JS pipeline:

  1. Standard script (@provider _standard, @topic order_dispatch) — always runs first. Builds the Cata-standard webhook envelope: url = registered webhook callback, body = null (Go uses the verbatim original payload bytes), headers = { "Content-Type": "application/json" }. The Go pipeline then injects X-Cata-Signature, X-Cata-Event, X-Cata-Delivery-ID, and X-Cata-Timestamp headers — but only on the no-transformer path.
  2. Transformer script (@provider <pos-slug>, @topic order_dispatch) — optional. Runs only when the dispatch request supplied builtInTransformer: "<pos-slug>". Receives input.standard = <standard's output> and may inherit standard.url (for testing-via-Hookdeck) or override every field. The transformer fully owns the outbound URL/method/headers/body and is solely responsible for auth (e.g. Revel's API-AUTHENTICATION header). Cata HMAC headers are NOT added on the transformer path.

The dispatch service forwards the request's builtInTransformer field into the pipeline. Unknown values (no script deployed for that name) bubble up as connectors.ErrUnknownTransformer and the controller maps it to HTTP 400.

Input — Standard script

input = {
  order:   { /* Cata Order JSON, validated upstream */ },
  outlet:  { uuid: "string" },
  event:   { deliveryId: "string", eventType: "order.paid", timestamp: "ISO-8601" },
  webhook: { callbackUrl: "string" } | null
           // Populated when an active webhook is registered for this
           // (store, event); null otherwise. Secret is intentionally NOT
           // exposed to JS — HMAC is computed in Go.
}

Output — Standard script

{
  url: "string",     // webhook.callbackUrl when present, else "" (signals no route)
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: null         // sentinel — Go uses the verbatim original payload bytes,
                     // preserving byte-for-byte parity (HMAC integrity, key order)
}

The standard script must NOT contain provider-specific knowledge. If you find yourself adding it, you want a transformer instead.

Input — Transformer script

input = {
  order: {
    uuid: "string",                // Cata order UUID (unique per order)
    orderRefNo: "string",          // Human-readable order reference
    storeUuid: "string",           // Cata outlet UUID (same as input.outlet.uuid)
    deliveryMethod: "PICKUP|DELIVERY|DINE_IN",
    status: "PAID",                // Cata order status at dispatch time
    currency: "AED|SGD|...",
    createdAt: "ISO-8601 string",
    items: [
      {
        plu: "string",             // SKU / POS item code
        name: "string",
        quantity: number,
        itemOnlyPrice: number,     // Price of item alone
        modifierOnlyPrice: number,
        itemPrice: number,         // itemOnlyPrice + modifierOnlyPrice
        itemSubTotal: number,      // itemPrice * quantity
        modifiers: [
          {
            modifierHeaderId: "string",
            modifierOptionId: "string",
            modifierHeaderName: "string",
            modifierOptionName: "string",
            price: number,
            quantity: number
          }
        ],
        notes: "string"            // optional
      }
    ],
    subtotal: number,
    discountTotal: number,
    serviceCharge: number,
    deliveryFee: number,
    totalPay: number,
    payment: {                     // optional — absent for free / 100%-discount orders
      amount: number,
      tip: number,
      method: "CARD|CASH|WALLET|...",
      reference: "string"
    },
    customer: { /* optional customer info */ }
  },
  outlet: {
    uuid:     "string",            // Cata outlet UUID
    settings: { /* parsed outlet_providers.settings JSON for this outlet — provider-specific (apiKey, establishmentId, etc.) */ }
  },
  event: {
    deliveryId: "string",          // Unique per dispatch attempt (useful for idempotency / logs)
    eventType: "order.paid",
    timestamp: "ISO-8601 string"
  },
  standard: {                      // exact output of the standard script
    url:     "string",             // = webhook.callbackUrl when registered, else ""
    method:  "POST",
    headers: { "Content-Type": "application/json" },
    body:    null
  }
}

Credentials live in outlet.settings, not at the top level. The Go pipeline looks them up server-side from outlet_providers.settings for the outlet (no JS access to the secret). Scripts should validate required settings up front and throw on missing values.

Output — Transformer script

{
  url: "string",         // required — absolute URL to POST the order to.
                         //   For testing via Hookdeck: inherit from input.standard.url.
                         //   For production direct-to-POS: derive from outlet.settings.baseUrl.
  method: "POST",        // optional — defaults to "POST"
  headers: {             // optional — string-valued header map
    "Authorization": "Bearer ...",
    "X-API-Key": "..."
  },
  body: {} | "string"    // optional — object is JSON-marshalled; string is sent verbatim
}

Rules:

  • url is required on the transformer. Omitting it surfaces as order_dispatch script output missing 'url'.
  • method defaults to POST. All methods are allowed but POST is overwhelmingly what POS create-order endpoints expect.
  • body as an object → json.Marshal'd. As a string → sent as-is (useful for XML or form-urlencoded payloads).
  • Cata HMAC headers are NOT added when the transformer ran. Auth is entirely the script's responsibility — set whatever headers the external POS requires.

Response handling

On HTTP 2xx, Cata reads the response body (capped at 1 MB) and runs the same logic on both paths:

  • Probe for an external order identifier, in priority order: externalOrderId (Cata-standard receivers), orderId (Revel cart-submit and most POS APIs — numeric values are stringified), id (generic fallback). First non-empty wins and is surfaced as externalOrderId on the dispatch response so callers can reconcile the Cata order to the POS order.
  • Detect body-level errors. Some destinations return HTTP 2xx with {"status":"ERROR","error":{...}} when they reject a request (Revel cart-submit does this). When such a body is detected, Cata reports dispatched: false with a path-specific reason mirroring the non-2xx wording:
  • Standard path → reason: "callback returned 2xx but body indicated error: <embedded message>".
  • Transformer path → reason: "POS returned 2xx but body indicated error: <embedded message>".

The detection only fires when both a non-OK status field and an error field are present, so benign payloads like {"status":"received"} aren't misclassified.

On HTTP non-2xx, Cata marks the order as failed:

  • Standard path → reason: "callback returned HTTP <code>".
  • Transformer path → reason: "POS returned HTTP <code>: <truncated body>" — the body is included up to 500 chars for diagnostics.

Errors

Scripts are expected to throw on unrecoverable input (missing required settings, empty items, malformed order). Thrown errors surface as a failed dispatch with the error string in the reason field — standard order_dispatch script error: ... for the standard path, order_dispatch transformer error: ... for the transformer path. Transient / retryable failures should be expressed as a non-2xx response from the returned URL, not as a thrown error.

Timeout

The pipeline runs under a single 30-second budget (much tighter than sync_products's 10 minutes) covering both script executions and the outbound HTTP call. Orders are latency-sensitive — a customer is waiting on the PAID event. Exceeding the budget surfaces as a context cancellation in the dispatch result.


Topic: order_status_update

Purpose: Transform an inbound POS-side order-status webhook payload into a Cata-standard status update. The script is receive-only — it does not POST anywhere; the Go runtime that invokes it consumes the returned shape and applies the transition via the existing order-status service path.

Input

input = {
  outlet: {
    uuid:     "string",                    // Cata outlet UUID
    settings: { /* outlet_providers.settings JSON — provider-specific (e.g. Revel reads pickupDiningOption / deliveryDiningOption / eatinDiningOption) */ }
  },
  payload: { /* raw POS webhook body, as the partner sent it */ }
}

The script reads only the fields it needs from payload. Each provider documents which fields its script consumes in docs/guides/adapters/<provider>.md.

Output

The script returns ONE of two shapes — a status update or a skip sentinel.

Status update (the inbound order maps to a Cata status transition):

{
  status:     "ACCEPTED" | "IN PROGRESS" | "READY" | "DRIVER PICKED UP" | "COMPLETED" | "CANCELLED",
  posOrderId: "string",                 // POS-side order reference (used to look up the Cata order via external_id)
  reason:     "string",                 // optional — required by Cata when status is "CANCELLED"
  timestamp:  "ISO-8601 string"         // optional — when the status changed in the POS; defaults to "now" server-side if absent
}

Skip sentinel (the inbound payload is not a CATA order, or the event type carries no Cata-mappable transition — e.g. Revel's order.finalized arriving with a non-CATA dining option):

{
  skip:   true,
  reason: "string"                      // short human reason for logs (e.g. "non-CATA dining option 5")
}

The skip sentinel and the throw distinction express the script-side intent — what the script wants the caller to do. How a particular Go call path reacts is up to that path.

Today, scripts for this topic invoked via the generic execution endpoint (POST /api/v1/adapter-scripts/executeAdapterExecuteService.routeOutput()) fall through to the default branch: the runtime returns "script executed (no auto-routing for this topic)" with the raw output stored on the response, and does not inspect the skip field, issue a skip acknowledgment, or apply / prevent any state transition. A dedicated inbound webhook receiver (per-provider HMAC-verified path) is responsible for honoring these semantics — 200 ack on { skip: true }, 5xx on a thrown error, and lookup-plus-transition on the status shape.

Throws are reserved for genuinely malformed payloads (missing required fields, unparseable IDs).


Topic: snooze_items

Purpose: Temporarily disable items on the external POS system (e.g. mark as out of stock).

Input

In addition to the universal input (credentials), input.payload contains:

input.payload = {
  items: [
    {
      itemCode: "LATTE-001",      // POS product ID
      type: "product",            // "product" or "modifier_option"
      snoozeEnd: "2026-03-18T18:00:00Z",  // disable until this time (null = unsnooze)
      snoozed: true                // true = snooze, false = unsnooze
    },
    {
      itemCode: "OPT-OAT",
      type: "modifier_option",    // modifier option (e.g. "Oat Milk" unavailable)
      snoozeEnd: null,
      snoozed: false              // unsnooze = re-enable
    }
  ]
}

Output

The script should call the POS API to update availability and return:

{
  results: [
    { itemCode: "LATTE-001", type: "product", success: true, message: "item snoozed until 18:00" },
    { itemCode: "OPT-OAT", type: "modifier_option", success: true, message: "option unsnoozed" },
    { itemCode: "WRAP-001", type: "product", success: false, message: "item not found in POS" }
  ]
}

Behavior

  • Applies to both products and modifier options (check type field)
  • Call the POS API to disable/enable each item
  • Return per-item success/failure (don't fail the whole batch if one item fails)
  • snoozeEnd = null or snoozed = false means unsnooze (re-enable the item)
  • Some POS systems may use different API endpoints for products vs modifier options

Error Handling

Scenario Script Should
Auth failed (401/403) Return failure result (for test_connection) or throw Error
Network error Let it propagate (Go runtime catches and returns 500)
Unexpected API response Throw Error with descriptive message
Missing field in POS data Omit from output (don't send null/empty)
Rate limited (429) Throw Error — Go runtime can implement retry later

Script Metadata

Every script should include a header comment:

/**
 * @provider   revel
 * @topic      test_connection
 * @version    1.0.0
 * @generated  2026-03-17
 * @apiVersion Revel API v1
 */
function transform(input, context) {
  // ...
}