Skip to main content

Menu Sync Guide

Read first: Overview and Concepts · General Requirements · Ordering Flows

This guide is for the engineer building the menu side of a Digital Ordering API integration. It walks you through the two menu endpoints you hostGET /rest/menu/revision and GET /rest/menu — and the menu model LoyaltyPlant expects back. Remember the inverted contract: you implement and host these endpoints; LoyaltyPlant is the HTTP client that polls them on a schedule. Field-level detail (every type, every required-ness rule, every enum) lives in the Digital Ordering API specification — this guide walks the structure in prose and links to it by schema name.

The two operations you build here:

Operation (you host)operationIdPurpose
GET /rest/menu/revisiongetMenuRevisionLightweight "has the menu changed?" probe — returns a single revision number.
GET /rest/menugetMenuThe full menu for one outlet: categories, modifier groups, modifiers, items, prices.

1. Prerequisites and how LoyaltyPlant syncs

Before a customer can order, LoyaltyPlant must hold an up-to-date copy of what you sell. It keeps that copy fresh by polling your menu endpoints on a schedule, per outlet — you never push the menu to LoyaltyPlant. The polling service is part of LoyaltyPlant; for the purposes of this contract, treat every menu request as coming from "LoyaltyPlant".

Before you start, you need from LoyaltyPlant (provided at onboarding):

ItemDescription
{vendorBaseUrl}The HTTPS base URL of the service you host. LoyaltyPlant appends /rest/menu and /rest/menu/revision to it. Must be reachable from LoyaltyPlant over TLS.
SalesOutletIdThe numeric outlet id LoyaltyPlant sends on every menu request. You resolve it to the right outlet and menu.
Request key (apiKey)The shared key behind the request AuthorizationToken you validate on each call. LoyaltyPlant sends SHA256-hex(apiKey).
Response key (apiKeyResponse)The second, distinct key you use to sign each menu response. You return SHA256-hex(apiKeyResponse) in the response AuthorizationToken header.

The sync loop, end to end:

The key point of the diagram: the revision pre-check is a bandwidth optimization in front of the full menu load, never a gate. LoyaltyPlant pulls the full GET /rest/menu when any of these is true, and skips it only when the revision is unchanged:

  • the revision changed since the last successful sync, or
  • there is no cached revision yet (first sync for the outlet), or
  • the returned revision is lower than the cached value (treated as changed, for consistency), or
  • the revision pre-check failed for any reason — LoyaltyPlant then fails open to a full GET /rest/menu. A broken optimization path never blocks menu sync.

A few operating facts to design for:

  • Each outlet has its own menu. Every request carries the SalesOutletId header; resolve it to the correct outlet, menu, and prices. The same brand may serve different menus at different outlets (see §4).
  • Freshness has a lag. A price change or a new item reaches customers only after the next successful sync; the sync interval is configured on the LoyaltyPlant side, per outlet.
  • Omitting an item removes it. There is no "delete" call — an item absent from the next GET /rest/menu response is automatically marked unavailable and hidden from customers. That is how a sold-out item disappears.
  • Generous timeouts — but be fast. LoyaltyPlant applies a connect timeout of 30 s and a read timeout of 180 s to both menu calls (much longer than the 5 s / 60 s used for orders), because a full menu can be large. Respond well within them.

Note: Whether LoyaltyPlant calls GET /rest/menu/revision at all is a per-outlet toggle on the LoyaltyPlant side (revisionCheckEnabled). When it is off, LoyaltyPlant pulls the full menu every cycle and never calls your revision endpoint. Implement both endpoints regardless — the toggle is configured at onboarding, and enabling it later requires no change on your side.

Authentication for both calls is identical and is covered in full in General Requirements. In short: each request carries SalesOutletId + an AuthorizationToken equal to SHA256-hex(apiKey), which you validate; each response must carry an AuthorizationToken header equal to SHA256-hex(apiKeyResponse), or LoyaltyPlant discards the response and treats the sync as failed.

2. Step 1 — Implement GET /rest/menu/revision

Implement getMenuRevisionGET {vendorBaseUrl}/rest/menu/revision. It returns only the current menu revision so LoyaltyPlant can decide, cheaply, whether to re-pull the full menu. There is no request body; resolve the outlet from the SalesOutletId header.

Response body:

{
"revision": 1727191974
}

The contract for revision:

  • It is an epoch timestamp (Long, seconds). Newer menu state → higher number.
  • It must equal menu.revision returned by GET /rest/menu. The two endpoints must agree at any moment — that equality is the whole point of the optimization.
  • Bump it whenever the menu content changes (item added/removed, price or modifier change, availability change) and not otherwise. If it never changes, customers never see updates; if it changes on every call, the optimization is defeated and LoyaltyPlant re-pulls the full menu every cycle. A common, safe implementation is to set it to the Unix time of the last menu edit.

