# Phone Number Guide

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

This guide walks you through the **phone number-based flow** on the **In-Store Loyalty API**: instead of presenting a QR code, the customer is identified by a phone number — either the customer enters it on the self-service kiosk, or the cashier enters it at the cash register / POS terminal — and the in-store API recognizes an existing loyalty account or creates one on the spot. The flow is available since protocol **1.6**.

This is a standalone integration: an outlet can run the phone number-based flow on its own — a QR-code integration is not a prerequisite.

The call sequence is the standard in-store cycle — only the identifier changes:

```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 (phoneNumber, SaltId, AuthorizationToken)
    API-->>POS: customer (existing or new), messages, rewards
    Note over POS: Apply any rewards, show messages
    POS->>API: POST /one-time-salt/1.7
    API-->>POS: saltId + salt
    POS->>API: POST /order-closed/1.7 (phoneNumber, SaltId, AuthorizationToken)
    API-->>POS: pointsReward
```

Each authenticated call is preceded by its own `one-time-salt` request; validation only **reserves** the loyalty transaction, and `order-closed` is what actually **credits** the points.

## 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`, …). The phone number-based flow is available since protocol **1.6**; 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 phone-specific steps below.

## 2. Step 2 — Capture the phone number

### 2.1 General

Capture the number on whichever surface the customer is at. On the **self-service kiosk**, the customer types their own number; on the **cash register / POS terminal**, the cashier asks for the number and enters it. Both surfaces feed the same `phoneNumber` into the same request — the in-store API does not care which surface captured it.

Whether the surface is offered at all is a per-outlet setting: the whole feature can be switched off via `config.loyalty.phoneNumberRegistration.enabled`. When phone-number registration is disabled, do not offer the entry surface — and if a request still reaches the in-store API, it is rejected with code `510` (see §7). This toggle, together with the consent and phone-field settings below, is configured per outlet and confirmed with LoyaltyPlant during onboarding; they belong to a kiosk-loyalty design that is **not** served by the core configuration endpoint in the current build (see [Kiosk and POS Configuration](06-kiosk-and-pos-configuration.md) §4 for what that endpoint does return).

> **Note:** Entering a phone number is always optional for the customer. A customer who declines simply completes the order with no loyalty transaction — exactly as a sale without a scan.
> **Note:** The phone-number entry surface and the marketing opt-in are entirely separate from the in-store API's contract. The in-store API only ever sees the resulting `phoneNumber`, `clientName`, and `subscribedToPushNotifications` values you put in the request. The screen texts, masks, and consent rules come from the outlet configuration covered in [Kiosk and POS Configuration](06-kiosk-and-pos-configuration.md).

### 2.2 Phone number capture

On the **kiosk**, show the phone-number prompt **after the payment screen and before the success/order-number screen** — at the point where the customer has committed to the order and can be shown the points it would earn as the incentive to share their number.

At the **cash register / POS terminal**, the cashier asks for the number and types it into the terminal — for example, when a customer who paid at a kiosk is directed to the register, or when ordering at the counter.

What the entry surface must offer, on the kiosk screen and at the POS terminal alike:

- **A phone-number field.** Send the number in **international format** with a leading `+` and country code, e.g. `+15551234567`. The in-store API expects an international number; the entry mask is yours to render.
- **A way to decline.** A "no thanks" path that completes the order without sending anything to the in-store API.

The phone field itself is configured per outlet: the default country and any additional allowed countries (for international customers), and the entry mask. The mask must **not** be mandatory — a customer entering a number from another country, whether on the kiosk or via the cashier at the POS / cash register, must still be able to submit it.

**Best practices (especially for self-service kiosks).** On a kiosk the phone-number prompt is the one moment you have the customer's attention with the order already placed — earn the number, don't demand it:

- **Confirm the order, unmistakably.** Make it obvious the order has been placed — ideally a large order / queue number the customer carries to the pickup area ("Your order number"). The phone-number prompt sits on top of that confirmation, never in place of it.
- **Show the points at stake, and frame them as already earned.** State how many points *this* order would earn if the customer leaves their number — e.g. "You'll get N points for your order. Redeem them for free rewards in the app" — and lean on loss aversion: these are points the customer has *already earned* and gives up by skipping. The product's own copy does exactly this, down to a decline button worded "I don't want free rewards" rather than a neutral "Skip."
  - **Compute N from the client's earn rule.** The points figure follows a simple per-client formula — e.g. one point per N currency units, with a defined rounding mode — set up for each restaurant chain during onboarding (not returned by the configuration endpoint). Use it to show the exact number the customer stands to gain.
  - **Use LoyaltyPlant's point icon.** Render the points figure with the point-icon asset LoyaltyPlant provides, so the symbol on the kiosk matches the one the customer sees in the app.
