# Kiosk and POS Configuration

Read first: [Overview & Concepts](01-overview-and-concepts.md) · [QR Code Loyalty Guide](04-qr-code-loyalty-guide.md)

This is the vendor-visible configuration reference for an **In-Store Loyalty API** integration. It
explains the **two configuration endpoints** your POS or kiosk calls to fetch a sales outlet's loyalty
settings, and how those settings change *when* and *how* you call the four in-store API operations. The
loyalty operations themselves (`getOneTimeSalt`, `processQrCode`, `orderClosed`, `refundOrder`) live in
the [In-Store Loyalty API specification](/in-store/api/); the configuration service is **not**
part of that contract — it has its own OpenAPI specification.

Configuration is delivered over **two endpoints**, used identically by every device type —
a cashier POS or a self-service kiosk (the same segments serve both; an rKeeper kiosk uses the same
`rkeeper` segment a cashier rKeeper terminal does):

1. **Credential endpoint** — `POST /cloudvalidator-config-service/v1/pos/{pos}` — fetched **once at
   setup**. Returns the outlet's encrypted in-store API key plus the legacy connection / POS-type
   fields. (Sections 1–2.)
2. **Configuration endpoint** — `GET /cloudvalidator-config-service/v1/config/{establishmentId}` — **authenticated and pollable**.
   Returns the full per-outlet loyalty configuration: connection, program rules, and the kiosk-facing
   presentation / legal settings. Your device re-fetches it on every startup and re-applies it whenever
   the `revision` increases, so admin-panel changes reach devices with no reprovisioning. (Section 3.)

The single source of field-level truth for both endpoints — parameters, the response envelopes, and every
configuration field, with types, enums, and worked examples — is the OpenAPI specification:

- [POS Configuration API specification](/in-store/api/) (OpenAPI 3.0.3) — operations `getPosConfig` and `getOutletConfig` (provided separately by LoyaltyPlant during onboarding)

This page explains *why*, *when*, and *in what order*; the spec is the definitive field-level reference. For the
configuration endpoint (Section 3) the prose also walks through each field, since those settings drive the
kiosk UX directly.

> **Note:** The configuration service (`cloudvalidator-config-service`) is distinct from the in-store API
> that serves the four loyalty operations. It has its own base path, host, and OpenAPI specification, and
> is not part of the In-Store Loyalty API contract.

## 1. How configuration reaches your integration

A device needs two kinds of settings: a one-time **credential** (which in-store API instance to talk to,
the outlet's **establishment** ID, and its API key), and the **operational configuration** (everything
the brand tunes — earn rules, registration flows, locales, legal documents, banners). The credential is
fetched once at setup; the operational configuration is polled on every startup so brand changes
propagate without re-touching the device. Because the configuration endpoint is authenticated, each
poll is preceded by a `one-time-salt` request to the **in-store API** — the salt is combined with the
decrypted `apiPrivateKey` to build the `AuthorizationToken` (Section 3.1).

```mermaid
sequenceDiagram
    participant POS as Vendor POS / kiosk
    participant API as In-store API (CloudValidator)
    participant CFG as Configuration service
    Note over POS,CFG: Once, at setup
    POS->>CFG: POST {configBaseUrl}/cloudvalidator-config-service/v1/pos/{pos}?identifier=...
    CFG-->>POS: accepted true → API key + establishmentId (persist these)
    Note over POS,CFG: On every startup
    POS->>API: POST {baseUrl}/one-time-salt/{version}
    API-->>POS: saltId + salt
    Note over POS: AuthorizationToken = SHA256(apiPrivateKey + salt)
    POS->>CFG: GET {configBaseUrl}/cloudvalidator-config-service/v1/config/{establishmentId}  (AuthorizationToken)
    CFG-->>POS: 200 → full outlet configuration
    Note over POS: revision increased → re-apply, otherwise keep cached config
```

## 2. The credential endpoint — `POST /cloudvalidator-config-service/v1/pos/{pos}`

Send an HTTP `POST`, keyed by the required `identifier` query parameter, with the `{pos}` path segment
your integration is registered as:

```http
POST {configBaseUrl}/cloudvalidator-config-service/v1/pos/{pos}?identifier=OUTLET-001
```