Note: The caching contract is one-directional: LoyaltyPlant caches the last revision per outlet and compares — you do not need to track what LoyaltyPlant last fetched. Just return a number that goes up when, and only when, the menu changes. A revision that ever moves backward makes LoyaltyPlant do a full re-load (it treats a decrease as a change), so prefer a monotonic source like epoch seconds.

The response must carry the same AuthorizationToken response header as GET /rest/menu — see General Requirements.

3. Step 2 — Implement GET /rest/menu

Implement getMenuGET {vendorBaseUrl}/rest/menu. It returns the full menu for the outlet in the v2 shape. There is no request body; resolve the outlet from SalesOutletId. LoyaltyPlant imports this and maps your ids to its own catalog.

3.1 The top-level v2 envelope

The whole catalog is nested under a menu key, alongside a server-generated requestId (GetMenuResponseV2MenuAggregatorV2).

GetMenuResponseV2
├── requestId (UUID, your correlation id)
└── menu (MenuAggregatorV2)
├── categories[] (root + nested, ≤ 2 levels)
├── modifierGroups[] (reusable group prototypes)
├── modifiers[] (the single modifier pool)
├── items[] (sellable items)
└── revision (epoch Long — equals GET /rest/menu/revision)

The model is reference-based: parents do not embed children inline; they point at them by refId. Items reference categories and modifier groups; groups reference modifiers; subcategory links reference categories. This avoids duplicating the same modifier or category across many items. Walk it top to bottom below; for exact field types, required-ness, and formats, follow the schema links into the spec.

3.2 Identity: refId, id, code

Every category, modifier group, modifier, and item shares four identity fields (BaseEntityV2): refId, id, code, title. The three id fields look similar and trip people up — keep them straight:

  • refId — response-scoped reference key. It exists only to link objects within one GET /rest/menu response. It must be unique inside a single response, and a parent references a child by the child's refId (an item names its category's refId; an embedded group names its prototype's refId via basedOn). It defaults to a UUID and may be reused across separate responses — do not treat it as a stable product id.
  • id — global product id in the POS. The same product carries the same id across outlets. This is how LoyaltyPlant recognizes "the same cheeseburger" at two locations. Required.
  • code — outlet-local product code. The product's code at this specific outlet; may differ between outlets. Required. If your POS has only one code per product, set code equal to id.

⚠️ Warning — id must be identical across outlets. A single product's id MUST be the same at every outlet that sells it: the same cheeseburger must carry the same id in every outlet's GET /rest/menu response. LoyaltyPlant keys centralised menu management, reward (loyalty) management, and marketing on this global id — if the same product is sent with different ids at different outlets, LoyaltyPlant treats them as different products and those features break across locations. (code is outlet-local and may differ; id may not.)

title and description are multilingual (see §3.6); sortOrder is an optional display-order hint (lower first).

3.3 Categories (≤ 2 levels; category XOR items)

menu.categories[] carries all categories — root and nested — in one flat array (MenuCategoryV2). Nesting is built with references, and two structural rules are hard constraints:

  • At most two levels of nesting. A root category may have subcategories, but a subcategory may not have its own subcategories. No sub-subcategories.
  • A root category contains either subcategories or items — never both. A root either groups (carries subCategories) or holds products (is referenced by items), not both.

Each category carries:

  • root (boolean) — whether this is a top-level category. Always serialized.
  • rootSortOrder — priority among root categories, numbered from 1 descending (1 = highest priority). Optional.
  • subCategories[] — present only on root categories that group subcategories. Each entry references the child by refId (the subCategory field) plus an optional sortOrder. The child category appears as its own full entry elsewhere in categories[]; the parent just points at its refId.
  • isPopup — see §3.7.

Note: Items attach to categories from the item side, not the category side. A category referenced by items does not list those items; instead each item carries a categories[] array naming the category's refId (see §3.5). A root category is "an items category" precisely because items point at it.

3.4 Modifiers and modifier groups (min/max/free, basedOn)

The Digital Ordering API separates the modifier pool from modifier groups, and groups come in two flavors — reusable prototypes and item-embedded groups:

  • menu.modifiers[] — the single modifier pool (ModifierV2). Every distinct modifier (e.g. "Extra bacon") is defined once here, with its own identity and pricesByOrderingType. Groups reference modifiers from this pool by refId; they never redefine them.
  • menu.modifierGroups[] — reusable group prototypes (ModifierGroupPrototypeV2). A prototype is a named, standalone group (e.g. "Extras") that many items share. An item's embedded group derives from a prototype by naming its refId in basedOn. Prototypes are optional — an item may embed a group with no basedOn at all.

