Skip to content

Revel Systems — Adapter Guide

Which integration is this? This guide documents the new JS-adapter model in pos-integration-service — Revel is integrated via JavaScript transform scripts under scripts/adapters/revel/ (how it works). The older direct Go integration (service/RevelService/… in kds-management-service) remains in production and is kept as-is; this guide is not about that code path, though the order_dispatch / order_status mappings below mirror its verified production behaviour.

This is the consolidated knowledge pack for Revel (see ADR 0004). It absorbs the former expert-revelsystem repo, which is now frozen.

Provider Info

Field Value
Slug revel
Auth Type api_key
Base URL Per-tenant (e.g. https://acme.revelup.com)
API Docs Revel Open API (requires partner access)
Model Hybrid — pull for outlets/products, push (webhook) for order status
Sample fixtures samples/revel/ — verified input/output pairs + a live API capture

Authentication

Revel uses a combined API key + secret in a single header:

API-AUTHENTICATION: {apiKey}:{apiSecret}

Input fields used

input.baseUrl    // e.g. "https://xyz.revelup.com"
input.apiKey     // API key
input.apiSecret  // API secret

Building the auth header

var headers = {
  "API-AUTHENTICATION": input.apiKey + ":" + input.apiSecret,
  "Accept": "application/json"
};

API Endpoints

Endpoint Method Use For Pagination
/enterprise/Establishment/ GET test_connection, list_remote_outlets Yes
/products/Product/ GET sync_products Yes
/resources/MenuCategory/ GET sync_products (modifier groups) Yes
/enterprise/Menu/ GET sync_products (menu structure — ignore for flat sync) Yes
/specialresources/cart/submit POST order_dispatch N/A

Pagination

Revel APIs use offset-based pagination:

?limit=50&offset=0
  • Default limit: 50
  • Max limit: 250
  • Response includes count (total), next (URL), previous (URL)
// Pagination helper pattern
function fetchAll(baseUrl, path, headers, context) {
  var all = [];
  var offset = 0;
  var limit = 250;
  while (true) {
    var resp = context.http.get(
      baseUrl + path + "?limit=" + limit + "&offset=" + offset,
      headers
    );
    if (resp.statusCode !== 200) throw new Error("HTTP " + resp.statusCode);
    all = all.concat(resp.body.results || resp.body.objects || []);
    if (!resp.body.next) break;
    offset += limit;
  }
  return all;
}

Rate Limits

  • 300 requests per minute per API key
  • If 429 returned, the script should throw (Go runtime handles retry)

Quirks & Constraints

Confidence: VERIFIED = tested against live Revel and/or the built adapter scripts; otherwise treat as needs-verification.

Quirk Detail Confidence
Prices are decimal floats, NOT cents Product.price is a decimal float (e.g. 1.5) — do not divide by 100. Money string fields like cost / upcharge_price are decimal strings ("0.5000", "2.00"); parseFloat them. All wire decimals on cart-submit are rounded to 2dp. VERIFIED
URI-based references, varying prefixes Foreign keys are URI strings and the prefix varies: /resources/Product/391/, /enterprise/Establishment/10/, /products/ProductCategory/914/. Parse the integer between the last two /. VERIFIED
Path prefixes vary by sub-API /enterprise/ (establishments), /resources/ (products, modifiers, combo sets, custom menus, product groups), /products/ (categories), /specialresources/ (cart submit). Not everything is /resources/. VERIFIED
Two pagination envelopes /enterprise/*{ count, next, previous, results }; /resources/* and /products/*{ meta: { next, … }, objects }. Read body.results \|\| body.objects. Max limit=250 (not 1000). VERIFIED
IDs are integers Convert to strings with String(id). Establishment id is the primary location identifier. VERIFIED
Pricing flexibility The same PLU can have different pricing per establishment (unlike Lightspeed). VERIFIED
Modifier linkage via PMC join table Modifiers attach to products through the ProductModifierClass join (per-product), not a modifier_classes[] field. Fetch ProductModifierClass?product={id}&expand=modifierclass, then ProductModifier?product_modifier_class={pmcId}. VERIFIED
Bundle = "shell product" + ComboProductSet Detect via is_combo > 0 on Product; fetch sets with ComboProductSet/{id}/?expand=products. Bundle parents carry the full combo price; children priced via upcharge_price (<= 0 ⇒ included). VERIFIED
dining_options is a JSON-encoded string Not an array — e.g. "[0,1,2,3]". Parse before use. VERIFIED
Cart-submit wire shape is non-negotiable skin:"weborder" (not "WebPortal"), snake_case orderInfo, customMenuInfo always present ({mode:0,name:""}), empty serviceFees/discounts/paymentInfo sent as null (not []), Content-Type: text/plain (not application/json). VERIFIED
Webhook is terminal-only One order.finalized event (X-Revel-Event-Type: order.finalized) per order; no ACCEPTED/IN PROGRESS/READY/DRIVER PICKED UP. It also fires for all closed orders (incl. in-store), so filter by the outlet's CATA dining-option IDs and {skip:true} the rest. VERIFIED
Timezone Revel stores timestamps in UTC; establishments carry a time_zone field for local conversion. (Earlier notes said local-time — treat as NEEDS VERIFICATION against sandbox.) needs verification
Timetable.day_of_week is 0-based Monday 0 = Monday (not Sunday). Only relevant once sync_outlet opening-hours land. VERIFIED

Topic: test_connection

API Call

GET /enterprise/Establishment/

Lightweight call — returns list of establishments. If credentials are valid, this returns 200.

Script

/**
 * @provider   revel
 * @topic      test_connection
 * @version    1.0.0
 * @apiVersion Revel Open API v1
 */
function transform(input, context) {
  var url = input.baseUrl.replace(/\/+$/, "") + "/enterprise/Establishment/";
  var resp = context.http.get(url, {
    "API-AUTHENTICATION": input.apiKey + ":" + input.apiSecret,
    "Accept": "application/json"
  });

  if (resp.statusCode === 200) {
    var count = resp.body.count || 0;
    return { success: true, message: "connection successful (" + count + " establishments found)" };
  }
  if (resp.statusCode === 401 || resp.statusCode === 403) {
    return { success: false, message: "authentication failed: invalid API key or secret" };
  }
  return { success: false, message: "unexpected response: HTTP " + resp.statusCode };
}

Sample API Response (200 OK)

{
  "count": 2,
  "next": null,
  "previous": null,
  "results": [
    {
      "id": 456,
      "name": "Orchard Road",
      "address": "123 Orchard Rd, Singapore",
      "phone": "+6561234567",
      "timezone": "Asia/Singapore",
      "active": true
    },
    {
      "id": 789,
      "name": "Marina Bay",
      "address": "1 Marina Blvd, Singapore",
      "phone": "+6567654321",
      "timezone": "Asia/Singapore",
      "active": true
    }
  ]
}

Topic: list_remote_outlets

API Call

GET /enterprise/Establishment/?limit=250&offset=0

Same endpoint as test_connection, but we extract the outlet list.

Script

/**
 * @provider   revel
 * @topic      list_remote_outlets
 * @version    1.0.0
 * @apiVersion Revel Open API v1
 */
function transform(input, context) {
  var baseUrl = input.baseUrl.replace(/\/+$/, "");
  var headers = {
    "API-AUTHENTICATION": input.apiKey + ":" + input.apiSecret,
    "Accept": "application/json"
  };

  var all = [];
  var offset = 0;
  var limit = 250;

  while (true) {
    var url = baseUrl + "/enterprise/Establishment/?limit=" + limit + "&offset=" + offset;
    var resp = context.http.get(url, headers);

    if (resp.statusCode !== 200) {
      throw new Error("Failed to list establishments: HTTP " + resp.statusCode);
    }

    var results = resp.body.results || [];
    for (var i = 0; i < results.length; i++) {
      var est = results[i];
      all.push({
        id: String(est.id),
        name: est.name || "Establishment #" + est.id,
        address: est.address || ""
      });
    }

    if (!resp.body.next) break;
    offset += limit;
  }

  return all;
}

Expected Output

[
  { "id": "456", "name": "Orchard Road", "address": "123 Orchard Rd, Singapore" },
  { "id": "789", "name": "Marina Bay", "address": "1 Marina Blvd, Singapore" }
]

Topic: sync_outlet

Status: test_connection and list_remote_outlets are built (they read /enterprise/Establishment/); a full sync_outlet (addresses + opening hours → POST /api/v1/outlets/sync) is not yet implemented in scripts/adapters/revel/. The built subset is documented first; the rest is planned.

API Call

GET /enterprise/Establishment/?limit=250&offset=0   # list (built)
GET /resources/Establishment/{id}/                  # nested address object (planned)

The /enterprise/ list endpoint returns a flat address string; the nested address object (address_1, city, …) lives on /resources/Establishment/{id}/.

Field Mapping — built path (list_remote_outlets)

Revel Field Cata Field Notes Confidence
id externalId String(id) VERIFIED
name name Falls back to "Establishment #" + id when empty VERIFIED
address address (display) Flat string from the enterprise endpoint VERIFIED

Field Mapping — planned (sync_outlet)

Revel Field Cata Field Notes
address.address_1 / address_2 address.line1 / line2 Requires /resources/Establishment/{id}/
address.city / state / zipcode / country address.city / state / postalCode / country Same fetch
email contact.email Direct
phone contact.phone Direct
currency currency ISO 4217 — confirm field name on /resources/Establishment/
time_zone timezone IANA format
Business hours (separate endpoint) openingHours NEEDS VERIFICATIONEstablishmentBusinessHours shape not yet exercised

Samples: sync_outlet_input.jsonsync_outlet_output.json.


Topic: sync_products

Implementation: scripts/adapters/revel/sync_products.js (VERIFIED). Flat catalog — Cata owns menu composition; ignore Revel's menu tree/categories except to resolve a posCategory label.

Fetch modes

Mode When Endpoints
All products outlet.settings.customMenuId absent GET /resources/Product/?limit=250&display_online=true (paginated)
CustomMenu-scoped customMenuId set CustomMenu/{id}ProductGroup/{groupId}/resources/Product/set/{ids}/ (semicolon-joined, chunked 50/call)

Both modes feed the same parallel fan-out (getBatch, concurrency 10):

  • non-bundle: ProductModifierClass?product={id}&expand=modifierclassProductModifier?product_modifier_class={pmcId}
  • bundle: ComboProductSet/{setId}/?expand=products (set IDs parsed from combo_productsets[] URIs)
  • best-effort: GET /products/ProductCategory/?limit=250posCategory name map (errors swallowed)

Common output fields (all models)

Cata field Source Notes
itemCode Product.sku Required — empty SKU skips the product
name Product.name
description Product.description Only when non-empty
basePrice Product.price Decimal float, direct (NOT cents)
visible Product.active === true strict equality
pickupAvailable / deliveryAvailable / eatInAvailable constant always true
isVariant constant always false (Revel has no variant concept)
posCategory categoryNameMap[parseIdFromUri(Product.category)] only when category fetch resolved

Model 1 — Simple product

is_combo falsy AND no active PMC → isBundle:false, modifierGroups:[], bundleSections:[].

Model 2 — Product with modifiers

is_combo falsy AND ≥1 PMC with modifierclass.active !== false.

Modifier group (ProductModifierClass + expanded ModifierClass):

Revel field Cata field Notes
modifierclass.id modifierGroups[].itemCode "MC_" + id
modifierclass.name modifierGroups[].name "" when missing
Forced minSelect true1, false0
LockAmount maxSelect default 1
(derived) inputType "SINGLE" when Forced && LockAmount===1, else "MULTIPLE" (not "MULTI")
modifierclass.Sort sortNum default 0

Groups with modifierclass.active === false are dropped.

Modifier option (ProductModifier + nested Modifier):

Revel field Cata field Notes
modifier.sku options[].itemCode Falls back to "RMOD_" + modifier.id when empty (option still synced — unlike products)
modifier.name options[].name "" default
PriceOverride / modifier.Price options[].additionalPrice PriceOverride wins when non-null & non-zero; else modifier.Price; else 0
modifier.Sorting options[].sortNum default 0
pm.DefaultModifier options[].asDefault strict === true
constant options[].visible always true

Options with nested modifier.active === false are dropped.

Model 3 — Bundle (Group Combo)

is_combo > 0isBundle:true, modifierGroups:[], bundleSections[] built from ComboProductSet:

Revel field (ComboProductSet) Cata field Notes
id bundleSections[].itemCode "CPS_" + id
name bundleSections[].name falls back to "Section " + (index+1)
min_quantity minSelection default 1 (supersedes the old single Quantity field)
max_quantity maxSelection default 1
array index sortNum 0-based position

Bundle items (ComboProductSet.products[] expanded child → items[]):

Revel field Cata field Notes
sku items[].itemCode child skipped if empty
upcharge_price items[].price omitted when <= 0; decimal when > 0
sorting items[].sortNum default 0

Children returned as bare URI strings (not expanded) are skipped.

Samples: sync_products_input.jsonsync_products_output.json; plus a verified live capture, real_product_response.json.


Topic: order_dispatch

Implementation: scripts/adapters/revel/order_dispatch.js.

Transforms a Cata-standard order into a Revel Web Order cart submit request. The shape of this section is derived from the legacy kds-management-service Revel integration (which is in production today against real Revel) — not guessed against API docs. Use this as the source of truth for the script.

Note on script version. v1.0.0 (status candidate) was a structural sketch that targeted the wrong endpoint (/resources/Order/) with the wrong field shapes. v1.1.0 (in progress) rewrites the script against the mapping below. Do not deploy v1.0.0 to a live Revel outlet.

API Call

POST {baseUrl}/specialresources/cart/submit
Headers:
  API-AUTHENTICATION: {apiKey}:{apiSecret}
  Accept: application/json
  Content-Type: text/plain

Why text/plain? That's what the legacy integration uses; Revel's cart-submit endpoint accepts the JSON-encoded body under text/plain Content-Type. Do not change to application/json without sandbox confirmation.

Required outlet_providers.settings

The script pulls Revel-specific config from the outlet's settings JSON. Field names below mirror what the legacy RevelCredentials struct stores; the script reads them under these JSON keys.

Field Required Purpose
baseUrl Yes Revel API base URL, e.g. https://acme.revelup.com
apiKey Yes Revel API key (legacy client_key)
apiSecret Yes Revel API secret (legacy client_secret)
establishmentId Yes Revel establishment numeric ID — sent as establishmentId in body
pickupDiningOption One of these required Dining option ID for PICKUP delivery method
deliveryDiningOption One of these required Dining option ID for DELIVERY delivery method
eatinDiningOption One of these required Dining option ID for EATIN delivery method
paymentTypeId No Revel payment type enum (1-8). Default 7 (CREDIT_PLUS) when payment is present
serviceFeeAlias No Label for the Cata-fee service-fee line. Default "Cata Fee"
discountId No Revel discount ID — only used if discountBarcode is also set
discountBarcode No Revel discount barcode string — required to send a discount line

The script throws if baseUrl, apiKey, apiSecret, establishmentId, or the dining option for the order's delivery method is missing.

Body shape

The full Revel cart-submit body assembled from a Cata order:

{
  "skin": "weborder",
  "establishmentId": 456,
  "customMenuInfo": { "mode": 0, "name": "" },
  "items": [
    {
      "product": 12345,
      "quantity": 2,
      "price": 10.50,
      "special_request": "no onion",
      "modifieritems": [
        {
          "modifier": 678,
          "product_id": 12345,
          "modifier_price": 1.25,
          "qty": 1,
          "qty_type": 0,
          "mod_type": 0
        }
      ]
    }
  ],
  "orderInfo": {
    "dining_option": 3,
    "notes": "Use bag",
    "asap": true,
    "pickup_time": "",
    "customer": {
      "first_name": "Jane",
      "last_name": "Doe",
      "email": "jane@example.com",
      "phone_number": "+6591234567"
    },
    "call_name": "Jane"
  },
  "serviceFees": [
    { "amount": 1.20, "alias": "Cata Fee" }
  ],
  "discounts": [
    { "amount": 2.00, "barcode": "DISC-001" }
  ],
  "discount_code": "",
  "paymentInfo": [
    {
      "tip": 0.50,
      "type": 7,
      "transaction_id": "ch_3O1abcXYZ",
      "amount": 22.20
    }
  ]
}

Cata → Revel field map

Cata field Revel field Transform
(constant) skin Always "weborder" (verified against kds-management-service/service/RevelService/constants.go:webOrderSkin; older "WebPortal" value was wrong and triggers an internal Revel "Unhandled error")
(constant) customMenuInfo Always {mode:0, name:""} (legacy Go struct emits zero value due to no omitempty; field must be present)
outlet.settings.establishmentId establishmentId parse as integer
order.items[].plu items[].product parse as integer (Cata plu IS the Revel product id, set by sync_products)
order.items[].quantity items[].quantity verbatim integer
order.items[].itemOnlyPrice items[].price decimal float, item-only — Revel adds modifier prices itself
order.items[].notes items[].special_request verbatim
order.items[].modifiers[] items[].modifieritems[] see modifier mapping below
order.items[].isBundle + subItems one cart item with is_combo: true + nested products_sets[] — see "Bundles" bundle base price stays on parent; sub-items priced at 0 (or surcharge)
order.deliveryMethod (PICKUP / DELIVERY / EATIN) orderInfo.dining_option resolve to pickupDiningOption / deliveryDiningOption / eatinDiningOption from settings (note: settings keys are camelCase, but the wire field is snake_case)
collected items[].notes (+ "Use bag" if packaging) orderInfo.notes comma-joined
!order.isPreorder orderInfo.asap boolean
order.expectedPickupAt orderInfo.pickup_time verbatim string (empty when ASAP)
order.customer.{name,email,phone} orderInfo.customer split namefirst_name/last_name; map emailemail, phonephone_number
order.customer.name (first token) orderInfo.call_name derived; fallback to order.orderRefNo
order.customer.email notifications.destination only set if email present
serviceFeeAlias setting (or "Cata Fee") serviceFees[0].alias constant per outlet
computed remainder serviceFees[0].amount totalPay − subtotal − tip + discountTotal, rounded to 2dp; omit line when ≤ 0
order.discountTotal (+ points-to-discount) discounts[0].amount only sent when discountId AND discountBarcode settings configured
discountBarcode setting discounts[0].barcode constant per outlet
order.payment.tip paymentInfo[0].tip decimal
paymentTypeId setting (or 7) paymentInfo[0].type numeric enum
order.payment.reference paymentInfo[0].transaction_id verbatim
order.totalPay paymentInfo[0].amount decimal — full order total

Modifier mapping (per item)

{
  "modifier":        <int>,    // modifier option's external ID (from sync_products), parse to int
  "product_id":      <int>,    // parent item's external product ID (same int as items[].product)
  "modifier_price":  <decimal>,// modifier price as decimal
  "qty":             <int>,    // modifier quantity
  "qty_type":        0,        // 0=NONE, 1=FIRST_HALF, 2=SECOND_HALF — V1 always 0
  "mod_type":        0         // 0=REGULAR, 1=NO, 2=SIDE, 3=ONLY, 4=LITE — V1 always 0
}

Modifiers are nested inside each items[] row, not sent as separate top-level rows. If a modifier's external ID can't be resolved, skip silently with a warning log (legacy behaviour).

Bundles

A Cata bundle line item (isBundle: true) emits ONE top-level cart item with is_combo: true and the children nested under products_sets[]. Children are grouped by subItems[].sectionItemCode (legacy "CPS_" format) so each choice section in the combo (e.g. "Soft Taco" choice, "Drink" choice) becomes its own product-set group on the wire.

{
  "product":   8315,           // bundle's plu, parsed as int
  "quantity":  1,
  "price":     27.0,           // bundle base price (the parent carries this)
  "is_combo":  true,
  "modifieritems": [],         // any modifiers attached to the bundle itself
  "products_sets": [
    {
      "id":   284,                                  // parsed from "CPS_284"
      "name": "Soft Taco",                          // from sectionName
      "products": [
        { "product": 8295, "quantity": 1, "price": 0, "modifieritems": [] },
        { "product": 8294, "quantity": 1, "price": 0, "modifieritems": [] }
      ]
    },
    {
      "id":   283,
      "name": "Hard Taco",
      "products": [
        {
          "product":  8288, "quantity": 1, "price": 0,
          "modifieritems": [{ "modifier": 2424, "product_id": 8288, ... }]
        }
      ]
    }
  ]
}

Rules:

  • Bundle parent's is_combo: true is the discriminator. Without it Revel ignores products_sets[] and treats the row as a regular product.
  • Bundle parent's price carries the full combo price; children are priced at 0 (or per-child surcharge if set on subItems[].surcharge).
  • Children with the same sectionItemCode are merged into one product-set group; id comes from parsing the integer suffix of "CPS_<int>"; name comes from subItems[].sectionName.
  • Children missing sectionItemCode fall into a single default group (id: 0, name: "") — older fixtures without section metadata still render a syntactically valid bundle.
  • Modifier mapping inside sub-items is identical to the top-level rule (each modifier gets product_id set to its parent sub-item's product).

Mirrors kds-management-service/service/RevelService/Order.go (mapBundleSubItems + IsCombo: true production path).

Service fee derivation

Revel has no separate delivery_fee, bag_fee, etc. fields on cart submit. The script computes a single Cata-fee line as the remainder:

cataFee = round((order.totalPay - order.subtotal - order.payment.tip + order.discountTotal) * 100) / 100

If cataFee <= 0, omit serviceFees entirely. Alias defaults to "Cata Fee" when serviceFeeAlias setting is empty.

Payment type enum

Numeric, sent as paymentInfo[0].type:

Value Method
1 PAYPAL_MOBILE
2 CREDIT_CARD
3 PAYPAL
4 AT_STORE_OR_DELIVERY
5 GIFT
6 STANFORD
7 CREDIT_PLUS (default when no paymentTypeId setting)
8 STRIPE_APPLEPAY

Cata's payment.method (CARD / CASH / WALLET / …) is not mapped to this enum directly — the outlet picks one numeric value via paymentTypeId and the script always uses it. This avoids per-order enum-mapping bugs at the cost of all orders looking like the same payment type to Revel.

Free orders

If order.payment is missing or order.payment.amount <= 0, omit the paymentInfo block entirely.

Conventions (this section retained from V1)

  • HMAC on Path B is skipped. The script's returned headers are the only auth — no X-Cata-Signature is added.
  • No external_order_id field is sent on cart-submit. Revel does not accept one on this endpoint. To reconcile, parse the Revel response's orderId and persist it on the dispatch row (handled in Go, V2).
  • No order status field is sent on submit. Revel sets the status itself; status updates happen via the order_status_update topic later.

Response handling

Revel returns a JSON body shaped roughly:

{ "status": "OK", "orderId": 987654, ... }

Or on error:

{ "status": "ERROR", "error": "PRODUCTS_NOT_AVAILABLE_FOR_SELECTED_TIME", ... }

The Go pipeline (internal/connectors/dispatch_pipeline.go) does not parse this V1 — on the transformer path it drains the body without parsing. V2 will introduce a script-side parseResponse hook to extract orderId and detect PRODUCTS_NOT_AVAILABLE_FOR_SELECTED_TIME for auto-cancel semantics.

Known uncertainties remaining for sandbox

These are open even after the legacy reference:

  1. Content-Type: text/plain parity — verified in legacy logs, but worth confirming that the cart-submit endpoint also accepts application/json (would simplify the script). Until confirmed, stick with text/plain.
  2. callName formatting — legacy uses customer first name with fallback to order ref number. Edge cases (single-token names, special characters) need real-Revel testing.
  3. Idempotency — does Revel deduplicate cart submits if we replay the same (establishmentId, ref_number)? Legacy doesn't rely on this (it tracks external_order_id itself). For our dispatch retry story, sandbox should confirm.

Topic: order_status_update

Transforms an inbound Revel webhook payload into a Cata-standard status update. Mirrors the legacy kds-management-service path (service/RevelService/Webhook.go: processOrderFinalized) so partner-observed semantics stay identical across the legacy and new flows.

The Cata status contract (full enum PAID / ACCEPTED / IN PROGRESS / READY / DRIVER PICKED UP / COMPLETED / CANCELLED and the transition rules) is the standard — see order_status_update output contract. Each provider's script translates that provider's webhook surface into the contract.

Revel webhook surface

Revel only sends one inbound webhook for the order lifecycle, regardless of intermediate state changes:

Revel event Header X-Revel-Event-Type When
order.finalized order.finalized Order is closed in Revel (orderInfo.closed = true)

There are no ACCEPTED / IN PROGRESS / READY / DRIVER PICKED UP signals from Revel — that's a property of Revel's webhook API, not a temporary scope. Other POS providers (Atlas Kitchen, Lightspeed, etc.) emit more granular events; their scripts will return the corresponding Cata statuses. The Cata OrderStatusService accepts the full status set from any provider — Revel is simply limited at the source.

Status mapping

Revel inbound condition Script output
order.finalized + closed=true + CATA dining option { status: "COMPLETED", posOrderId, timestamp }
order.finalized + dining option NOT in {pickup, delivery, eatin}DiningOption { skip: true, reason: "non-CATA dining option N" }
order.finalized + closed=false (defensive — shouldn't fire) { skip: true, reason: "..." }
orderInfo missing or id unparseable throws

Dining-option filter

Revel fires order.finalized for every closed order at the establishment, including in-store dine-in orders that never went through CATA. Without filtering we would 404 on external_id lookup. The script therefore reads the outlet's CATA dining-option IDs from the same outlet-provider settings record used by order_dispatch.js:

Setting Source Notes
pickupDiningOption outlet_providers.settings.pickupDiningOption numeric Revel dining-option ID
deliveryDiningOption outlet_providers.settings.deliveryDiningOption numeric Revel dining-option ID
eatinDiningOption outlet_providers.settings.eatinDiningOption numeric Revel dining-option ID

At least one must be configured. If payload.orderInfo.dining_option doesn't match any configured CATA dining option, the script returns the skip sentinel (see adapter-script-contracts.md).

Fields read from payload

The script reads only the minimal slice of the Revel payload it needs:

Path Type Use
orderInfo.id int Stringified into posOrderId (the Revel-side order ID, matches what we wrote to pos_intgr_orders.external_id on dispatch).
orderInfo.dining_option int CATA filter (above).
orderInfo.closed bool Must be true to map to COMPLETED.
orderInfo.updated_date string Becomes timestamp in the output. Falls back to created_date.

history, items, payments, shell_combo_items are intentionally ignored in V1 — only the close signal matters for status. Any of those fields may be useful in V2 (e.g. payments for refund detection).

Samples: order_status_webhook.json (3 scenarios: CATA order completed, non-CATA skipped, defensive open order) → order_status_output.json. Order dispatch samples: order_dispatch_input.jsonorder_dispatch_output.json.


Changelog

Track Revel API changes that affect this integration. Newest first.

2026-05-18 — Knowledge base realigned to built-in transformers

  • Source of change: none on Revel's side — this was a documentation audit against the production-validated scripts under scripts/adapters/revel/.
  • order_dispatch: corrected to the single POST /specialresources/cart/submit flow (text/plain, snake_case orderInfo, skin:"weborder" not "WebPortal", customMenuInfo always present, bundles via items[].is_combo:true + products_sets[], empty arrays as null). Replaced the old 3-step Order/OrderItem/OrderPayment description.
  • order_status_update: removed the polling + 6-status state machine; documented the real surface (single order.finalizedCOMPLETED, {skip:true} for non-CATA dining options and closed=false).
  • sync_outlet: endpoint corrected /resources/Establishment//enterprise/Establishment/; documented the two pagination envelopes.
  • sync_products: max page 250 (not 1000); documented the CustomMenu-scoped flow and getBatch fan-out; bundle min/max from ComboProductSet.min_quantity/ max_quantity; inputType emits "MULTIPLE"; modifier-option empty-SKU fallback "RMOD_<id>".
  • Price format: confirmed decimal float, not cents — corrected the earlier "divide by 100" note in this guide.

Verification log

Topic Last verified Environment Notes
sync_outlet 2026-05-18 Adapter audit test_connection + list_remote_outlets against /enterprise/Establishment/; full sync_outlet not yet built
sync_products 2026-05-18 Adapter audit all-products + CustomMenu fetch, PMC modifier discovery, ComboProductSet bundle sections
order_dispatch 2026-05-18 Legacy kds-management-service production path single cart-submit, text/plain, full body shape incl. bundle products_sets[]
order_status_update 2026-05-18 Legacy kds-management-service production path inbound order.finalizedCOMPLETED; {skip:true} sentinel

Open uncertainties (for sandbox)

  • Exact auth header / API-key generation console path.
  • Current rate limit (this guide cites 300/min; expert-revelsystem cited ~200/min per establishment — neither vendor-confirmed).
  • EstablishmentBusinessHours endpoint shape (blocks full sync_outlet).
  • Cart-submit response parsing (orderId, PRODUCTS_NOT_AVAILABLE_FOR_SELECTED_TIME) — V2.
  • Whether a bundle product can also carry modifierGroups[] (currently [] for bundle parents).
  • Timestamp timezone (UTC vs establishment-local).