> ## Documentation Index
> Fetch the complete documentation index at: https://docs.loyalty.xeniamoments.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Loyalty basket flow

> The synchronous basket call: sequence, latency contract, timeouts and retries, fallback, statuses, the no-change response, the calculation GUID and basket changes after calculation.

<Check>
  **Agreed.** The basket call is **synchronous** (REKOM request 31 Aug 2026, OnlinePOS
  acceptance 4 Sep 2026). There is no `basketId`, no polling and no callback. The
  checkout waits on the REKOM backend's response. If the response does not arrive
  in time or is invalid, the sale proceeds with the original basket, without a new
  card approval, and the order's **loyalty status** is set to `failed`. The sale itself
  completes normally.
</Check>

<Note>
  **Directional.** Field names, enum values, headers and JSON structures on this page
  show *which* information is exchanged, not the final names. None of them has been
  aligned with OnlinePOS yet. REKOM will adopt whatever OnlinePOS has by default for
  the order object and its webhooks, and does not require any of these specific names.
</Note>

## Sequence

```mermaid theme={null}
sequenceDiagram
  autonumber
  participant S as Staff
  participant P as POS / mPOS
  participant T as Terminal (BAXI) / Softpay
  participant B as OnlinePOS backend
  participant R as REKOM backend

  S->>P: Ring up products, press card payment
  P->>T: Start card payment (prepurchase as today)
  T-->>P: loyaltyId (or not found)
  Note over P: Generate calculationId (GUID)
  P->>B: basket + loyaltyId + calculationId
  B->>R: POST /v1/onlinepos/basket (Idempotency-Key = calculationId)
  activate R
  R-->>B: 200 applied (full basket) / unchanged / member_not_found
  deactivate R
  B->>B: Validate schema + arithmetic
  B-->>P: Final basket (returned, or original on failure)
  P->>T: Authorise final amount — single approval
  T-->>P: Approved
  P->>B: Finalise transaction (stores calculationId)
  B->>R: POST /v1/onlinepos/sale (status, transactionId)
  R-->>B: 200
```

