Revel Systems — Adapter Guide¶
Provider Info¶
| Field | Value |
|---|---|
| Slug | revel |
| Auth Type | api_key |
| Base URL | Per-tenant (e.g. https://xyz.revelup.com) |
| API Docs | Revel Open API (requires partner access) |
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¶
- Prices are in cents (integer) — divide by 100 for Cata's decimal format
- IDs are integers — convert to strings with
String(id) - Establishment
idis the primary location identifier - Timestamps are in the establishment's local timezone, not UTC
- Pricing flexibility: Same PLU can have different pricing per establishment (unlike Lightspeed)
- Fee handling: Revel has its own fee structure in orders — different from Deliverect and Lightspeed
- Bundle items: Items that exist only inside a bundle should NOT be required as standalone products in a menu section
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¶
TODO — Transform Revel Establishment → Cata outlet format.
API Call¶
GET /enterprise/Establishment/{id}/
Field Mapping (planned)¶
| Revel Field | Cata Field | Notes |
|---|---|---|
id |
externalId |
Convert to string |
name |
name |
|
address |
address.street1 |
|
city |
address.city |
May not be separate field |
phone |
contact |
|
timezone |
timezone |
Already IANA format |
Topic: sync_products¶
TODO — Transform Revel Product catalog → Cata flat products.
API Calls¶
GET /products/Product/?limit=250&offset=0
GET /resources/MenuCategory/?limit=250&offset=0
Field Mapping (planned)¶
| Revel Field | Cata Field | Notes |
|---|---|---|
Product.id |
itemCode |
Convert to string |
Product.name |
name |
|
Product.description |
description |
|
Product.price |
basePrice |
Divide by 100 (cents → decimal) |
Product.active |
visible |
|
MenuCategory.id |
modifierGroups[].itemCode |
If used as modifier |
MenuCategory.name |
modifierGroups[].name |
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).