- **Preview the rewards they could unlock.** Alongside the points, show a banner of enticing rewards available in the app together with their cost in points, so an abstract "N points" maps to something the customer actually wants.

### 2.3 Consent

Two consent touchpoints belong to this flow and are both **configured per outlet**, not hard-coded in your client. They apply to whichever surface captures the number — the kiosk screen and the POS / cash register alike:

- **Legal consent** — the privacy-policy / terms text shown on the phone-entry surface, the linked documents, and whether the customer must explicitly tick a box to proceed.
- **Marketing opt-in** — whether a marketing-consent screen is shown, on which channels, and how its answer maps to the `subscribedToPushNotifications` flag you send to the in-store API (Step 3). The one-step ("all or nothing") and two-step ("claim then offer") consent layouts differ in *when* that flag is captured.

## 3. Step 3 — Validate via `processQrCode`

Once the customer has entered a number — on the kiosk, or via the cashier at the POS / cash register — fetch a fresh salt and call [`processQrCode`](/in-store/api/) — `POST {baseUrl}/qr-code/1.7` — with `SaltId` and `AuthorizationToken` headers. The endpoint is shared with the QR flow; you simply send `phoneNumber` instead of `qrCode`. Even though no QR code is involved, the `qr-code` request is still the entry point that creates the in-store order.

### 3.1 Request

```json
{
  "phoneNumber": "+15551234567",
  "subscribedToPushNotifications": true,
  "clientName": "Jane",
  "orderId": "68acf80c0d0a7e6d2fd19946",
  "total": 5,
  "posInfo": {
    "establishmentId": 5259,
    "terminalId": "650326fe75bec93f9e01f316",
    "terminalCurrentTimestamp": 1756166153871,
    "terminalTimeZone": "GMT-5"
  },
  "order": {
    "menuItems": [
      { "itemId": "11804", "title": "Blueberry Lavender Lemonade", "price": 5, "quantity": 1 }
    ]
  }
}
```

The fields specific to this flow:

- **`phoneNumber`** — the customer's number in international format. Send this **instead of** `qrCode`.
- **`clientName`** — optional display name used when an account is created. If omitted, the account is created with a default name. Only meaningful when the in-store API creates a new account.
- **`subscribedToPushNotifications`** — the customer's marketing opt-in, captured on the entry surface. Defaults to `false` when omitted.

Everything else describes the order at validation time, exactly as in any in-store validation request: `orderId` is your POS order identifier — send the **same** value later in `order-closed`, which the in-store API uses to correlate the two requests; `total` is the order's current total at scan time (any pre-applied POS-side discounts are already subtracted, with the points payment never deducted — the same monetary basis as `orderTotal` at close); and `posInfo.terminalCurrentTimestamp` is the terminal's current UTC time in **milliseconds** (a 13-digit value, e.g. `1756166153871`, not a 10-digit seconds value). Field-level definitions live in `QrCodeRequest_v1_7` in [In-Store Loyalty API specification](/in-store/api/).

> **Note:** Identification priority. If your request carries more than one of `qrCode`, `phoneNumber`, and `publicClientId`, the in-store API resolves them in the fixed order `qrCode` → `phoneNumber` → `publicClientId`. To use the phone number-based flow, send a non-empty `phoneNumber` and **no** `qrCode`. If none of the three is sent, the request is rejected with message code `503` (QR code invalid).

### 3.2 Response and user resolution

The response is the standard `processQrCode` response. On `/qr-code/1.7` the `customer` block follows `Customer_v1_7`, so it also carries the nullable best-effort `customerTier` and `customerPointsBalance` fields (`null` when the tier/balance enrichment lookup is unavailable — the common case for a freshly created account, and possible for an existing one too; treat them as optional display data and never gate the flow on their presence). The `customer.loyaltyPlantId` it returns tells you which account the phone number resolved to:

```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": 41234567,
    "customerName": "Jane",
    "customerEmail": null,
    "customerTier": null,
    "customerPointsBalance": null
  },
  "payment": null,
  "hasEverRewardedPoints": false,
  "maxPointsPercent": null
}
```

The phone number-based flow works whether or not the customer already has a loyalty account, and the in-store API resolves the number for you:

- **If a customer with that phone number already exists in LoyaltyPlant**, the loyalty points accrue to that existing account, and its `loyaltyPlantId` is returned — there is nothing more for the customer to do.
- **If no account matches the number**, the in-store API creates a new account for it and returns the new `loyaltyPlantId`. The customer claims the points — and the order history — by installing the LoyaltyPlant app and **verifying the same phone number**.

Your integration does not branch on this — it sends the same closure either way. The resolution is entirely on the LoyaltyPlant side; the `loyaltyPlantId` is informational.