> **Note:** This request has **no** `one-time-salt` / `AuthorizationToken` handshake — the outlet
> identifier is the lookup key. `{configBaseUrl}` is the configuration service's scheme and host,
> provided by LoyaltyPlant during onboarding (e.g. `https://release.loyaltyplant.com`); the
> `/cloudvalidator-config-service` prefix is the service's fixed base path. `{configBaseUrl}` is
> distinct from the in-store API `{baseUrl}` used by the four loyalty operations (returned per outlet
> as the `apiUrl` field).

This endpoint is **fetch-once**: the service marks an outlet's credential as *applied* the first time it
is successfully returned, and a second fetch for the same identifier returns `accepted: false`. Persist
what you receive; `accepted: false` is **not** an error to retry — if you lost the credential, contact
LoyaltyPlant to reset the outlet's *applied* state.

### 2.1 Parameters, the `{pos}` segment, and the response envelope

The request takes the `{pos}` path segment and selects the outlet by the required `identifier` query
parameter. Two optional query parameters change the lookup: `midIdentifier`, when present, **overrides**
`identifier` as the lookup key; and `version`, an optional plugin/integration version string accepted but
not persisted. The service registers the segments `generic`, `clover`, `iiko` (**Syrve**), and `rkeeper`
(**Restera**) — the product segments are the original product identifiers, so keep calling
`/cloudvalidator-config-service/v1/pos/iiko` and `/cloudvalidator-config-service/v1/pos/rkeeper`;
**kiosks use the same segment as the matching POS** (an rKeeper kiosk uses `rkeeper`). If you are a POS
vendor building a **new** integration — not one of the existing Clover / Syrve / rKeeper products — use
the `generic` segment (`POST /cloudvalidator-config-service/v1/pos/generic`); LoyaltyPlant confirms the
segment for your integration during onboarding. An unrecognized segment returns `accepted: false`. The
complete parameter list, the `PosConfigResponse` envelope (`{ accepted, config }`), and worked per-POS
examples are defined under operation `getPosConfig` in the
[specification](/in-store/api/).

> **Note:** The `identifier` must be a **stable, globally-unique** value that already lives in the POS —
> assigned once, at provisioning, and **never overwritten** afterwards (do not reuse a mutable field such
> as a display name or a value the POS regenerates on update/reinstall). The outlet is resolved by this
> value on every credential fetch, so if it changes the outlet can no longer be matched.

### 2.2 What the credential endpoint returns

The credential endpoint returns the bootstrap your integration needs to authenticate to the in-store API:

- `password` — the outlet's in-store API key (your `apiPrivateKey`), returned **encrypted**. The same key
  is used to authenticate to the configuration endpoint (Section 3.1).
- `apiUrl`, `apiVersion`, `establishmentId`, `discountId`, `flow` — connection and POS-behavior fields
  (and POS-type-specific extras for Clover / Syrve / Restera). These are documented by the
  `GenericPOSConfiguration` and per-POS schemas in the spec.

> **Note:** `password` is encrypted with DESede (Triple DES) in CBC mode with PKCS5 padding,
> Base64-encoded. The shared key and initialization vector are fixed and shared with integrators;
> LoyaltyPlant provides the decryption routine during onboarding. The decrypted value is the
> `apiPrivateKey` used to build the `AuthorizationToken` (see the
> [QR Code Loyalty Guide](04-qr-code-loyalty-guide.md), Step 2).

## 3. The configuration endpoint — `GET /cloudvalidator-config-service/v1/config/{establishmentId}`

Once you hold the `establishmentId` (from Section 2), fetch the outlet's full loyalty configuration:

```http
GET {configBaseUrl}/cloudvalidator-config-service/v1/config/{establishmentId}
AuthorizationToken: <token>
```

The response is the same `{ accepted, config }` envelope (`OutletConfigResponse`). `accepted: false`
(with `config: null`) here means the outlet is **not bound** or loyalty is **not configured** (guest
mode) — never fetch-once. Only fields with a value are present.

### 3.1 Authentication

This endpoint is protected by the same `AuthorizationToken` scheme as the in-store loyalty API — SHA-256
of the outlet's decrypted `apiPrivateKey` (from the credential endpoint's `password`) combined with a
one-time-salt. You already build this token for `processQrCode`; reuse it. The configuration payload
itself never contains the key. A missing or invalid token returns `401`.

