Phone Number Guide
Read first: General Requirements · Loyalty Flows
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:
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.
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, 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 §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, andsubscribedToPushNotificationsvalues you put in the request. The screen texts, masks, and consent rules come from the outlet configuration covered in Kiosk and POS Configuration.
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
subscribedToPushNotificationsflag 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 — 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
{
"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 ofqrCode.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 tofalsewhen 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.
Note: Identification priority. If your request carries more than one of
qrCode,phoneNumber, andpublicClientId, the in-store API resolves them in the fixed orderqrCode→phoneNumber→publicClientId. To use the phone number-based flow, send a non-emptyphoneNumberand noqrCode. If none of the three is sent, the request is rejected with message code503(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:
{
"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
loyaltyPlantIdis 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, apayment, and a numericmessages[]catalog. When present, add reward items to the order (each arrives with a 100%LoyaltyPlantdiscount that zeroes its line), apply the discounts, register anypayment(keeping itstransactionIdto echo inorder-closed), and display everymessages[]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 — 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.
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:
{
"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-closedfields, defined on theOrderClosedRequestschema and used since 1.6 — even though the publicorder-closedprotocol page does not list them. They are documented in the spec:phoneNumber,subscribedToPushNotifications, andclientNameonOrderClosedRequestin In-Store Loyalty API specification.
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(orrefund), 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
phoneNumberandqrCodein the same request and expecting the phone branch. ✅ For the phone number-based flow, sendphoneNumberand leaveqrCodeempty —qrCodewins 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 like5551234567is accepted (no509) and passed downstream unchanged, which can create or match an account on a non-international key. Code509fires whenever the in-store API cannot parse the input: empty, containing any separator (spaces, dashes, brackets — so555-123-4567is rejected), non-numeric, or outside 6–15 digits (e.g.123orabc). - ❌ 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-closedafter sending them inqr-code. ✅ EchophoneNumber(andsubscribedToPushNotifications/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'sconfig.loyalty.phoneNumberRegistration.enabledconfig 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 for the runnable derivation.
Note: The base URL and credentials are provided by LoyaltyPlant during onboarding; substitute them for the placeholders below.
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 and Message & Error Codes. 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
509and510are listed on the specMessageschema and produced by the current service for this flow; they are catalogued in Message & Error Codes. Always display thetextthe in-store API sends rather than hard-coding strings against the numeric code — thetextis localized and authoritative.
Not part of this flow —
publicClientId: If you seeaccepted: falsewith a generic code500on apublicClientIdrequest, that belongs to thepublicClientIdflow (returning customers you already identify), not the phone number-based flow. The likely cause is that a storedpublicClientIdwas not found, or belongs to a different client. Distinguish the two causes by the messagetext, not the code. See Message & Error Codes §3.