> **Note:** What ultimately happens to that account is **LoyaltyPlant business behavior**, not part of the protocol, and it plays out *after* you close the order (Step 4): the claim — installing the app and verifying the number — happens entirely in the LoyaltyPlant app, and the in-store API exposes no webhook for it. A customer who declined to leave a number has no account created and completes an ordinary sale (they can still install the app later via any install-link QR you show). Your only responsibility is to send a valid `phoneNumber` (and, if you captured it, `subscribedToPushNotifications`) and close the order.

As always, a business rejection is **not** an HTTP error: the in-store API returns HTTP 200 with `accepted: false` and the reason as an `error`-type entry in `messages[]`. Inspect `messages[]` and display its `text`. The phone-specific rejections are `509` and `510` (see §7).

> **Note:** The phone number-based flow may also return reward `menuItems`, `discounts`, a `payment`, and a numeric `messages[]` catalog. When present, add reward items to the order (each arrives with a 100% `LoyaltyPlant` discount that zeroes its line), apply the discounts, register any `payment` (keeping its `transactionId` to echo in `order-closed`), and display every `messages[]` entry. In the common case the order earns points only, so these arrays are usually empty.

## 4. Step 4 — Close the order

When the order is paid and closed, fetch a fresh salt and call [`orderClosed`](/in-store/api/) — `POST {baseUrl}/order-closed/1.7`. This is what credits the points to the resolved (or newly created) account; the response `pointsReward` is the number actually credited. The standard `order-closed` rules apply unchanged: reuse the exact `orderId` you sent in `qr-code`, report `orderTotal` **without** deducting any points payment (it is listed in `payments`, not subtracted from the total), and close each order exactly once. Field-level definitions live in `OrderClosedRequest` in [In-Store Loyalty API specification](/in-store/api/).

The phone number-based flow adds three **optional** request fields — `phoneNumber`, `subscribedToPushNotifications`, and `clientName` — that you should echo into the closure so the same identity and consent travel with the finalized order:

```json
{
  "phoneNumber": "+15551234567",
  "subscribedToPushNotifications": true,
  "clientName": "Jane",
  "establishmentId": 5259,
  "orderId": "68acf80c0d0a7e6d2fd19946",
  "orderTotal": 5,
  "order": {
    "menuItems": [
      { "itemId": "11804", "title": "Blueberry Lavender Lemonade", "price": 5, "quantity": 1 }
    ],
    "discounts": []
  },
  "waiter": { "posId": "650326fe75bec93f9e01f316", "name": "BITE" },
  "payments": []
}
```

> **Note:** These three phone fields are real `order-closed` fields, defined on the `OrderClosedRequest` schema and used since 1.6 — even though the public `order-closed` protocol page does not list them. They are documented in the spec: `phoneNumber`, `subscribedToPushNotifications`, and `clientName` on `OrderClosedRequest` in [In-Store Loyalty API specification](/in-store/api/).

The response is the standard `order-closed` response — `accepted`, `pointsReward`, `pointsSpent`. The authoritative success signal is the boolean `accepted`, **not** `errorCode` (`errorCode` is `0` in both the accepted and the rejected cases); when `accepted: false`, the rejection reason is in `errorText`. Always branch on `accepted`.

> **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 and no points are credited. Guarantee the closing call in every code path, including crashes and offline recovery.

## 5. Common mistakes

- ❌ **Sending `phoneNumber` *and* `qrCode` in the same request** and expecting the phone branch. ✅ For the phone number-based flow, send `phoneNumber` and leave `qrCode` empty — `qrCode` wins the priority order.
- ❌ **Relying on the in-store API to reject a local-format number** so you can skip client-side normalization. ✅ Always send international format with a leading `+`, e.g. `+15551234567` — **your client must normalize**. The in-store API's format check accepts a bare digit string with an optional leading `+`, so a digits-only local number like `5551234567` is *accepted* (no `509`) and passed downstream unchanged, which can create or match an account on a non-international key. Code `509` fires whenever the in-store API cannot parse the input: empty, containing any separator (spaces, dashes, brackets — so `555-123-4567` **is** rejected), non-numeric, or outside 6–15 digits (e.g. `123` or `abc`).
- ❌ **Enforcing the entry mask** so customers from other countries cannot submit on the kiosk or have the cashier enter it. ✅ Treat the mask as guidance only; accept numbers that do not fit it.
- ❌ **Dropping the phone fields from `order-closed`** after sending them in `qr-code`. ✅ Echo `phoneNumber` (and `subscribedToPushNotifications` / `clientName`) into the closure.
- ❌ **Offering the phone-entry surface on an outlet where the flow is disabled** — the call comes back with code `510`. ✅ Honor the outlet's `config.loyalty.phoneNumberRegistration.enabled` config and fall back to QR-only.
- ❌ **Treating a customer with an account vs one without as different code paths.** ✅ Send the same validate-then-close sequence; the in-store API resolves or creates the account for you and reports the result in `customer.loyaltyPlantId`.
- ❌ **Requiring the customer to enter a phone number** to finish ordering. ✅ Always offer a decline path on both the kiosk and at the cash register / POS terminal; phone entry is optional.

