# QR Code Loyalty Guide

Read first: [General Requirements](02-general-requirements.md) · [Loyalty Flows](03-loyalty-flows.md)

This guide walks you through the core **In-Store Loyalty API** capability: **QR- or short-code-based point accrual and reward redemption**. The customer is identified by a loyalty **QR code** (or short code) — either the customer scans it at the self-service kiosk, or the cashier scans the customer's code at the cash register / POS terminal — and your integration applies the returned rewards to the order, then closes the order so the customer's points are credited.

The full call sequence you will build:

```mermaid
sequenceDiagram
    participant POS as Kiosk / cash register (POS)
    participant API as In-Store Loyalty API
    POS->>API: POST /one-time-salt/1.7
    API-->>POS: saltId + salt
    POS->>API: POST /qr-code/1.7 (SaltId, AuthorizationToken)
    API-->>POS: rewards, messages, customer, payment
    Note over POS: Apply rewards and discounts, show messages
    POS->>API: POST /one-time-salt/1.7
    API-->>POS: saltId + salt
    POS->>API: POST /order-closed/1.7 (SaltId, AuthorizationToken)
    API-->>POS: pointsReward / pointsSpent
    opt Order refunded
        POS->>API: POST /one-time-salt/1.7
        API-->>POS: saltId + salt
        POS->>API: POST /refund/1.7 (SaltId, AuthorizationToken)
        API-->>POS: accepted
    end
```

## 1. Step 1 — Prerequisites and authentication

Before you start, you need from LoyaltyPlant:

| Item | Description |
|---|---|
| `establishmentId` | Numeric ID of the **establishment** (sales outlet). Must be configurable per outlet on your side. |
| `apiPrivateKey` | Secret key used to compute the `AuthorizationToken` header. Never sent over the wire. |
| Base URL | The in-store API endpoint for your environment. |

> **Note:** The base URL is provided by LoyaltyPlant during onboarding. All examples in this guide use the `{baseUrl}` placeholder.

The protocol version is part of every URL path (`/qr-code/1.7`, `/order-closed/1.7`, …). New integrations should use **1.7**, the latest version, which this guide documents. Older versioned paths remain available for existing integrations — see [Versioning and Changelog](07-versioning-and-changelog.md).

All requests are HTTP `POST` with `Content-Type: application/json`.