Both group flavors carry the same selection constraints (ModifierGroupV2), which are scalar integers (not objects):

  • min — minimum number of modifiers the customer must select.
  • max — maximum number the customer may select.
  • free — number of modifiers included free; selections beyond this count are charged.

A group's modifiers[] lists its members, each referencing a pool modifier by refId (the modifier field) plus selected (pre-selected by default) and an optional sortOrder.

The basedOn override rule: an item-embedded group (ModifierGroupEmbeddedV2) that names a prototype in basedOn may override the prototype's min/max/free for that item. This lets one "Extras" prototype allow up to 3 toppings in general but only 2 (with 1 free) on a specific burger.

3.5 Items

menu.items[] carries the sellable products (MenuItemV2). Beyond the shared identity fields, an item carries:

  • pricesByOrderingType — a map keyed by ordering type. The keys are the OrderingType enum names, and each value is a Price. At least one entry is expected; an item may be priced for only some ordering types. Each Price is a decimal value plus an optional taxes array of tax percentages (an empty taxes: [] is common).
  • categories[] — the categories this item belongs to, each referencing a category by refId (the category field) plus an optional sortOrder for its position within that category.
  • modifierGroups[] — the groups embedded in this item, each optionally basedOn a prototype and optionally overriding its min/max/free (see §3.4).

Note: LoyaltyPlant maps each item to one price internally and treats EAT_IN as the primary price, falling back to the first available ordering-type price when EAT_IN is absent; the other ordering-type prices are stored as per-type extensions. So always include a price for every ordering type the item is sold under, and do not omit EAT_IN if the item is eaten in. An item with an empty id or empty title is skipped during import with a warning — both are effectively required for a usable item.

The full worked example — one root category, one modifier in the pool, one prototype group, one item that embeds the group with overridden min/max/free — is the MenuResponseExample on getMenu in the spec. Read it alongside this section.

3.6 Multilingual titles and descriptions

title and description are MultilingualTextobjects keyed by ISO 639-1 language code ("en", "es", "fr", …), not plain strings:

"title": { "en": "Cheeseburger", "es": "Hamburguesa con queso", "fr": "Hamburger au fromage" }

Provide at least one language for every required title; supply each language the outlet serves so customers see localized names. description is optional and follows the same shape.

3.7 isPopup (non-root only)

A non-root category may carry an optional isPopup boolean — a UI hint asking LoyaltyPlant to render the category as a popup. LoyaltyPlant evaluates it on every sync, so return the value you want each time you send the category:

  • Applied on every sync. isPopup is honored on every successful menu sync. isPopup: true on a non-root category renders it as a popup; isPopup: false renders it as an ordinary category and removes a popup that was set on a previous sync. The change takes effect on the next successful sync — so bump the menu revision when you change it, or the revision pre-check will skip the full load and the new value won't be seen.
  • Root categories ignore it. Sending isPopup: true on a root category does nothing except log a warning (category <id, name>: root categories cannot be popup). Only non-root categories honor it.
  • Return the desired state each sync. To keep a category shown as a popup, include isPopup: true on every sync; sending false — or omitting the field — yields an ordinary, non-popup category.

3.8 Empty collections

Per-entity, empty sub-objects are omitted from the wire (for example, an item with no modifier groups omits the field rather than sending []). At the top level of menu, however, the four collections may still appear as empty arrays — do not assume their absence. Send what you have; LoyaltyPlant tolerates both forms.

4. Per-outlet menus

Menu sync is per outlet, keyed by the SalesOutletId header on every menu request. Resolve that id to the correct outlet and return only that outlet's menu, prices, and availability. Two consequences shape your implementation:

  • Same product, same id everywhere; code may differ. Because id is the global product id, the same item must carry the same id at every outlet, while code is the outlet-local code (see §3.2). This is what lets LoyaltyPlant recognize a product across locations even when prices differ.
  • The revision is per outlet too. LoyaltyPlant caches one revision per SalesOutletId, so a menu change at a single outlet must bump only that outlet's revision — every other outlet keeps its current revision until its own menu changes. Conversely, a change rolled out chain-wide (e.g. a price update applied everywhere) should bump the revision of every affected outlet. Bumping an unaffected outlet forces a needless full re-pull; failing to bump an affected outlet leaves its customers on the stale menu.

5. Validation and test transcript

You can exercise both endpoints with curl before LoyaltyPlant is wired up. Stand up a mock that serves a fixed revision and a small v2 menu, then verify the request auth and the mandatory response header.

Note: The base URL, SalesOutletId, request key, and response key are provided by LoyaltyPlant at onboarding; substitute them for the placeholders below. The response AuthorizationToken is SHA256-hex(apiKeyResponse) — note the response key, distinct from the request key.

