Skip to content

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 id is 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 (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).