QR Code Loyalty Guide
Read first: General Requirements · Loyalty Flows
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:
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.
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 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 — 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:
{
"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.terminalCurrentTimestampis the terminal's current UTC time in milliseconds — a 13-digit value (e.g.1466337067000), not a 10-digit seconds value.totalis the order's current total at scan time — the same monetary basis asorderTotalat 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 inorder).orderIdis your POS order identifier. You must send the sameorderIdlater inorder-closed— the in-store API correlates the two requests by it.ordercarries the order contents at scan time: items with prices, quantities, modifiers, and already-applied discounts. Field-level details live in the spec — seeQrCodeRequest_v1_7in cloudvalidator-api.yaml.- Instead of
qrCode, the request may identify the customer byphoneNumber(since 1.6) or bypublicClientId(since 1.7). If several identifiers are sent at once, the in-store API applies them in priority orderqrCode→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.
2.2 Response
{
"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[]:
{
"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.
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_POINTSorMOBILE_WALLET) to register on the order. Keep itstransactionId: it must be echoed inorder-closed.hasEverRewardedPoints—falseif 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 — 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 returnedcustomerNameand — 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:
- Add reward items. Each entry in response
menuItemsis added to the order. Rewards arrive together with a discount that brings the line price to zero — a 100% discount (typepercent, value100) withdiscountId"LoyaltyPlant". - Apply discounts. Apply response
discountsat order level and per-item discounts on the returned items. - Register the payment. If
paymentis present, register it on the order using your POS payment type for LoyaltyPlant, and store thetransactionId. - 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-closedwith 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 — POST {baseUrl}/order-closed/1.7. This is the request that actually credits points to the customer and settles redeemed rewards.
{
"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 exactorderIdyou sent inqr-code. A different or unknownorderIdis rejected with an error like "document not found or not in open state". orderTotalsemantics. The total must be reported without deducting the amount paid with points — the points payment is listed inpayments, not subtracted from the total. The value of reward items, by contrast, is deducted (their lines are zero after the 100% discount).transactionIdecho and uniqueness. For a LoyaltyPlant payment,payments[i].transactionIdmust equal thetransactionIdfrom theqr-coderesponse, 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:
OrderClosedRequestin cloudvalidator-api.yaml. - One closure per order.
order-closedis accepted once. A repeatedorder-closedfor the sameorderIdis not a506/ORDER_ALREADY_CLOSED— that code is aprocessQrCode-time outcome (scanning a QR against an already-closed order). A secondorder-closedcomes back as a business rejection: HTTP 200 withaccepted: falseanderrorCode: 0(errorText"document not found or not in open state"), because the POS document is no longer in the open state. Branch onaccepted, 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:
{
"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).
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: 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 — POST {baseUrl}/refund/1.7:
{
"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 freshone-time-saltcall per authenticated request. - ❌ Hashing in the wrong order (
salt + apiPrivateKey) or including the request body in the hash — fails withUnauthorized: 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 byqr-code. - ❌ Closing with a different
orderIdthan the one validated — rejected as document not found or not in open state. ✅ Reuse theqr-coderequest'sorderIdverbatim. - ❌ 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
transactionIdfor the points payment instead of the one from theqr-coderesponse — the deduction cannot be matched. ✅ Echopayment.transactionIdexactly. - ❌ 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-closedtwice (e.g. on a retry without checking the first response) — and then expecting a506. The repeat comes back as HTTP 200 withaccepted: falseanderrorCode: 0("document not found or not in open state"), not as506/ORDER_ALREADY_CLOSED(506is theprocessQrCodescan-time code). ✅ Close each order exactly once; branch onacceptedand 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:
{
"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):
{
"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:
{
"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:
{
"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):
{
"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:
{
"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):
{
"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):
{
"accepted": true,
"pointsReward": 18,
"pointsSpent": 150,
"errorCode": 0,
"errorText": ""
}
Note: If the reward is dropped from the
order-closedbody, 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 fromorder-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.
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 →