<Steps>
  <Step title="Card payment starts">
    Staff press the normal card-payment button. The terminal (BAXI/Viking) or
    Softpay performs the Nexi Engage lookup as part of the prepurchase step and
    returns a loyalty ID, or nothing. See [Member identification](/architecture/member-identification).
  </Step>

  <Step title="POS generates the calculation GUID">
    POS/mPOS generate a **GUID per basket calculation** (`calculationId`), send it
    to the REKOM backend with the basket, store it on the order and expose it
    through the REST API as a meta attribute (*agreed 7 Oct 2026; meta attribute
    mechanism agreed after 7 Oct*). The GUID is the only correlation key
    the two sides need.
  </Step>

  <Step title="OnlinePOS backend calls the REKOM backend">
    `POST /v1/onlinepos/basket` with the loyalty ID, the full basket, the context
    (venue, BAX number, terminal, clerk, channel) and the GUID. The basket is
    OnlinePOS's own object exactly as OnlinePOS has it, including the discounts
    already on it; REKOM returns the same object, modified. The BAX number and
    the terminal identifier are required so REKOM can attribute every order to a
    venue and a till (*agreed after 7 Oct 2026*). `Idempotency-Key` equals the GUID.
    The call is signed; see [Configuration and security](/architecture/configuration-and-security).
  </Step>

  <Step title="REKOM backend answers synchronously">
    One of three outcomes, always HTTP `200`:
    `applied` with the full basket, `unchanged` with a reason, or `member_not_found`.
    Anything else (4xx, 5xx, timeout, malformed body) is a failure.
  </Step>

  <Step title="OnlinePOS validates and swaps the basket">
    OnlinePOS checks schema and arithmetic identities only (see
    [Basket rules → Validation](/architecture/basket-rules#validation-in-onlinepos)).
    On success the POS replaces its basket with the returned one. On any failure it
    keeps the original basket.
  </Step>

  <Step title="Single approval on the final amount">
    The terminal authorises the final amount once: either the original amount or
    the discounted one. The guest, who tapped once at step 1, is not asked to do
    anything else. BAXI prepurchase is used as it is today; the integration requires
    no change to the payment flow (*agreed 6 Oct 2026*).
  </Step>

  <Step title="Post-sale event">
    After the transaction is finalised, the OnlinePOS backend sends exactly one
    `POST /v1/onlinepos/sale` for the GUID. See
    [Post-sale and reconciliation](/architecture/post-sale-and-reconciliation).
  </Step>
</Steps>

## Latency contract

<Info>
  **Proposed — for OnlinePOS confirmation.** The numbers below are REKOM's proposal.
  They have to fit inside the window the BAXI prepurchase step gives the POS between
  card read and authorisation, which OnlinePOS and Nexi own (see
  [open points](/reference/open-points)).
</Info>

| What | Value | Measured where |
| - | - | - |
| REKOM backend response time, p95 | ≤ 150 ms | Server processing plus network to OnlinePOS's hosting |
| REKOM backend response time, p99 | ≤ 250 ms | Same |
| `Server-Timing` header | `app;dur=<ms>` on every response | Lets both sides separate server time from network time when investigating |

### Proposed POS client behaviour

| Setting | Proposal | Rationale |
| - | - | - |
| Timeout per attempt | **500 ms** | Twice the p99 target, so a slow but valid response is not thrown away by a tight timeout. |
| Retry | **One** retry, and only when the connection failed **before the request was sent** (DNS, TCP connect, TLS). Same `Idempotency-Key`. | A retry after the request was sent could cross with a response in flight; the idempotent server makes it safe but it costs time with no benefit. |
| Hard cap | **3 s** from first attempt to decision | Beyond that the POS uses the original basket. Leaves room for the single connect-retry plus a slow response. |
| On failure | Original basket, status `failed`, `failureReason` set in the sale event | Loyalty never blocks checkout. |
| Connection reuse | Keep-alive / HTTP/2 to the REKOM host | Removes TLS handshake time from the hot path. |

The V1 text ("retries up to three times with the same idempotency key, within an
agreed overall maximum timeout") is superseded by the proposal above because
three sequential attempts cannot fit the payment window.

## Fallback

| Situation | POS behaviour | Sale event |
| - | - | - |
| Timeout, connection error, TLS error | Original basket; no new approval | `failed` / `timeout` or `connection_error` |
| HTTP 4xx or 5xx from the REKOM backend | Original basket | `failed` / `http_error` |
| HTTP 200 but schema or arithmetic validation fails | Original basket | `failed` / `invalid_response` |
| POS-side error while swapping the basket | Original basket | `failed` / `pos_error` |
| POS offline (no backend) | No lookup, no pricing; sale completes offline | `failed` / `offline`, sent when the order is replayed |
| Response `unchanged` | Original basket | `unchanged` |
| Response `member_not_found` | Original basket | `member_not_found` |
| Response `applied` | Returned basket | `applied` |

In every row the guest approves exactly once, on whatever amount the POS ends up
with. Staff are not asked anything.

## Order loyalty status

OnlinePOS marks each order with one loyalty status (*agreed 12 Aug 2026; `unchanged`
kept through the no-change response, 7 Oct 2026*). The status describes the loyalty
step only and never the sale: an order with loyalty status `failed` is a completed,
paid sale with the original basket.

| Status | Meaning |
| - | - |
| `not_applicable` | No loyalty flow ran: module off, cash payment, or no card lookup possible by design. |
| `member_not_found` | Lookup returned no member, or the REKOM backend did not recognise the loyalty ID. |
| `applied` | The returned basket was used and contains at least one loyalty discount. |
| `unchanged` | The REKOM backend answered that nothing applies; original basket used. |
| `failed` | The call failed or the response was rejected; original basket used. |

## The no-change response

When the member is known but no benefit applies, the REKOM backend returns status
`unchanged` with a machine-readable `reason` and **no lines** (*agreed 7 Oct 2026*).
The POS keeps its basket untouched. Reasons are informational for support and
never shown to the guest:

| `reason` (directional values) | When |
| - | - |
| `no_applicable_benefits` | Member has no benefit matching anything in the basket. |
| `all_lines_excluded` | Every eligible line is flagged `noDiscount` or already carries a better discount than loyalty would give. |
| `member_inactive` | Membership exists but is paused, expired or blocked. |
| `basket_too_small` | A minimum-spend rule is not met. |

Invariant: a response with status `applied` always changes the basket in at least
one way (a discount, an order discount, a price update, an added, removed or split
line). If the calculation changes nothing, the REKOM backend returns `unchanged`.

## The calculation GUID

| Rule | Detail |
| - | - |
| Who generates it | POS/mPOS, one GUID per basket calculation (*agreed 7 Oct 2026*). |
| Format | UUID, lower-case, 36 characters with hyphens. |
| Where it travels | `calculationId` in the basket request, `Idempotency-Key` header, `calculationId` in the sale event, stored on the order and exposed by the REST API as a meta attribute. |
| Replaces | `loyaltyDiscountReference`, dropped 28 Aug 2026. |
| Support use | Staff or REKOM support look up a transaction by GUID on either side. |

## Idempotency

All calls carry an idempotency key / request ID and retries never create duplicates
or double discounts (*agreed 12 Aug 2026*).

* The REKOM backend stores the response for each `Idempotency-Key` for 24 hours.
  A repeat with the same key and the same body gets the stored response.
* The same key with a **different** body is answered `409 idempotency_conflict`.
  The POS treats this as `failed`; it indicates a POS bug, not a transient fault.
* A new basket calculation always gets a new GUID (below).

## Basket change after calculation

<Info>**Proposed — for OnlinePOS confirmation.**</Info>

If the basket changes after a calculation but before authorisation (staff add a
drink, void a line), the POS:

1. generates a **new** GUID,
2. calls `POST /v1/onlinepos/basket` again with the changed basket and
   `supersedesCalculationId` set to the previous GUID,
3. reuses the `loyaltyId` already resolved. The guest does **not** tap again.

The REKOM backend releases anything reserved for the superseded calculation and
sends no sale event expectation for it. The POS sends the sale event for the GUID
that was actually finalised; the superseded one needs no event.

If the payment is abandoned after a calculation, the POS sends a sale event with
status `cancelled` for the last GUID. Calculations that receive neither a sale
event nor a superseding calculation **expire after 30 minutes** on REKOM's side
and are treated as abandoned. A late sale event for an expired calculation is
still accepted and reconciled.

## What the call never does

* Ask the guest or staff anything, or wait for input.
* Reserve, authorise or in any way touch the payment.
* Depend on a previous call: every request contains the full basket.
* Return VAT, or add a product that does not exist on the venue.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.