### 3.2 Polling and the `revision`

The endpoint is **not** fetch-once — call it on every device startup. The service always returns the
full configuration; the body carries a `revision` (a monotonic epoch-second stamp). The device should
compare it to its cached copy and re-apply the configuration only when `revision` increases. This is
how a brand updates
legal texts, swaps an install link, adds a country, or toggles a flow in the admin panel and has every
device pick up the change at its next start.

### 3.3 Configuration fields

The full structure and a worked example are in the `OutletConfig` schema (operation `getOutletConfig`).
Field by field:

**Connection.**
- `apiUrl` — the in-store API base URL this outlet calls.
- `loyaltyApiVersion` — the in-store API protocol version (e.g. `1.7`); append it to each operation path
  (`/qr-code/{loyaltyApiVersion}`). Because it is delivered here, **version migration is a config change**:
  once your app ships support for a new version, LoyaltyPlant updates this value and your devices switch
  on their next poll — no reprovisioning.
- `establishmentId` — the LoyaltyPlant establishment (sales outlet) ID, sent on every runtime call.

**Display basics.**
- `currency` / `currencySymbol` — ISO 4217 code and symbol, used to render the accrual rule and balances.
- `timezone` — IANA timezone, for timestamps and acceptance audit logs.
- `locales` — `{ enabled, default, fallback }`: the ISO 639-1 languages this device offers, the default,
  and the language to fall back to when a translation is missing.

**`loyaltyEnabled` — the master switch.** When `false`, the device runs as a plain POS/kiosk: no points,
no loyalty-card or reward scanning, no registration — and the entire `loyalty` block is ignored. Use it to
pause loyalty at specific outlets without removing the integration.

**`loyalty.*` — the program settings** (read only when `loyaltyEnabled` is `true`):

- `loyaltyPointAccrualRules` — `{ pointsPerCurrencyUnit, rounding }`: how purchases convert to points.
  `pointsPerCurrencyUnit: 0.04` means "0.04 points per unit of currency" (i.e. 1 point per 25 ₾);
  `rounding` is `floor` (down, the default), `round` (arithmetic), or `ceil` (up).