## 6. Test transcript

A complete phone number-based session against a test environment. Requires `curl`, `jq`, and `shasum`. The salt handshake and `AuthorizationToken` computation are the same for every authenticated call — see [General Requirements §2](02-general-requirements.md#2-authentication--the-keystone) for the runnable derivation.

> **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>"
PHONE="+15551234567"
ORDER_ID="test-phone-001"

# --- 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 by phone number (no qrCode) ---
curl -s -X POST "$BASE_URL/qr-code/1.7" \
  -H "Content-Type: application/json" \
  -H "SaltId: $SALT_ID" \
  -H "AuthorizationToken: $TOKEN" \
  -d '{
    "phoneNumber": "'"$PHONE"'",
    "subscribedToPushNotifications": true,
    "clientName": "Jane",
    "orderId": "'"$ORDER_ID"'",
    "total": 5,
    "posInfo": {
      "establishmentId": '"$ESTABLISHMENT_ID"',
      "terminalId": "test-terminal-1",
      "terminalCurrentTimestamp": 1766337067000,
      "terminalTimeZone": "GMT-5"
    },
    "order": {
      "menuItems": [
        { "itemId": "11804", "title": "Blueberry Lavender Lemonade", "price": 5, "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 (echo the phone fields) ---
curl -s -X POST "$BASE_URL/order-closed/1.7" \
  -H "Content-Type: application/json" \
  -H "SaltId: $SALT_ID" \
  -H "AuthorizationToken: $TOKEN" \
  -d '{
    "phoneNumber": "'"$PHONE"'",
    "subscribedToPushNotifications": true,
    "clientName": "Jane",
    "establishmentId": '"$ESTABLISHMENT_ID"',
    "orderId": "'"$ORDER_ID"'",
    "orderTotal": 5,
    "order": {
      "menuItems": [
        { "itemId": "11804", "title": "Blueberry Lavender Lemonade", "price": 5, "quantity": 1 }
      ],
      "discounts": []
    },
    "waiter": { "posId": "test-terminal-1", "name": "Test Cashier" },
    "payments": []
  }' | jq .
```

Expected: the validation returns `accepted: true` with a `customer.loyaltyPlantId` (the resolved or newly created account); the closure returns `accepted: true` with `pointsReward` for the credited points.

## 7. Troubleshooting

The phone number-based flow shares all the transport-, auth-, and field-validation failures common to every endpoint (HTTP 403 / 400 / 503 and the `accepted` semantics) — see [General Requirements §2](02-general-requirements.md#2-authentication--the-keystone) and [Message & Error Codes](08-message-and-error-codes.md). The two failures unique to this flow:

| Symptom | Likely cause | Fix |
|---|---|---|
| HTTP 200, `accepted: false`, `messages[]` entry with code `509` (`PHONE_NUMBER_WRONG_FORMAT`) | The phone number is empty/blank, contains separators (spaces, dashes, brackets), is non-numeric, or is outside 6–15 digits. Note: a *digits-only* local number (no separators) passes this check — the in-store API does **not** reject it for you. | Show the returned `text`; re-prompt. Normalize on your side to international `+<country><number>` before sending — do not depend on `509` to catch a local-format number. |
| HTTP 200, `accepted: false`, `messages[]` entry with code `510` (`PHONE_FLOW_DISABLED`) | The phone number-based flow is not enabled for this outlet. | Do not retry. Hide the phone-entry surface for this outlet and fall back to QR-only; contact LoyaltyPlant if it should be enabled. |

> **Note:** Codes `509` and `510` are listed on the spec `Message` schema and produced by the current service for this flow; they are catalogued in [Message & Error Codes](08-message-and-error-codes.md). Always display the `text` the in-store API sends rather than hard-coding strings against the numeric code — the `text` is localized and authoritative.

> **Not part of this flow — `publicClientId`:** If you see `accepted: false` with a generic code `500` on a `publicClientId` request, that belongs to the `publicClientId` flow (returning customers you already identify), not the phone number-based flow. The likely cause is that a stored `publicClientId` was not found, or belongs to a different client. Distinguish the two causes by the message `text`, not the code. See [Message & Error Codes §3](08-message-and-error-codes.md).

---

**Next:** [Kiosk and POS Configuration →](06-kiosk-and-pos-configuration.md)
