Skip to main content

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.

PropertyValue
ProtocolHTTPS only
MethodPOST for every operation (salt, qr-code, order-closed, refund)
Request bodyContent-Type: application/json
Response bodyJSON
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 servers entry 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:

  1. Fetch a one-time salt. Call getOneTimeSaltPOST {baseUrl}/one-time-salt/1.7 with { "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.
  2. Sign the next call. Compute an AuthorizationToken from the salt and your apiPrivateKey, then send both the SaltId and the AuthorizationToken as HTTP headers on the qr-code, order-closed, or refund call.

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 fresh one-time-salt call 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 + salt with nothing appended. In a shell, use printf, not echoecho adds 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 Signature header computed as MD5(requestBody + apiPrivateKey + salt), with the in-store API signing the response the same way. New integrations never use it — build on 1.7 with AuthorizationToken. 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 messageMeaning
Token expired.The salt behind SaltId was already used or never existed.
Unauthorized: illegal auth tokenThe AuthorizationToken hash does not match — wrong key, wrong salt, or wrong concatenation order.
Unauthorized: required header "SaltId" is not setMissing SaltId header.
Unauthorized: required header "AuthorizationToken" is not setMissing AuthorizationToken header.

Note: Authentication failures return HTTP 403, as documented here, implemented by the service, and labelled in the OpenAPI spec (the ForbiddenError response on the qr-code/order-closed/refund operations). Code for 403.

Worked example: salt → token → first authenticated call (curl)
  1. 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"}
  1. Compute the token. Hash apiPrivateKey + salt over the exact bytes — printf, not echo (a trailing newline would corrupt the hash). Carry SaltId and salt from the response above.
SALT_ID=12345
SALT="abcdefghijklmnop"
TOKEN=$(printf '%s%s' "$API_PRIVATE_KEY" "$SALT" | shasum -a 256 | cut -d' ' -f1)
  1. Make the first authenticated call. Send SaltId and the computed AuthorizationToken as headers. Success is HTTP 200 — read the body and branch on accepted (§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:

ValueSpec nameWhat it is
Establishment IDestablishmentIdThe numeric ID of the establishment (sales outlet). Sent in getOneTimeSalt and the request bodies; must be configurable per outlet on your side.
API private keyapiPrivateKeyThe 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:

StatusMeaningBodyWhat to do
200The request reached the loyalty logic. This does not mean the loyalty operation succeeded — inspect the body (§4.2).Operation resultBranch on accepted and read messages[].
400Request-body schema validation failed (v1.4+).JSON map of field JSON-Pointer → errorFix the listed fields against the spec and resend.
403Authentication failed (§2.3).{"message": "..."}Recompute the token with a fresh salt; never reuse a salt.
503Service temporarily unavailable — in practice the salt/validation store (Redis) is unreachable. Business rejections never use 503: they return HTTP 200 with accepted: false.ErrorResponseRetry 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 boolean accepted. On a rejection (accepted: false) the reason arrives as a single error-type entry in messages[]; there is no top-level error field.
  • orderClosed and refundOrder — branch on the boolean accepted, not on 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.

Warning: Never treat HTTP 200 as unconditional success, and never use orderClosed/refundOrder errorCode as the success check. Always branch on accepted. 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 processQrCode response payment.transactionId is an integer, while in the orderClosed request payments[].transactionId is 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 return 504/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 with accepted: false and errorCode: 0 (errorText "document not found or not in open state") — not a 506/ORDER_ALREADY_CLOSED, which belongs to the processQrCode path. Treat that accepted: false rejection as confirmation the first attempt succeeded, not as a new error; branch on accepted, 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:

LimitStatus
QR code validityAbout 5 minutes after the customer opens it; single-use.
One-time salt validitySingle-use; also expires ~3 minutes (180 s) after issue if unused (§2.1).
orderId lengthUp to 45 characters.
Request timeoutsNot currently specified. Design conservatively (e.g. a few seconds connect, tens of seconds read) and make closure retry-safe per §5.
Rate limitsNot currently specified. No published per-outlet request rate; do not assume a fixed budget — pace requests to actual order volume.
Currency, scale, and roundingNot 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.

TermMeaning
In-Store Loyalty APILoyaltyPlant's vendor-facing in-store loyalty service that your kiosk / cash register / POS terminal calls.
establishment / sales outletThe 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 saltA single-use random value issued by getOneTimeSalt, consumed by exactly one authenticated request.
SaltIdThe numeric identifier of a one-time salt; sent in the SaltId header.
AuthorizationTokenThe per-request auth header for v1.4+: SHA256(apiPrivateKey + salt) as lowercase hex.
apiPrivateKeyThe secret outlet key used to compute the AuthorizationToken; never transmitted.
pointsThe loyalty currency a customer earns on a purchase and spends on rewards; all point arithmetic is computed on the LoyaltyPlant side.
rewardWhat 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.
transactionIdThe identifier of a points payment returned by processQrCode and echoed back, unique, in orderClosed.
publicClientIdA stored LoyaltyPlant client ID that identifies a returning customer in processQrCode (since 1.7), as an alternative to a QR code or phone number.
tierThe customer's loyalty tier name (e.g. "Gold"), returned as customer.customerTier since 1.7; best-effort, may be absent.
validationReading and processing of a loyalty identifier. A code is used once read and validated once all its operations are applied.

Next: Loyalty Flows →