- `phoneNumberFlow` — `{ enabled, flow }`: whether a customer can join/identify by phone here, and how
  consent is collected. `single_step` shows phone + marketing consent on one screen (the runtime
  `qr-code` request's `subscribedToPushNotifications` reflects that single tap); `two_step` shows a
  separate consent screen after the phone screen (`subscribedToPushNotifications` reflects the second
  screen's answer — cleaner GDPR posture). Ignored when `enabled` is `false`.
- `loyaltyCardFlow` — `{ enabled }`: whether the customer can identify by scanning a loyalty QR card or
  entering its short code (vs. phone-only). Both reach the in-store API in the `qrCode` field; only the
  UX differs.
- `rewardRedemption` — `{ enabled }`: whether the customer can scan a reward/gift QR and apply it to the
  order.
- `pointsIconUrl` — image URL for the points icon shown next to balances/accrual (not localized).
- `rewardBanner` — `{ imageUrl }`: an image promoting the rewards/gifts available at this outlet.
- `marketingConsent` — `{ channels, screen }`: the marketing channels the brand offers (`sms`, `whatsapp`,
  `email`, `push`) and the consent-screen copy. An empty `channels` means consent is not requested (and
  the `two_step` consent screen is not shown). `screen.headlineByLocale` / `screen.bodyByLocale` hold the
  copy per language; the body may contain `{document_kind}` placeholders (e.g. `{privacy_policy}`) listed
  in `screen.documentRefs`, which your device replaces with links to the matching `documents[]` entry.
- `phoneFormDisclaimer` — `{ templateByLocale, documentRefs }`: the fine-print under the phone-entry form,
  per language, shown in both flows. The `{privacy_policy}`, `{terms_conditions}`, `{loyalty_terms}`,
  `{marketing_consent}` placeholders are replaced with links to the documents named in `documentRefs`.
- `appInstallBanner` — `{ url, imageUrl, promoTextByLocale, showWhen }`: the "get the app" banner. `url`
  is the universal/dynamic install link you render as a QR code; `promoTextByLocale` is the per-language
  text next to it; `showWhen` is four booleans deciding which end-state shows the banner —
  `justRegisteredWithApp`, `justRegisteredWithoutApp`, `alreadyRegistered`, `skipped` (guest).

**`documents[]` — legal documents**: each entry has a `kind`
(`privacy_policy`, `terms_conditions`, `loyalty_terms`, `marketing_consent`), a `version` (recorded in the
consent audit trail when the customer accepts), `titleByLocale`, `pdfUrlByLocale` (immutable — a new
version is a new URL), and `requiresAcceptance` (whether an explicit "I accept" tap is required, vs.
informational). The `documentRefs` in the consent screen and the disclaimer point at these `kind`s.

**`phone` — number entry.** `defaultCountry` (ISO 3166-1 alpha-2) drives the dialing code and validation;
`allowedCountries` is an optional list of extra accepted countries (e.g. for tourists); `masksByCountry`
is an optional per-country input mask (`X` = a digit, other characters are literals), falling back to
libphonenumber when a country is absent.

### 3.4 Worked example

A full `accepted: true` response for a loyalty-enabled outlet. Comments are annotations only — they are
**not** part of the wire format, and only fields with a value are present:

```jsonc
{
  "accepted": true,
  "config": {
    "revision": 1748186400,                 // monotonic change stamp; re-apply only when it increases
    "apiUrl": "https://release.loyaltyplant.com/CloudValidator", // in-store API base URL for this outlet
    "loyaltyApiVersion": "1.7",             // pollable protocol version — append to each operation path
    "establishmentId": 5259,                // sent on every runtime call
    "currency": "GEL",                      // ISO 4217 code
    "currencySymbol": "₾",
    "timezone": "Asia/Tbilisi",             // IANA tz for timestamps / audit logs
    "locales": {                            // languages this device offers
      "enabled": ["en", "ka"],
      "default": "en",
      "fallback": "en"                      // used when a translation is missing
    },
    "loyaltyEnabled": true,                 // master switch — when false, the whole "loyalty" block is ignored
    "loyalty": {
      "loyaltyPointAccrualRules": {
        "pointsPerCurrencyUnit": 0.04,      // 0.04 points per unit of currency (1 point per 25 ₾)
        "rounding": "floor"                 // floor (default) | round | ceil
      },
      "pointsIconUrl": "https://cdn.example.com/assets/points-icon.svg", // not localized
      "phoneNumberFlow": {
        "enabled": true,
        "flow": "two_step"                  // single_step | two_step (separate consent screen)
      },
      "loyaltyCardFlow": { "enabled": true },   // identify by scanning a loyalty QR / short code
      "rewardRedemption": { "enabled": true },  // scan a reward/gift QR and apply it
      "rewardBanner": {
        "imageUrl": "https://cdn.example.com/assets/rewards-banner.png"
      },
      "marketingConsent": {
        "channels": ["sms", "whatsapp", "email", "push"], // empty ⇒ consent not requested
        "screen": {
          "headlineByLocale": {
            "en": "Would you like to get exclusive deals to your phone number?",
            "ka": "გსურთ მიიღოთ ექსკლუზიური შეთავაზებები?"
          },
          "bodyByLocale": {
            // {document_kind} placeholders resolve to the matching documents[] entry
            "en": "We'll send personalized offers based on your activity. See {privacy_policy}. Opt out anytime.",
            "ka": "გამოგიგზავნით პერსონალურ შეთავაზებებს. იხ. {privacy_policy}."
          },
          "documentRefs": ["privacy_policy"]
        }
      },
      "phoneFormDisclaimer": {              // fine-print under the phone-entry form, per language
        "templateByLocale": {
          "en": "By providing your phone number, you agree to {privacy_policy}, {terms_conditions}, {loyalty_terms} and {marketing_consent}",
          "ka": "ნომრის მითითებით თქვენ ეთანხმებით: {privacy_policy}, {terms_conditions}, {loyalty_terms}, {marketing_consent}"
        },
        "documentRefs": ["privacy_policy", "terms_conditions", "loyalty_terms", "marketing_consent"]
      },
      "appInstallBanner": {
        "url": "https://brand.app.link/get",   // universal/dynamic install link — render as a QR code
        "imageUrl": "https://cdn.example.com/assets/app-banner.png",
        "promoTextByLocale": {
          "en": "Get the app — earn and spend points right from your phone",
          "ka": "ჩამოტვირთეთ აპლიკაცია — დააგროვეთ ქულები"
        },
        "showWhen": {                       // which end-state shows the banner
          "justRegisteredWithApp": true,
          "justRegisteredWithoutApp": true,
          "alreadyRegistered": true,
          "skipped": false                  // guest
        }
      }
    },
    "documents": [                          // legal documents referenced by documentRefs above
      {
        "kind": "privacy_policy",
        "version": "2026-04-15",            // recorded in the consent audit trail on acceptance
        "titleByLocale": { "en": "Privacy Policy", "ka": "კონფიდენციალურობის პოლიტიკა" },
        "pdfUrlByLocale": {                 // immutable — a new version is a new URL
          "en": "https://cdn.example.com/docs/pp/en/2026-04-15.pdf",
          "ka": "https://cdn.example.com/docs/pp/ka/2026-04-15.pdf"
        },
        "requiresAcceptance": true          // explicit "I accept" tap required (vs. informational)
      }
      // ... terms_conditions, loyalty_terms, marketing_consent entries follow the same shape
    ],
    "phone": {
      "defaultCountry": "GE",               // ISO 3166-1 alpha-2 — drives dialing code + validation
      "allowedCountries": ["GE", "AM", "TR"], // optional extra accepted countries
      "masksByCountry": {                   // optional input mask; X = a digit, other chars literal
        "GE": "+995 XXX XXX XXX",
        "AM": "+374 XX XXX XXX",
        "TR": "+90 XXX XXX XX XX"
      }
    }
  }
}
```

## 4. How configuration drives the flows

Two values from the **credential endpoint** change how you run the in-store API operations described in
the [QR Code Loyalty Guide](04-qr-code-loyalty-guide.md): `flow` and `discountId`.

### 4.1 `flow` changes *when* you scan

The full in-store API cycle is always `one-time-salt → qr-code → order-closed` (with an optional
`refund`). What `flow` decides is the moment the `qr-code` scan happens relative to closing the order:

- **`REGULAR`** — 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 (or, on the phone number-based flow, the customer
  enters their number on the kiosk or the cashier enters it at the POS / cash register) at any point while
  the order is open; apply the returned rewards and discounts, and call `order-closed` when the order is
  paid. This is the flow the QR Code Loyalty Guide walks through.
- **`PATCH_ORDER`** — assemble the full order with reward items added as ordinary paid lines, then 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 before closing so the validation applies the discounts
  that zero out the reward lines. Scanning early is not expected in this flow.

`REGULAR` (the default) and `PATCH_ORDER` are the values you will receive. The request and response schemas
are identical for both flows — only your timing changes. Honor the `flow` your configuration returns; do
not hard-code one.

### 4.2 `discountId` is the reward discount you echo

When `processQrCode` returns a **reward** item, it arrives with a 100% discount that brings the line to
zero, carrying the code your outlet is configured with — the value of `discountId`. When you echo the
reward back in `order-closed`, the zero-priced reward line must carry that same `discountId`; that is how
the in-store API recognizes the reward was actually used and settles it. Using a different code (or
dropping the discount) leaves the redemption unsettled.

> **Note:** `discountId` is configured per outlet — it is **not** always the literal string
> `"LoyaltyPlant"`. Read it from your configuration and echo exactly the value the in-store API sends on
> the reward's discount in the `qr-code` response.

## 5. Scope note

These configuration endpoints are consumed by **your** POS or kiosk integration to bootstrap and operate
each outlet. They are served by a separate LoyaltyPlant service (`cloudvalidator-config-service`), not the
in-store API, so they are **not** part of the [In-Store Loyalty API specification](/in-store/api/)
(which is reserved for the four loyalty operations). Their machine-readable contract is the dedicated
[POS Configuration API specification](/in-store/api/);
this page is the narrative companion to it, and where prose and the contract disagree, the spec is
authoritative for field-level detail. The legacy Integration Server XML protocol is out of scope for this
documentation set and is not described here.

---

**Next:** [Versioning and Changelog →](07-versioning-and-changelog.md)
