Skip to main content

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:

ItemDescription
establishmentIdNumeric ID of the establishment (sales outlet). Must be configurable per outlet on your side.
apiPrivateKeySecret key used to compute the AuthorizationToken header. Never sent over the wire.
Base URLThe 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 processQrCodePOST {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.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.
  • 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 qrCodephoneNumberpublicClientId; 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.
  • menuItemsreward 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.
  • hasEverRewardedPointsfalse 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 — 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 orderClosedPOST {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 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.
  • 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:

{
"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 acceptednot 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 (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 refundOrderPOST {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 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-codeorder-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 responseaccepted: 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-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.

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

SymptomLikely causeFix
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 tokenWrong 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 codeQR 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 stateThe 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 scanA 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 settledThe 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 responseTier/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 themselvesNeither 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 →