# Menu Sync Guide

Read first: [Overview and Concepts](01-overview-and-concepts.md) · [General Requirements](02-general-requirements.md) · [Ordering Flows](03-ordering-flows.md)

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 host** — `GET /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](/digital-ordering/api/) — this guide walks the structure in prose and links to it by schema name.

The two operations you build here:

| Operation (you host) | `operationId` | Purpose |
|---|---|---|
| `GET /rest/menu/revision` | [`getMenuRevision`](/digital-ordering/api/) | Lightweight "has the menu changed?" probe — returns a single revision number. |
| `GET /rest/menu` | [`getMenu`](/digital-ordering/api/) | The 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):

| Item | Description |
|---|---|
| `{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. |
| `SalesOutletId` | The 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:

```mermaid
sequenceDiagram
    participant LP as LoyaltyPlant
    participant POS as YOUR service (you host /rest/*)

    loop Every sync cycle, per SalesOutletId
        opt Revision pre-check enabled
            LP->>POS: GET /rest/menu/revision
            POS-->>LP: { "revision": <epoch> }
            Note over LP: Compare against cached revision
        end
        alt Revision changed, no cache, lower than cache, or pre-check failed
            LP->>POS: GET /rest/menu
            POS-->>LP: full v2 menu
            LP->>LP: Import and map ids to its own catalog
        else Revision unchanged
            LP->>LP: Skip the full menu load
        end
    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](#4-per-outlet-menus)).
- **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](02-general-requirements.md). 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 [`getMenuRevision`](/digital-ordering/api/) — `GET {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:

```json
{
  "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](02-general-requirements.md).

## 3. Step 2 — Implement `GET /rest/menu`

Implement [`getMenu`](/digital-ordering/api/) — `GET {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` ([`GetMenuResponseV2`](/digital-ordering/api/) → [`MenuAggregatorV2`](/digital-ordering/api/)).

```text
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](/digital-ordering/api/).

### 3.2 Identity: `refId`, `id`, `code`

Every category, modifier group, modifier, and item shares four identity fields ([`BaseEntityV2`](/digital-ordering/api/)): `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 `id`s 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](#36-multilingual-titles-and-descriptions)); `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`](/digital-ordering/api/)). 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](#37-ispopup-non-root-only).

> **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](#35-items)). 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`](/digital-ordering/api/)). 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`](/digital-ordering/api/)). 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`](/digital-ordering/api/)), 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`](/digital-ordering/api/)) 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`](/digital-ordering/api/)). Beyond the shared identity fields, an item carries:

- **`pricesByOrderingType`** — a map keyed by ordering type. The keys are the [`OrderingType`](/digital-ordering/api/) enum names, and each value is a [`Price`](/digital-ordering/api/). 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](#34-modifiers-and-modifier-groups-minmaxfree-basedon)).

> **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`](/digital-ordering/api/) in the spec. Read it alongside this section.

### 3.6 Multilingual titles and descriptions

`title` and `description` are [`MultilingualText`](/digital-ordering/api/) — **objects keyed by ISO 639-1 language code** (`"en"`, `"es"`, `"fr"`, …), not plain strings:

```json
"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](#32-identity-refid-id-code)). 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.

```bash
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](#3-step-2--implement-get-restmenu): unique `refId`s 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](#37-ispopup-non-root-only)).

## 7. Troubleshooting

| Symptom | Likely cause | Fix |
|---|---|---|
| Menu sync fails even though `/rest/menu` returns `200` with a valid body | Missing 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 skipping | `revision` 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 customers | `revision` 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 import | The 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) price | No `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 sync | The 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 logs | `isPopup: 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 render | A `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 →](05-order-injection-guide.md)
