# General Requirements

Read first: [Overview and Concepts](01-overview-and-concepts.md)

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`](/in-store/api/)); 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](07-versioning-and-changelog.md)).

### 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 [`getOneTimeSalt`](/in-store/api/) —
   `POST {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.

```mermaid
sequenceDiagram
    participant POS as Kiosk / cash register / POS
    participant API as In-Store Loyalty API
    POS->>API: POST /one-time-salt/1.7  { establishmentId }
    API-->>POS: { saltId, salt }
    Note over POS: token = SHA256(apiPrivateKey + salt)
    POS->>API: POST /qr-code/1.7  (SaltId, AuthorizationToken)
    API-->>POS: 200 result  (or 403 on auth failure)
```

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.

```text
authorizationToken = lowercase_hex( SHA256( apiPrivateKey + salt ) )
```

> **Warning:** Hash the exact bytes of `apiPrivateKey + salt` with nothing appended. In a shell,
> use `printf`, not `echo` — `echo` 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:

```http
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](04-qr-code-loyalty-guide.md).

> **Note:** Protocol versions below **1.5** are **deprecated** and are not described in this
> documentation set (versions 1.0–1.3 additionally use a legacy MD5 `Signature` scheme instead of
> `AuthorizationToken`). Integrations built on them keep working for backward compatibility, but
> new integrations are recommended to build on 1.5 or later. If you maintain an older
> integration, see the migration guide in
> [Versioning and Changelog](07-versioning-and-changelog.md).

### 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 `ForbiddenError` response on the
> qr-code/order-closed/refund operations). Code for `403`.

<details>
<summary><strong>Worked example: salt → token → first authenticated call (curl)</strong></summary>

1. **Fetch a one-time salt.** No auth headers on this call.

```bash
curl -s -X POST "{baseUrl}/one-time-salt/1.7" \
  -H "Content-Type: application/json" \
  -d '{"establishmentId": 5259}'
```

```json
{"saltId":12345,"salt":"abcdefghijklmnop"}
```

2. **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.

```bash
SALT_ID=12345
SALT="abcdefghijklmnop"
TOKEN=$(printf '%s%s' "$API_PRIVATE_KEY" "$SALT" | shasum -a 256 | cut -d' ' -f1)
```

3. **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).

```bash
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>"}'
```

</details>

## 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](06-kiosk-and-pos-configuration.md).

## 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 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](08-message-and-error-codes.md).

### 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`](/in-store/api/) and explained in
[Message & Error Codes](08-message-and-error-codes.md).

## 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:

| 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](09-integration-checklist.md) 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](04-qr-code-loyalty-guide.md).

## 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 →](03-loyalty-flows.md)
