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 underscripts/adapters/revel/(how it works). The older direct Go integration (service/RevelService/…inkds-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-revelsystemrepo, 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_connectionandlist_remote_outletsare built (they read/enterprise/Establishment/); a fullsync_outlet(addresses + opening hours →POST /api/v1/outlets/sync) is not yet implemented inscripts/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 VERIFICATION — EstablishmentBusinessHours shape not yet exercised |
Samples: sync_outlet_input.json →
sync_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=modifierclass→ProductModifier?product_modifier_class={pmcId} - bundle:
ComboProductSet/{setId}/?expand=products(set IDs parsed fromcombo_productsets[]URIs) - best-effort:
GET /products/ProductCategory/?limit=250→posCategoryname 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 |
true→1, false→0 |
LockAmount |
maxSelect |
default 1 |
| (derived) | inputType |
"SINGLE" when Forced && LockAmount===1, else "MULTIPLE" (not "MULTI") |
modifierclass.Sort |
sortNum |
default 0 |
Groups with
modifierclass.active === falseare 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 === falseare dropped.
Model 3 — Bundle (Group Combo)¶
is_combo > 0 → isBundle: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.json →
sync_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(statuscandidate) 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 deployv1.0.0to 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 undertext/plainContent-Type. Do not change toapplication/jsonwithout 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 name → first_name/last_name; map email → email, phone → phone_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_
{
"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: trueis the discriminator. Without it Revel ignoresproducts_sets[]and treats the row as a regular product. - Bundle parent's
pricecarries the full combo price; children are priced at0(or per-child surcharge if set onsubItems[].surcharge). - Children with the same
sectionItemCodeare merged into one product-set group;idcomes from parsing the integer suffix of"CPS_<int>";namecomes fromsubItems[].sectionName. - Children missing
sectionItemCodefall 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_idset 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
headersare the only auth — noX-Cata-Signatureis added. - No
external_order_idfield is sent on cart-submit. Revel does not accept one on this endpoint. To reconcile, parse the Revel response'sorderIdand 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_updatetopic 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:
Content-Type: text/plainparity — verified in legacy logs, but worth confirming that the cart-submit endpoint also acceptsapplication/json(would simplify the script). Until confirmed, stick withtext/plain.callNameformatting — legacy uses customer first name with fallback to order ref number. Edge cases (single-token names, special characters) need real-Revel testing.- Idempotency — does Revel deduplicate cart submits if we replay the same
(establishmentId, ref_number)? Legacy doesn't rely on this (it tracksexternal_order_iditself). 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.json →
order_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/submitflow (text/plain, snake_caseorderInfo,skin:"weborder"not"WebPortal",customMenuInfoalways present, bundles viaitems[].is_combo:true+products_sets[], empty arrays asnull). Replaced the old 3-stepOrder/OrderItem/OrderPaymentdescription. - order_status_update: removed the polling + 6-status state machine; documented
the real surface (single
order.finalized→COMPLETED,{skip:true}for non-CATA dining options andclosed=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 andgetBatchfan-out; bundle min/max fromComboProductSet.min_quantity/max_quantity;inputTypeemits"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.finalized → COMPLETED; {skip:true} sentinel |
Open uncertainties (for sandbox)¶
- Exact auth header / API-key generation console path.
- Current rate limit (this guide cites 300/min;
expert-revelsystemcited ~200/min per establishment — neither vendor-confirmed). EstablishmentBusinessHoursendpoint shape (blocks fullsync_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).