**Authenticate every call.** Every authenticated call (`qr-code`, `order-closed`, `refund`) is signed per-request: fetch a fresh one-time salt and compute `AuthorizationToken = SHA256(apiPrivateKey + salt)`. This per-request salt is the single most important rhythm of the protocol. Implement it exactly as described in [General Requirements §2](02-general-requirements.md#2-authentication--the-keystone), which also includes a runnable curl example.

With that in place, continue with the QR-specific steps below.

## 2. Step 2 — Validate the QR code

When the customer scans their QR code at the self-service kiosk, or the cashier scans the customer's QR code at the cash register / POS terminal, immediately call [`processQrCode`](/in-store/api/) — `POST {baseUrl}/qr-code/1.7` — with fresh `SaltId` and `AuthorizationToken` headers.

### 2.1 Request

The request identifies the customer and describes the current order:

```json
{
  "qrCode": "1234567878901234567890",
  "orderId": "1234567",
  "total": 1234.3,
  "posInfo": {
    "establishmentId": 1234,
    "terminalId": "1234",
    "terminalCurrentTimestamp": 1466337067000,
    "terminalTimeZone": "GMT-6"
  },
  "order": {
    "menuItems": [
      {
        "itemId": "1234",
        "title": "Something",
        "price": 10.2,
        "quantity": 2
      }
    ]
  }
}
```

Key points:

- A QR code is valid for 5 minutes and can be used for exactly one validation.
- `posInfo.terminalCurrentTimestamp` is the terminal's current UTC time in **milliseconds** — a 13-digit value (e.g. `1466337067000`), not a 10-digit seconds value.
- `total` is the order's current total at scan time — the same monetary basis as `orderTotal` at close: any pre-applied (POS-side) discounts are already subtracted, with the points payment **never** deducted. It need not equal the line-item sum (it may include items, taxes, or service charges not itemized in `order`).
- `orderId` is your POS order identifier. You must send the same `orderId` later in `order-closed` — the in-store API correlates the two requests by it.
- `order` carries the order contents at scan time: items with prices, quantities, modifiers, and already-applied discounts. Field-level details live in the spec — see `QrCodeRequest_v1_7` in [cloudvalidator-api.yaml](/in-store/api/).
- Instead of `qrCode`, the request may identify the customer by `phoneNumber` (since 1.6) or by `publicClientId` (since 1.7). If several identifiers are sent at once, the in-store API applies them in priority order `qrCode` → `phoneNumber` → `publicClientId`; if none is sent, the request fails. The phone number-based flow — where the customer enters their number on the kiosk, or the cashier enters it at the POS / cash register — is covered in the [Phone Number Guide](05-phone-number-guide.md).

### 2.2 Response

```json
{
  "accepted": true,
  "menuItems": [
    {
      "itemId": "1234",
      "title": "Something",
      "price": 10.2,
      "quantity": 2
    }
  ],
  "discounts": [],
  "messages": [
    {
      "type": "info",
      "code": 201,
      "text": "Welcome customer by name: John"
    }
  ],
  "customer": {
    "loyaltyPlantId": 1234,
    "customerName": "John",
    "customerEmail": "johndoe@web.com",
    "customerTier": "Gold",
    "customerPointsBalance": 450
  },
  "payment": {
    "amount": 2.34,
    "transactionId": 1234,
    "type": "PAY_WITH_POINTS"
  },
  "hasEverRewardedPoints": true,
  "maxPointsPercent": 10
}
```

A business rejection (e.g. the QR code expired, was already used, or is not a LoyaltyPlant code) is **not** an HTTP error. The in-store API returns HTTP 200 with `accepted: false`, omits `menuItems`/`discounts`, and reports the reason as a single `error`-type entry in `messages[]`. There is no top-level `error` field — always inspect `messages[]`:

```json
{
  "accepted": false,
  "messages": [
    {
      "type": "error",
      "code": 503,
      "text": "QR code invalid"
    }
  ]
}
```

The `code` comes from the message catalog — `503` (QR code invalid), `504` (QR code already scanned), and `505` (QR code already validated) are the common QR rejections. The full catalog is on the `Message` schema in [cloudvalidator-api.yaml](/in-store/api/).

How to read it:

- `accepted` — whether the identification was processed successfully.
- `menuItems` — **reward** items that must be added to the order (see Step 3).
- `discounts` — order-level discounts that must be applied to the order.
- `payment` — a LoyaltyPlant payment (`PAY_WITH_POINTS` or `MOBILE_WALLET`) to register on the order. Keep its `transactionId`: it must be echoed in `order-closed`.
- `hasEverRewardedPoints` — `false` if the customer has never been rewarded points (useful for a "first points earned" prompt); `maxPointsPercent` — the maximum percentage of the order total that may be paid with points. Both are auxiliary display context.

### 2.3 Messages

`messages[]` is not diagnostic noise — each entry must be displayed to the cashier (or on the kiosk screen) so the customer sees the outcome of the scan. Each message has a `type` (`info`, `warn`, `error`), a numeric `code`, and a human-readable `text`. The code catalog is defined on the `Message` schema in [cloudvalidator-api.yaml](/in-store/api/) — for example, `201` means the loyalty card was accepted and `202` means a reward was added.

### 2.4 Customer name, tier and balance

> **Recommended:** after a successful validation (`accepted: true`), display the returned `customerName` and — for partners using tiered loyalty — `customerTier`, to personalize the moment (e.g. a greeting like *"Welcome back, John — Gold tier"*). Render each only when present, and never gate the flow on them.

Since 1.7, the `customer` block also carries `customerTier` (the loyalty tier name, e.g. `"Gold"`) and `customerPointsBalance` (current points balance), so the POS can show them at the moment of payment without extra calls.

Both fields are nullable and degrade gracefully: if the backing services are unavailable, they may be returned as `null` (and should be treated as optional display data) while the validation itself still succeeds (`accepted: true`). Never gate the flow on their presence.

## 3. Step 3 — Apply rewards on the POS

Everything the in-store API returned in Step 2 must now be reflected in the actual order:

1. **Add reward items.** Each entry in response `menuItems` is added to the order. Rewards arrive together with a discount that brings the line price to zero — a 100% discount (type `percent`, value `100`) with `discountId` `"LoyaltyPlant"`.
2. **Apply discounts.** Apply response `discounts` at order level and per-item discounts on the returned items.
3. **Register the payment.** If `payment` is present, register it on the order using your POS payment type for LoyaltyPlant, and store the `transactionId`.
4. **Show the messages.** Display every `messages[]` entry (see 2.3).

The echo rule: whatever rewards and discounts you applied from the validation response must appear again, unchanged, in the `order-closed` request. This is how the in-store API confirms the reward was actually used and finalizes its deduction from the customer's account. If a reward disappears between validation and closure, the redemption is left hanging and the customer's reward is not settled correctly.

> **Note:** The customer may still change the order after the scan — including removing a reward or declining the points payment. That is allowed: send `order-closed` with only the rewards that were actually used, and LoyaltyPlant automatically refunds the validated-but-unused ones.

## 4. Step 4 — Close the order

When the order is paid and closed on the POS, fetch a fresh salt and call [`orderClosed`](/in-store/api/) — `POST {baseUrl}/order-closed/1.7`. This is the request that actually credits points to the customer and settles redeemed rewards.

```json
{
  "establishmentId": 1234,
  "orderId": "1234567",
  "orderTotal": 12.35,
  "order": {
    "menuItems": [
      {
        "itemId": "1234",
        "title": "Something",
        "price": 10.2,
        "quantity": 2
      }
    ],
    "discounts": []
  },
  "payments": [
    {
      "type": "1",
      "amount": 2.34,
      "transactionId": "1234"
    }
  ],
  "waiter": {
    "posId": "2134",
    "name": "John Doe"
  }
}
```

Rules that matter:

- **Same `orderId`.** Use the exact `orderId` you sent in `qr-code`. A different or unknown `orderId` is rejected with an error like *"document not found or not in open state"*.
- **`orderTotal` semantics.** The total must be reported *without* deducting the amount paid with points — the points payment is listed in `payments`, not subtracted from the total. The value of reward items, by contrast, *is* deducted (their lines are zero after the 100% discount).
- **`transactionId` echo and uniqueness.** For a LoyaltyPlant payment, `payments[i].transactionId` must equal the `transactionId` from the `qr-code` response, and each points-payment transaction ID must be unique — otherwise points deduction is processed incorrectly.
- **Full final contents.** Send the complete final order — all items including rewards, all discounts, all payments. Field-level details: `OrderClosedRequest` in [cloudvalidator-api.yaml](/in-store/api/).
- **One closure per order.** `order-closed` is accepted once. A repeated `order-closed` for the same `orderId` is **not** a `506`/`ORDER_ALREADY_CLOSED` — that code is a `processQrCode`-time outcome (scanning a QR against an already-closed order). A second `order-closed` comes back as a business rejection: HTTP 200 with `accepted: false` and `errorCode: 0` (`errorText` "document not found or not in open state"), because the POS document is no longer in the open state. Branch on `accepted`, not on an error code, and treat that rejection on a retry as confirmation the first closure already landed.

> **Warning:** Do not set the request field `pointsReward` — it overrides LoyaltyPlant's calculated points award and is reserved for special cases agreed with LoyaltyPlant.

The response reports the loyalty outcome:

```json
{
  "accepted": true,
  "pointsReward": 10,
  "pointsSpent": 20,
  "errorCode": 0,
  "errorText": ""
}
```

`pointsReward` is the number of points credited, `pointsSpent` the number deducted. The authoritative success signal is the boolean `accepted` — **not** `errorCode`. `errorCode` is `0` in *both* the accepted and the rejected cases, so `errorCode == 0` does not mean success; when `accepted: false`, the rejection reason is in `errorText`. Always branch on `accepted` (the per-code catalog and the golden rule are in [Message & Error Codes](08-message-and-error-codes.md)).

> **Warning:** Always finish the cycle. If a validated order is never followed by `order-closed` (or `refund`), the transaction is annulled automatically after about a day: no points are credited, and points spent on rewards are eventually returned. This breaks the customer experience and must not happen in normal operation.

## 5. Step 5 — Refunds

If a closed order is refunded on the POS, fetch a fresh salt and call [`refundOrder`](/in-store/api/) — `POST {baseUrl}/refund/1.7`:

```json
{
  "establishmentId": 5259,
  "orderId": "68acf80c0d0a7e6d2fd19946"
}
```

The refund reverts the loyalty effects of the order: points credited for it are taken back, and points the customer spent on rewards are restored.

> **Note:** Only full refunds are supported. There is no partial-refund operation — a partially refunded order cannot be partially reverted in the loyalty program.

The response mirrors `order-closed` without the points fields: `accepted`, plus `errorCode`/`errorText` on failure.

## 6. Common mistakes

- ❌ **Reusing a salt** for a second request — fails with HTTP 403 `Token expired.` ✅ One fresh `one-time-salt` call per authenticated request.
- ❌ **Hashing in the wrong order** (`salt + apiPrivateKey`) or including the request body in the hash — fails with `Unauthorized: illegal auth token`. ✅ `SHA256(apiPrivateKey + salt)`, key first, body excluded.
- ❌ **Swallowing `messages[]`** — the cashier never sees that a reward was added or a card accepted. ✅ Display every message returned by `qr-code`.
- ❌ **Closing with a different `orderId`** than the one validated — rejected as *document not found or not in open state*. ✅ Reuse the `qr-code` request's `orderId` verbatim.
- ❌ **Dropping the reward from `order-closed`** — the redemption hangs and the reward is not settled. ✅ Echo the reward item with its 100% discount in the final order contents.
- ❌ **Sending a fresh `transactionId`** for the points payment instead of the one from the `qr-code` response — the deduction cannot be matched. ✅ Echo `payment.transactionId` exactly.
- ❌ **Subtracting the points payment from `orderTotal`** — points are over- or under-credited. ✅ Report the total without deducting points payments; only reward value is deducted.
- ❌ **Sending `order-closed` twice** (e.g. on a retry without checking the first response) — and then **expecting a `506`**. The repeat comes back as HTTP 200 with `accepted: false` and `errorCode: 0` ("document not found or not in open state"), *not* as `506`/`ORDER_ALREADY_CLOSED` (`506` is the `processQrCode` scan-time code). ✅ Close each order exactly once; branch on `accepted` and read an already-closed rejection on retry as "the first closure landed".

## 7. Worked examples by flow

The two QR flows use the **same** call sequence (`qr-code` → `order-closed`) but differ in what
the validation returns and what you must echo at closure. Side-by-side request/response pairs for
each follow — match your integration against the one that fits the scenario.

### 7.1 Bonus card — point accrual

The everyday flow: the customer is identified and earns points on the purchase; the validation
returns **no** reward items or discounts.

**`qr-code` request:**

```json
{
  "qrCode": "1234567878901234567890",
  "orderId": "order-7001",
  "total": 18.00,
  "posInfo": {
    "establishmentId": 5259,
    "terminalId": "pos-1",
    "terminalCurrentTimestamp": 1766337067000,
    "terminalTimeZone": "GMT-5"
  },
  "order": {
    "menuItems": [
      { "itemId": "burger", "title": "Cheeseburger", "price": 9.00, "quantity": 2 }
    ]
  }
}
```

**`qr-code` response** — `accepted: true`, empty `menuItems`/`discounts`, an `info` message to
display (code `201`):

```json
{
  "accepted": true,
  "menuItems": [],
  "discounts": [],
  "messages": [
    {
      "type": "info",
      "code": 201,
      "text": "Purchase with loyalty card confirmed. Points will be added after the order is closed."
    }
  ],
  "customer": {
    "loyaltyPlantId": 1234,
    "customerName": "John"
  },
  "hasEverRewardedPoints": true,
  "maxPointsPercent": 10
}
```

**`order-closed` request** — the plain order, no rewards and no `payments`. `orderTotal` is the
amount actually paid:

```json
{
  "establishmentId": 5259,
  "orderId": "order-7001",
  "orderTotal": 18.00,
  "order": {
    "menuItems": [
      { "itemId": "burger", "title": "Cheeseburger", "price": 9.00, "quantity": 2 }
    ],
    "discounts": []
  },
  "payments": [],
  "waiter": { "posId": "pos-1", "name": "Test Cashier" }
}
```

**`order-closed` response** — points credited, none spent:

```json
{
  "accepted": true,
  "pointsReward": 18,
  "pointsSpent": 0,
  "errorCode": 0,
  "errorText": ""
}
```

### 7.2 Reward redemption — a free item

The customer redeemed a reward in the app before paying. The validation returns the **reward
item** with a 100% discount that zeroes its line; you add it to the order and echo it back,
unchanged, at closure. Points are still accrued on the rest of the order.

**`qr-code` request** — same shape as accrual (the redeemed reward rides on the customer's QR
code, not in the request):

```json
{
  "qrCode": "9876543210987654321098",
  "orderId": "order-7002",
  "total": 18.00,
  "posInfo": {
    "establishmentId": 5259,
    "terminalId": "pos-1",
    "terminalCurrentTimestamp": 1766337090000,
    "terminalTimeZone": "GMT-5"
  },
  "order": {
    "menuItems": [
      { "itemId": "burger", "title": "Cheeseburger", "price": 9.00, "quantity": 2 }
    ]
  }
}
```

**`qr-code` response** — a reward `menuItems` entry arrives with a 100% per-item discount
(`type: percent`, `value: 100`, `discountId` from your configuration) and a `202` "reward added"
message:

```json
{
  "accepted": true,
  "menuItems": [
    {
      "itemId": "free-fries",
      "title": "Free Fries",
      "price": 3.50,
      "quantity": 1,
      "discounts": [
        { "type": "percent", "value": 100, "discountId": "LoyaltyPlant" }
      ]
    }
  ],
  "discounts": [],
  "messages": [
    {
      "type": "info",
      "code": 202,
      "text": "Reward added: Free Fries"
    }
  ],
  "customer": {
    "loyaltyPlantId": 1234,
    "customerName": "John"
  },
  "hasEverRewardedPoints": true,
  "maxPointsPercent": 10
}
```

**`order-closed` request** — the reward line is echoed **unchanged**, with its 100% discount, so
its contribution is zero; `orderTotal` stays the amount paid for the rest of the order (the reward
line nets to zero — do not add it to the total):

```json
{
  "establishmentId": 5259,
  "orderId": "order-7002",
  "orderTotal": 18.00,
  "order": {
    "menuItems": [
      { "itemId": "burger", "title": "Cheeseburger", "price": 9.00, "quantity": 2 },
      {
        "itemId": "free-fries",
        "title": "Free Fries",
        "price": 3.50,
        "quantity": 1,
        "discounts": [
          { "type": "percent", "value": 100, "discountId": "LoyaltyPlant" }
        ]
      }
    ],
    "discounts": []
  },
  "payments": [],
  "waiter": { "posId": "pos-1", "name": "Test Cashier" }
}
```

**`order-closed` response** — points are both credited (on the paid items) and spent (on the
redeemed reward):

```json
{
  "accepted": true,
  "pointsReward": 18,
  "pointsSpent": 150,
  "errorCode": 0,
  "errorText": ""
}
```

> **Note:** If the reward is dropped from the `order-closed` body, the redemption is left hanging
> and the reward is not settled (see §6). If the customer changes their mind and removes the reward
> before paying, simply omit it from `order-closed` — LoyaltyPlant returns the unused reward to
> their account.

## 8. Test transcript

A complete happy-path session against a test environment. Requires `curl`, `jq`, and `shasum`.

> **Note:** The base URL and credentials are provided by LoyaltyPlant during onboarding; substitute them for the placeholders below.

```bash
BASE_URL="{baseUrl}"
ESTABLISHMENT_ID=5259
API_PRIVATE_KEY="<your apiPrivateKey>"

# --- Salt #1: for the validation request ---
SALT_JSON=$(curl -s -X POST "$BASE_URL/one-time-salt/1.7" \
  -H "Content-Type: application/json" \
  -d "{\"establishmentId\": $ESTABLISHMENT_ID}")
SALT_ID=$(echo "$SALT_JSON" | jq -r '.saltId')
SALT=$(echo "$SALT_JSON" | jq -r '.salt')
TOKEN=$(printf '%s%s' "$API_PRIVATE_KEY" "$SALT" | shasum -a 256 | cut -d' ' -f1)

# --- Validate the QR code ---
curl -s -X POST "$BASE_URL/qr-code/1.7" \
  -H "Content-Type: application/json" \
  -H "SaltId: $SALT_ID" \
  -H "AuthorizationToken: $TOKEN" \
  -d '{
    "qrCode": "1234567878901234567890",
    "orderId": "test-order-001",
    "total": 15.2,
    "posInfo": {
      "establishmentId": '"$ESTABLISHMENT_ID"',
      "terminalId": "test-terminal-1",
      "terminalCurrentTimestamp": 1766337067000,
      "terminalTimeZone": "GMT-5"
    },
    "order": {
      "menuItems": [
        { "itemId": "1234", "title": "Something", "price": 10.2, "quantity": 1 }
      ]
    }
  }' | jq .

# --- Salt #2: a NEW salt for order-closed ---
SALT_JSON=$(curl -s -X POST "$BASE_URL/one-time-salt/1.7" \
  -H "Content-Type: application/json" \
  -d "{\"establishmentId\": $ESTABLISHMENT_ID}")
SALT_ID=$(echo "$SALT_JSON" | jq -r '.saltId')
SALT=$(echo "$SALT_JSON" | jq -r '.salt')
TOKEN=$(printf '%s%s' "$API_PRIVATE_KEY" "$SALT" | shasum -a 256 | cut -d' ' -f1)

# --- Close the order (same orderId as in qr-code) ---
curl -s -X POST "$BASE_URL/order-closed/1.7" \
  -H "Content-Type: application/json" \
  -H "SaltId: $SALT_ID" \
  -H "AuthorizationToken: $TOKEN" \
  -d '{
    "establishmentId": '"$ESTABLISHMENT_ID"',
    "orderId": "test-order-001",
    "orderTotal": 15.2,
    "order": {
      "menuItems": [
        { "itemId": "1234", "title": "Something", "price": 10.2, "quantity": 1 }
      ],
      "discounts": []
    },
    "payments": [],
    "waiter": { "posId": "test-terminal-1", "name": "Test Cashier" }
  }' | jq .
```

Expected: both calls return `"accepted": true`; the `order-closed` response includes `pointsReward` for the credited points. If the validation returned rewards, discounts, or a payment, add them to the `order-closed` body as described in Steps 3–4.

## 9. Troubleshooting

| Symptom | Likely cause | Fix |
|---|---|---|
| HTTP 403 `Token expired.` | Salt already used, or `SaltId` unknown. | Fetch a fresh salt immediately before each request; never reuse one. |
| HTTP 403 `Unauthorized: illegal auth token` | Wrong hash input — wrong key, wrong salt, or `salt + key` order. | Compute `SHA256(apiPrivateKey + salt)` as lowercase hex; verify the key with LoyaltyPlant. |
| HTTP 400 with JSON-pointer keys (e.g. `/order/menuItems/0/itemId`) | Request body fails schema validation — wrong types or enum values. | Fix the listed fields; check types against the spec (e.g. `itemId` is a string). |
| HTTP 200 with `accepted: false` and an error-type entry in `messages[]` (`type: error`, code `503`/`504`/`505`) about the QR code | QR code expired (5-minute validity), already used, or not a LoyaltyPlant code. | Inspect `messages[]` (there is no top-level `error` field); ask the customer to refresh the app and present a new QR code; display the returned message. |
| `order-closed` rejected: *document not found or not in open state* | The `orderId` was never validated via `qr-code`, or the order was already closed. | Send `order-closed` once, with the same `orderId` used in `qr-code`. |
| Message code `506` (`ORDER_ALREADY_CLOSED`) on a `qr-code` scan | A QR was **scanned** (`processQrCode`) against an order that is already closed — *not* a duplicate `order-closed` (a repeated `order-closed` instead returns `accepted: false`/`errorCode: 0`, see the row above). | Show the message; do not re-scan into a closed order. Deduplicate closure logic and treat the first successful `order-closed` response as final. |
| Reward redeemed in the app but never settled | The reward item was missing from the `order-closed` body. | Echo every reward from the validation response in the final order contents. |
| `customerTier` / `customerPointsBalance` missing from the response | Tier/balance enrichment degraded gracefully (backing service unavailable or no data). | Expected behavior — render these fields only when present; the validation itself succeeded. |
| Points not credited, spent points came back by themselves | Neither `order-closed` nor `refund` followed the validation; the transaction was auto-annulled after about a day. | Guarantee the closing call in every code path, including crashes and offline recovery. |

---

**Next:** [Phone Number Guide →](05-phone-number-guide.md)