VENDOR_BASE_URL="{vendorBaseUrl}"
SALES_OUTLET_ID=1234
API_KEY="<your request apiKey>"
API_KEY_RESPONSE="<your response apiKeyResponse>"

# Token LoyaltyPlant would send on the request = SHA256-hex(apiKey)
REQ_TOKEN=$(printf '%s' "$API_KEY" | shasum -a 256 | cut -d' ' -f1)

# --- Step 1: revision pre-check ---
curl -s -D - -X GET "$VENDOR_BASE_URL/rest/menu/revision" \
-H "Content-Type: application/json" \
-H "SalesOutletId: $SALES_OUTLET_ID" \
-H "AuthorizationToken: $REQ_TOKEN"

# --- Step 2: full menu ---
curl -s -D - -X GET "$VENDOR_BASE_URL/rest/menu" \
-H "Content-Type: application/json" \
-H "SalesOutletId: $SALES_OUTLET_ID" \
-H "AuthorizationToken: $REQ_TOKEN" | jq .

What to check in the responses:

  • /rest/menu/revision returns {"revision": <epoch>} and a 200, and the response carries an AuthorizationToken header equal to SHA256-hex(apiKeyResponse) (-D - prints headers so you can see it).
  • /rest/menu returns {"requestId": ..., "menu": {...}}, and its menu.revision equals the number the revision endpoint returned a moment ago. The response also carries the AuthorizationToken header.
  • Cross-check the menu body against §3: unique refIds within the response, ≤ 2 levels of categories, no root mixing subcategories and items, every referenced refId present, every priced entity carrying at least one pricesByOrderingType entry.

If you see repeated full menu loads when the menu is stable, your revision number is changing when it should not.

6. Common mistakes

  • Reusing a refId for two different objects within one response. References resolve wrong or collide, corrupting the import. ✅ Make every refId unique inside each GET /rest/menu response (a fresh UUID per object is safest); refId only needs to be unique within that one response.
  • Nesting categories more than two deep (a subcategory with its own subcategories). ✅ Keep to root → subcategory only; flatten anything deeper.
  • Mixing subcategories and items under one root. A root that both lists subCategories and is referenced by items violates the model. ✅ A root either groups subcategories or holds items — never both.
  • Returning a stale or never-changing revision while the menu actually changed — customers keep seeing the old menu because LoyaltyPlant skips the full load. ✅ Bump revision (e.g. to the edit's epoch time) on every menu change, and never below a value you already returned.
  • Making revision change on every call (e.g. now() each time) — defeats the optimization; LoyaltyPlant re-pulls the full menu every cycle. ✅ Tie revision to actual menu content, not to request time.
  • Letting /rest/menu/revision disagree with menu.revision. ✅ Both must return the same number at any moment — back them with one source of truth.
  • Omitting the response AuthorizationToken header on a menu response — LoyaltyPlant discards the whole response and the sync fails. ✅ Sign every /menu and /menu/revision response with SHA256-hex(apiKeyResponse).
  • Sending isPopup only once and expecting it to persist. isPopup is evaluated on every sync. ✅ Return isPopup: true on every sync for categories you want shown as popups (non-root only); false or omitting it makes them ordinary categories (see §3.7).

7. Troubleshooting

SymptomLikely causeFix
Menu sync fails even though /rest/menu returns 200 with a valid bodyMissing or wrong response AuthorizationToken header — LoyaltyPlant discards the response.Return AuthorizationToken: SHA256-hex(apiKeyResponse) on every menu response (note the response key).
LoyaltyPlant pulls the full menu every cycle, never skippingrevision changes on every call (e.g. computed from now()), or /rest/menu/revision disagrees with menu.revision.Tie revision to actual menu content; serve the same number from both endpoints.
Menu changes never reach customersrevision did not change after the menu edit, so the full load was skipped.Bump revision on every menu change so the full menu is re-pulled.
An item is missing from the app after importThe item had an empty id or empty title and was skipped during import.Ensure every item has a non-empty id and at least one title language.
An item shows the wrong (or no) priceNo EAT_IN price and the first available ordering-type price was used as the primary, or the ordering type the customer chose has no pricesByOrderingType entry.Provide a price for every ordering type the item is sold under, including EAT_IN for dine-in.
isPopup change isn't reflected after a syncThe menu revision didn't change, so LoyaltyPlant skipped the full load and never saw the new value — or the category is a root category, which ignores isPopup.Bump revision whenever isPopup changes so the full menu is re-pulled; set isPopup only on non-root categories.
WARN … root categories cannot be popup in the logsisPopup: true was sent on a root category, which ignores it.Set isPopup only on non-root categories; remove it from roots.
A referenced category/modifier/group does not renderA refId referenced by an item, group, or subcategory link is not present as a full object in the same response.Include every referenced object as a full entry in the same GET /rest/menu response.

Next: Order Injection Guide →