General Requirements
Read first: Overview and Concepts
This page collects everything that is true of every In-Store Loyalty API call, so the
capability guides that follow can stay focused on one flow at a time: how requests travel, how
each one is authenticated, where your credentials and configuration come from, the
request/response conventions that apply across operations, and how to make your integration safe
to retry. Field-level truth — schemas, types, enums, per-operation examples — lives in the
OpenAPI specification (cloudvalidator-api.yaml); this page links
to it by operation and schema name and never restates schemas. This documentation describes protocol
v1.7, the current canonical version.
1. Transport and environments
All in-store API traffic is HTTP over TLS (HTTPS). LoyaltyPlant (LP) hosts the in-store API; your self-service kiosk or cash register / POS terminal is the HTTP client and initiates every call. The in-store API never calls into your system, and there are no webhooks to receive.
| Property | Value |
|---|---|
| Protocol | HTTPS only |
| Method | POST for every operation (salt, qr-code, order-closed, refund) |
| Request body | Content-Type: application/json |
| Response body | JSON |
| URL shape | {baseUrl}/{method}/{version} — e.g. {baseUrl}/qr-code/1.7 |
The protocol version is part of the URL path of every operation (/one-time-salt/1.7,
/qr-code/1.7, /order-closed/1.7, /refund/1.7). New integrations use 1.7; older
versioned paths remain available for existing integrations (see
Versioning and Changelog).
1.1 The base URL
Every example in this documentation set uses the {baseUrl} placeholder. Your real base URL is
assigned by LoyaltyPlant during onboarding — there is a separate URL for the test
environment you build against and for the production environment you go live on. Treat both
as opaque values from onboarding; do not hardcode any host you see in this documentation or in
the spec.
Note: The single
serversentry in the OpenAPI specification is illustrative — a sample host, not your production endpoint. Substitute the per-environment URL LoyaltyPlant gives you.
LoyaltyPlant provisions the test environment with a working loyalty setup — a bonus card and a small set of rewards (typically two points-rewards and one discount-reward) — plus a test Android app and a test plan, so you can exercise the full cycle before certification. The exact server addresses and test credentials come with your onboarding package.
2. Authentication — the keystone
This is the single most important section of this document. Every in-store API call except
getOneTimeSalt is authenticated per request with a fresh, single-use credential. Get this
rhythm right and the rest of the protocol follows; get it wrong and every call returns HTTP 403.
2.1 The salt → signed-call rhythm
Authentication is a two-step handshake repeated before each authenticated call:
- Fetch a one-time salt. Call
getOneTimeSalt—POST {baseUrl}/one-time-salt/1.7with{ "establishmentId": <id> }. The in-store API returns a SaltId (numeric) and a salt (string). This is the only operation that itself requires no authentication headers. - Sign the next call. Compute an AuthorizationToken from the salt and your
apiPrivateKey, then send both theSaltIdand theAuthorizationTokenas HTTP headers on theqr-code,order-closed, orrefundcall.
A salt is valid for exactly one authenticated request: the in-store API deletes the salt
record the moment it is consumed, so it can sign one call and no more. An unused salt also expires
server-side after ~3 minutes (180 s) — even if you never reuse it, an unconsumed salt held
across a slow code path (e.g. waiting on a card reader) lapses and the next call returns
Token expired.. Fetch a fresh salt immediately before each authenticated call — do not pool
salts, cache one across calls, or reuse one.
Warning: Reusing a
SaltId— or sending one that was never issued — is rejected with HTTP 403 and the body{"message": "Token expired."}. One freshone-time-saltcall per authenticated request, always.
2.2 Computing the AuthorizationToken (v1.4+)
For protocol versions 1.4 and later — which includes 1.7 — the token is the SHA-256 hash of
your apiPrivateKey concatenated with the salt, key first, encoded as lowercase hex. The request
body is not part of the hash.
authorizationToken = lowercase_hex( SHA256( apiPrivateKey + salt ) )
Warning: Hash the exact bytes of
apiPrivateKey + saltwith nothing appended. In a shell, useprintf, notecho—echoadds a trailing newline that becomes part of the hashed input and produces a wrong token. The same trap exists in any language: do not append a separator, newline, or terminator.
The two headers sent on every authenticated call:
SaltId: 12345
AuthorizationToken: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
The same token computation applies to processQrCode, orderClosed, and refundOrder — only
the salt changes between calls. Step-by-step
implementation, including a working shasum transcript, is in the
QR Code Loyalty Guide.
Note: Protocol versions 1.0–1.3 use a different, legacy scheme: an MD5
Signatureheader computed asMD5(requestBody + apiPrivateKey + salt), with the in-store API signing the response the same way. New integrations never use it — build on 1.7 withAuthorizationToken. It is mentioned here only so maintainers of an older integration recognize it; see Versioning and Changelog.
2.3 Authentication failures
When authentication fails, the in-store API responds with HTTP 403 and a JSON body
{"message": "..."} describing the reason — before the request reaches any loyalty logic. A 403 is never a loyalty
outcome; it is a transport/auth problem to fix and retry with a fresh salt.
| HTTP 403 message | Meaning |
|---|---|
Token expired. | The salt behind SaltId was already used or never existed. |
Unauthorized: illegal auth token | The AuthorizationToken hash does not match — wrong key, wrong salt, or wrong concatenation order. |
Unauthorized: required header "SaltId" is not set | Missing SaltId header. |
Unauthorized: required header "AuthorizationToken" is not set | Missing AuthorizationToken header. |
Note: Authentication failures return HTTP 403, as documented here, implemented by the service, and labelled in the OpenAPI spec (the
ForbiddenErrorresponse on the qr-code/order-closed/refund operations). Code for403.
Worked example: salt → token → first authenticated call (curl)
- Fetch a one-time salt. No auth headers on this call.
curl -s -X POST "{baseUrl}/one-time-salt/1.7" \
-H "Content-Type: application/json" \
-d '{"establishmentId": 5259}'
{"saltId":12345,"salt":"abcdefghijklmnop"}
- Compute the token. Hash
apiPrivateKey + saltover the exact bytes —printf, notecho(a trailing newline would corrupt the hash). CarrySaltIdandsaltfrom the response above.
SALT_ID=12345
SALT="abcdefghijklmnop"
TOKEN=$(printf '%s%s' "$API_PRIVATE_KEY" "$SALT" | shasum -a 256 | cut -d' ' -f1)
- Make the first authenticated call. Send
SaltIdand the computedAuthorizationTokenas headers. Success is HTTP 200 — read the body and branch onaccepted(§4.2); an auth failure is HTTP 403 with{"message":"Unauthorized: illegal auth token"}(§2.3).
curl -s -i -X POST "{baseUrl}/qr-code/1.7" \
-H "SaltId: $SALT_ID" \
-H "AuthorizationToken: $TOKEN" \
-H "Content-Type: application/json" \
-d '{"establishmentId": 5259, "orderId": "order-1001", "qrCode": "<scanned-code>"}'
3. Provisioned credentials and configuration
Two values are all you need to authenticate and address the in-store API for a given outlet:
| Value | Spec name | What it is |
|---|---|---|
| Establishment ID | establishmentId | The numeric ID of the establishment (sales outlet). Sent in getOneTimeSalt and the request bodies; must be configurable per outlet on your side. |
| API private key | apiPrivateKey | The secret key used to compute the AuthorizationToken. Never sent over the wire; used only as the first argument of the SHA-256 hash. |
LoyaltyPlant assigns both during onboarding, out of band, along with your base URL. One outlet
maps to one key. On the LoyaltyPlant side these are an outlet's SalesOutletId (handed to you as
establishmentId) and its integration-server password (handed to you as apiPrivateKey); you do
not need to know the LP-internal names — onboarding gives you the two values in the in-store API
spelling.
Note: For a standard vendor-built in-store API integration,
establishmentId+apiPrivateKey(plus the base URL) are everything you need to start calling the API.
Some deployments instead fetch a per-outlet configuration once at setup from a separate
LoyaltyPlant configuration endpoint, which returns the same establishmentId and key (as
password) together with the protocol apiVersion, the reward discountId, and the QR-scan
flow (REGULAR vs PATCH_ORDER). The config password is returned encrypted
(DESede/CBC/PKCS5Padding, BASE64) and must be decrypted to obtain the apiPrivateKey — do not
hash the BASE64 ciphertext directly. Whether your integration uses the out-of-band handoff or the
config endpoint, the credential it yields (after decryption) is the same apiPrivateKey. The
config endpoint, its fields, the decryption procedure, and the flow behavior are documented in
Kiosk and POS Configuration.
4. Request and response conventions
These conventions hold across all operations. They are the rules a vendor most often gets wrong when reading only the spec.
4.1 HTTP status codes
The in-store API uses HTTP status codes narrowly — a loyalty operation that fails for a business reason still returns HTTP 200. Non-200 statuses are reserved for transport-, auth-, and field-validation problems:
| Status | Meaning | Body | What to do |
|---|---|---|---|
200 | The request reached the loyalty logic. This does not mean the loyalty operation succeeded — inspect the body (§4.2). | Operation result | Branch on accepted and read messages[]. |
400 | Request-body schema validation failed (v1.4+). | JSON map of field JSON-Pointer → error | Fix the listed fields against the spec and resend. |
403 | Authentication failed (§2.3). | {"message": "..."} | Recompute the token with a fresh salt; never reuse a salt. |
503 | Service temporarily unavailable — in practice the salt/validation store (Redis) is unreachable. Business rejections never use 503: they return HTTP 200 with accepted: false. | ErrorResponse | Retry after a short backoff, with a fresh salt. |
4.2 The accepted-vs-errorCode contract
Because business outcomes ride inside the HTTP 200 body, you must read the body to know whether the operation succeeded — and the success signal differs by operation:
processQrCode— branch on the booleanaccepted. On a rejection (accepted: false) the reason arrives as a singleerror-type entry inmessages[]; there is no top-levelerrorfield.orderClosedandrefundOrder— branch on the booleanaccepted, not onerrorCode.errorCodeis0in both the accepted and the rejected cases, soerrorCode == 0does not mean success. Whenaccepted: false, the rejection reason is inerrorText.
Warning: Never treat HTTP 200 as unconditional success, and never use
orderClosed/refundOrdererrorCodeas the success check. Always branch onaccepted. The full per-code catalog and the "golden rule" are in Message & Error Codes.
4.3 Always display messages[]
The processQrCode response carries a messages[] array; every entry must be displayed —
on the self-service kiosk screen for the customer, or to the cashier at the cash register / POS
terminal — because that is how the customer learns the scan was accepted, a reward was added, or the
code was rejected. Each entry has a type (info, warn, error), a
numeric code, and a localized text. Display the text the in-store API sends rather than hardcoding
strings against the code. The code catalog is on the Message schema in
cloudvalidator-api.yaml and explained in
Message & Error Codes.
5. Idempotency, ordering, and retries
The loyalty cycle is a reserve-then-finalize transaction split across calls, so the ordering and retry rules below are points-integrity-critical, not cosmetic.
5.1 orderId and transactionId — uniqueness and matching
Your POS orderId ties a validation to its closure: send the same orderId in
processQrCode and the later orderClosed, and the in-store API correlates the two by it. A different or
unknown orderId at closure is rejected ("document not found or not in open state"). orderId
is any unique string; LoyaltyPlant stores up to 45 characters.
Separately, a points payment returned by processQrCode carries a transactionId that you
must echo, unchanged, in the matching orderClosed payments[] entry, and each points-payment
transactionId must be unique — otherwise the points deduction is processed incorrectly.
Note: In the
processQrCoderesponsepayment.transactionIdis an integer, while in theorderClosedrequestpayments[].transactionIdis a string — "matching" means sending the response integer as its decimal string, with no reformatting (no leading zeros, no scientific notation).
5.2 Close each order exactly once
orderClosed is the call that actually credits points and settles redeemed rewards, and it is
accepted once per order. A second orderClosed for the same orderId is rejected 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, not with a 506/ORDER_ALREADY_CLOSED.
Branch on accepted (per §4.2), not on an error code. Build closure to fire exactly once and
treat the first successful response as final.
5.3 Every validation must be finalized
A reserved transaction must end in either orderClosed or refundOrder. If neither follows a
validation, the in-store API annuls the transaction automatically after roughly 24 hours: no points are
credited, and any points the customer spent on rewards are returned to their account a short while
later. This is a recovery safety net, not a normal path — customers notice missing points
immediately. Guarantee the closing call in every code path, including crashes and offline
recovery.
5.4 Retrying safely after a timeout
A network timeout creates ambiguity: a POST that times out after the in-store API committed it leaves you
unsure whether the call succeeded. Handle each operation according to its idempotency:
getOneTimeSalt— safe to retry freely; each call just issues a new single-use salt. Never reuse a salt across a retry — fetch a new salt for each attempt.processQrCode— re-validating the same QR after a lost response may return504/505("already scanned"/"already validated"); treat those as "the first scan counted", not as a new failure.orderClosed— exactly-once. After a timeout, a retry of an already-applied closure comes back withaccepted: falseanderrorCode: 0(errorText "document not found or not in open state") — not a506/ORDER_ALREADY_CLOSED, which belongs to theprocessQrCodepath. Treat thataccepted: falserejection as confirmation the first attempt succeeded, not as a new error; branch onaccepted, never watch for a 506 here.- HTTP 503 — transient; the salt/validation store (Redis) is temporarily unavailable. Retry after a short backoff with a fresh salt.
Note: The in-store API does not expose an idempotency-key header or an "order status" query operation, and explicit retry/timeout/rate limits are not published (see §6). The safe pattern is to make closure exactly-once on your side (keyed by
orderId) and to read an already-closed/already-validated rejection on retry as "the prior attempt landed".
6. Operational limits
State of what is documented, honestly:
| Limit | Status |
|---|---|
| QR code validity | About 5 minutes after the customer opens it; single-use. |
| One-time salt validity | Single-use; also expires ~3 minutes (180 s) after issue if unused (§2.1). |
orderId length | Up to 45 characters. |
| Request timeouts | Not currently specified. Design conservatively (e.g. a few seconds connect, tens of seconds read) and make closure retry-safe per §5. |
| Rate limits | Not currently specified. No published per-outlet request rate; do not assume a fixed budget — pace requests to actual order volume. |
| Currency, scale, and rounding | Not currently specified at the protocol level. Monetary fields (price, value, amount, total, orderTotal) are JSON number (decimal) with no declared currency, scale, or rounding rule. The outlet's currency and the points-to-currency conversion rate are configured on the LoyaltyPlant side per client; agree the decimal precision and rounding to use with LoyaltyPlant at onboarding and verify it at certification. |
Note: Where a limit is "not currently specified", design defensively rather than assume a value — and confirm the real number with LoyaltyPlant during onboarding. The Integration Checklist verifies the behaviors that depend on these (timeout recovery, exactly-once closure, monetary correctness) at certification.
6.1 orderTotal and money on the wire
One monetary rule is load-bearing and applies to every orderClosed: report orderTotal
without subtracting any amount paid with points — the points payment is listed in
payments[], not deducted from the total — while the value of reward items is netted out (their
lines are zero after the 100% reward discount). Getting this wrong over- or under-credits points.
Full treatment is in the QR Code Loyalty Guide.
7. Glossary
Terms used throughout this documentation set. Field-level definitions live in the spec; this table gives the meaning.
| Term | Meaning |
|---|---|
| In-Store Loyalty API | LoyaltyPlant's vendor-facing in-store loyalty service that your kiosk / cash register / POS terminal calls. |
| establishment / sales outlet | The physical store — referred to as the outlet throughout this documentation set. Its wire identifier is establishmentId in in-store API payloads (SalesOutletId on the LoyaltyPlant side). |
| one-time salt | A single-use random value issued by getOneTimeSalt, consumed by exactly one authenticated request. |
| SaltId | The numeric identifier of a one-time salt; sent in the SaltId header. |
| AuthorizationToken | The per-request auth header for v1.4+: SHA256(apiPrivateKey + salt) as lowercase hex. |
| apiPrivateKey | The secret outlet key used to compute the AuthorizationToken; never transmitted. |
| points | The loyalty currency a customer earns on a purchase and spends on rewards; all point arithmetic is computed on the LoyaltyPlant side. |
| reward | What a customer redeems points for — a menu item, a discount, or extra points; a reward never increases the order total, and alcohol/tobacco cannot be a reward. |
| transactionId | The identifier of a points payment returned by processQrCode and echoed back, unique, in orderClosed. |
| publicClientId | A stored LoyaltyPlant client ID that identifies a returning customer in processQrCode (since 1.7), as an alternative to a QR code or phone number. |
| tier | The customer's loyalty tier name (e.g. "Gold"), returned as customer.customerTier since 1.7; best-effort, may be absent. |
| validation | Reading and processing of a loyalty identifier. A code is used once read and validated once all its operations are applied. |
Next: Loyalty Flows →