# Cheers Loyalty (POS API) — LLM Reference

> Single-file, self-contained reference for the Cheers Loyalty POS integration.
> Source: https://integration.cheersapp.io/docs/integrations/cheers-loyalty/overview
> OpenAPI spec: `pos-openapi` — https://integration.cheersapp.io/docs/api-reference/pos-openapi/cheers-api-documentation

## What this API does

Cheers Loyalty is a credit-based payment system. Users buy credits (virtual currency) in the
Cheers app and spend them at partner venues. The API applies venue-configured discounts
automatically and deducts credits from the user's wallet.

A POS integrates by driving a four-call lifecycle over one transaction:
**preview → (update) → finalize**, with **cancel** and **refund** as escape hatches.

1 credit = 1 unit of the local currency.

## Environments

| Environment | Base URL |
|---|---|
| Sandbox | `https://api.dev.votesess.com` |
| Production | `https://api.uniqpon.com` |

Sandbox test app: `https://dev.app.uniqpon.com`. Production app: `https://cheersapp.io`.

## Authentication

Three headers, depending on endpoint.

| Header | Required on | Format | Meaning |
|---|---|---|---|
| `X-API-Key` | **all endpoints** | JWT-like string | Identifies and authorizes the POS terminal. One key per physical terminal; determines identity and permissions. |
| `X-Transaction-Key` | **preview only** | `^[0-9a-fA-F]{32}$` | One-time-use code from the user's dynamic QR code in the Cheers app. Authorizes the POS to act for this user, this transaction. Single use, expires. |
| `X-Idempotency-Key` | **preview only** | 1–255 chars, unique per device | Prevents duplicate transactions when the same QR is scanned twice. |

Example values:

```
X-API-Key: eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9...
X-Transaction-Key: 5ccd7850844b415091071c027930101f
X-Idempotency-Key: a7e454c6-17ab-4fee-b823-c595bf3adb4a
```

The cashier scans the user's QR code; the QR payload *is* the `X-Transaction-Key` value.

### Reading the key: QR code or NFC card

Two sources produce a Transaction Key: the dynamic QR code in the Cheers app, and a physical
Cheers NFC card. NFC readers typically return the card value without leading zeros, so it can be
shorter than 32 characters. Left-pad the scanned value with zeros to 32 characters before sending:

```js
const transactionKey = scannedValue.padStart(32, "0");
```

Plain left zero-padding, no other transformation. QR payloads are already 32 characters, so
padding is a no-op there — apply it unconditionally to both sources and keep one code path.

### Verifying the device key

`GET /v1/integrations/me` returns the identity behind the `X-API-Key`: organization, place,
desks, and the `authorities` array. Credit transactions require the `CREDIT` authority — a key
without it gets `403 EXTERNAL_DEVICE_HAS_NO_RIGHT_AUTHORITY`. Call it once at POS startup and
cache the result, so the operator never discovers a misprovisioned key mid-sale.
Full reference: https://integration.cheersapp.io/llms/device-details.md

### POS prerequisite

The POS must expose a product query endpoint so Cheers can synchronize product data for
discount validation. Each product must expose: name, product ID, gross price, VAT rate.

## Payment types

Sent as `paymentType` on the preview request.

| Value | When to use | Behavior |
|---|---|---|
| `CREDIT` | POS does **not** support split payments | Full amount paid with credits. Returns `INSUFFICIENT_CREDIT` if the wallet is short — catch and retry with `EXTERNAL_PAYMENT`. |
| `SPLIT_PAYMENT` | POS **supports** split payments | Part credits, remainder external. `outstandingBalance` is the amount to collect via cash/card. |
| `EXTERNAL_PAYMENT` | Fallback, or credit-free purchase | Full amount paid externally. No credits charged, but discounts still apply. |

Decision logic:

```
if POS supports split payment:
    preview(paymentType = SPLIT_PAYMENT)
    collect outstandingBalance externally
    finalize()
else:
    try preview(paymentType = CREDIT)
    on 400 INSUFFICIENT_CREDIT:
        preview(paymentType = EXTERNAL_PAYMENT)
        collect full amount externally
    finalize()
```

## Key concepts

| Concept | Description |
|---|---|
| Credit | Virtual currency purchased in the Cheers app. 1 credit = 1 unit of local currency. |
| Discount | Automatic price reduction configured by the venue, applied per product during preview. |
| Outstanding balance | Amount left after credits and discounts; collected externally (cash/card). |
| Transaction Key | One-time 32-char hex code from the user's app QR. Identifies the user for one transaction. |
| Idempotency Key | Unique per device per transaction; prevents double charges on retry. |

## Endpoint summary

All paths are relative to the base URL. All require `X-API-Key`.

| Method | Path | Purpose |
|---|---|---|
| POST | `/v3/integrations/credit-transactions/preview` | Create preview with discount calculation |
| POST | `/v1/integrations/credit-transactions/{creditTransactionId}/update` | Add items (no discounts on new items) |
| POST | `/v3/integrations/credit-transactions/{creditTransactionId}/finalize` | Execute the transaction, deduct credits |
| POST | `/v1/integrations/credit-transactions/{creditTransactionId}/cancel` | Cancel an unfinalized transaction |
| POST | `/v1/integrations/credit-transactions/{creditTransactionId}/refund` | Refund a finalized transaction (24h window) |

---

## 1. Create transaction preview

`POST /v3/integrations/credit-transactions/preview`
Operation id: `createCreditTransactionPreview`
Headers: `X-API-Key`, `X-Transaction-Key`, `X-Idempotency-Key`
Success: `201 Created`. Errors: `400`, `401`, `403`, `404`.

### Request body — `IntegrationCreateCreditTransactionPreviewRequest`

| Field | Type | Required | Notes |
|---|---|---|---|
| `paymentType` | enum | yes | `CREDIT` \| `SPLIT_PAYMENT` \| `EXTERNAL_PAYMENT` |
| `currencyCode` | string | yes | ISO 4217, `^[A-Z]{3}$` |
| `items` | array | yes | min 1 item |

Item object — `IntegrationCreditTransactionItemGroupRequest`:

| Field | Type | Required | Constraints |
|---|---|---|---|
| `productId` | string | no | 1–255 chars |
| `quantity` | number | yes | min 1 |
| `unitPrice` | number | yes | |
| `vat` | number | yes | min 0; see supported VAT rates below |
| `name` | string | yes | 1–1000 chars |

```bash
curl -X POST https://api.dev.votesess.com/v3/integrations/credit-transactions/preview \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-Transaction-Key: 5ccd7850844b415091071c027930101f" \
  -H "X-Idempotency-Key: a7e454c6-17ab-4fee-b823-c595bf3adb4a" \
  -H "Content-Type: application/json" \
  -d '{
    "paymentType": "SPLIT_PAYMENT",
    "currencyCode": "HUF",
    "items": [
      {
        "productId": "f04ce0c0-1034-40bf-a066-3e313f68c781",
        "quantity": 3,
        "unitPrice": 500,
        "name": "Draft Beer 0.5L",
        "vat": 27
      }
    ]
  }'
```

### Response — `IntegrationCreditTransactionResponse`

```json
{
  "transactionId": "b04ce0c0-1034-40bf-a066-3e313f68c782",
  "totalCredits": 800,
  "totalDiscount": 600,
  "outstandingBalance": 100,
  "currencyCode": "HUF",
  "items": [
    {
      "productId": "f04ce0c0-1034-40bf-a066-3e313f68c781",
      "name": "Draft Beer 0.5L",
      "unitPrice": 500,
      "quantity": 3,
      "credits": 800,
      "discount": 600,
      "outstandingBalance": 100,
      "vat": 27
    }
  ]
}
```

| Field | Meaning |
|---|---|
| `transactionId` | Id used by update / finalize / cancel / refund |
| `totalCredits` | Credits spent from the user's wallet |
| `totalDiscount` | Discount applied by Cheers |
| `outstandingBalance` | Amount the customer must pay externally |
| `items[]` | Per-item `credits`, `discount`, `outstandingBalance`, `vat` — enough to print a receipt |

Worked example: 3 × 500 = 1,500 gross. Cheers applies a 600 discount → 900. Credits cover 800.
Remaining 100 is the outstanding balance, collected in cash or card.

**Rule:** send only discount-eligible items here. Do **not** send service fees, tips, or
surcharges in preview — they would incorrectly receive discounts. Add them via update.

---

## 2. Update basket (optional)

`POST /v1/integrations/credit-transactions/{creditTransactionId}/update`
Operation id: `updateCreditTransaction`
Headers: `X-API-Key`
Success: `200 OK`. Errors: `400`, `401`, `403`, `404`.

Adds extra items such as service fees and tips. **No discounts are applied to items added
here.** Each update also extends the preview expiration by 10 minutes.

### Request body — `IntegrationUpdateCreditTransactionRequest`

| Field | Type | Required | Notes |
|---|---|---|---|
| `currencyCode` | string | yes | ISO 4217 |
| `newItems` | array | yes | min 1; same item shape as preview |

```bash
curl -X POST https://api.dev.votesess.com/v1/integrations/credit-transactions/b04ce0c0-1034-40bf-a066-3e313f68c782/update \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "currencyCode": "HUF",
    "newItems": [
      { "productId": null, "quantity": 1, "unitPrice": 9,   "name": "Service fee", "vat": 27 },
      { "productId": null, "quantity": 1, "unitPrice": 100, "name": "Tip",         "vat": 0  }
    ]
  }'
```

Response is the same `IntegrationCreditTransactionResponse` shape. New items come back with
`credits: 0` and `discount: 0`; their full price lands in `outstandingBalance`. Continuing
the example above, the outstanding balance becomes 100 + 9 + 100 = **209**.

---

## 3. Collect external payment

Not an API call. If `outstandingBalance > 0`, collect that amount by cash or card **before**
finalizing.

**Critical:** if the external payment fails (card declined, insufficient cash), call cancel —
never finalize. Finalizing without collecting the external portion deducts credits for money
you did not take.

---

## 4. Finalize transaction

`POST /v3/integrations/credit-transactions/{creditTransactionId}/finalize`
Operation id: `finalizeCreditTransactionPreview`
Headers: `X-API-Key`. No request body — the cart state is not resent.
Success: `200 OK`. Errors: `400`, `401`, `403`, `404`.

Deducts credits from the user's wallet.

```bash
curl -X POST https://api.dev.votesess.com/v3/integrations/credit-transactions/b04ce0c0-1034-40bf-a066-3e313f68c782/finalize \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json"
```

After finalization the POS already holds everything needed for the receipt: per-item credits,
discount and outstanding balance, plus the three totals from the preview/update response.

---

## 5. Cancel transaction

`POST /v1/integrations/credit-transactions/{creditTransactionId}/cancel`
Operation id: `cancelCreditTransaction`
Headers: `X-API-Key`. No request body.
Success: `200 OK`. Errors: `400`, `401`, `403`, `404`.

Cancels an unfinalized transaction — use when external payment fails or the customer walks
away. No credits are deducted. The user's Transaction Key is consumed and cannot be reused.

---

## 6. Refund transaction

`POST /v1/integrations/credit-transactions/{creditTransactionId}/refund`
Operation id: `refundCreditTransactionByIntegration`
Headers: `X-API-Key`
Success: `200 OK`. Errors: `400`, `401`, `403`, `404`.

Refunds a **finalized** transaction; credits return to the user's wallet.

### Request body — `IntegrationRefundCreditTransactionRequest`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `comment` | string | no | 1–2000 chars |

```bash
curl -X POST https://api.dev.votesess.com/v1/integrations/credit-transactions/b04ce0c0-1034-40bf-a066-3e313f68c782/refund \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "comment": "Customer returned the order" }'
```

**Refunds are only available within 24 hours of finalization.** After that the API returns
`CREDIT_TRANSACTION_CAN_NO_LONGER_BE_REFUNDED_BY_EXTERNAL_DEVICE`.

---

## Error handling

Every error response uses this envelope:

```json
{
  "errorModel": {
    "errorCode": "ERROR_CODE",
    "message": "Human-readable description",
    "descriptors": ["additional", "context"]
  }
}
```

### Authentication & authorization

| Error code | Description |
|---|---|
| `AUTHORIZATION` | The API key (External Device) does not exist or has been disabled. |
| `EXTERNAL_DEVICE_HAS_NO_RIGHT_AUTHORITY` | The API key lacks permission for this operation (e.g. a `COUPON` key cannot process payments). |

### Transaction Key

| Error code | Description |
|---|---|
| `X_TRANSACTION_KEY_FORMAT_IS_NOT_VALID` | Does not match `^[0-9a-fA-F]{32}$`. |
| `X_TRANSACTION_HAS_NO_RIGHT_AUTHORITY` | Transaction Key lacks permission for this operation. |
| `X_TRANSACTION_HAS_EXPIRED` | Key expired — the user must generate a new QR code. |
| `X_TRANSACTION_IS_NOT_IN_ACTIVE_STATUS` | Key already used or manually invalidated. |

### Payment

| Error code | Description |
|---|---|
| `INSUFFICIENT_CREDIT` | Not enough credits. Only with `paymentType: CREDIT`. Retry with `EXTERNAL_PAYMENT` if the POS has no split-payment support. |
| `CREDIT_TRANSACTION_PREVIEW_HAS_EXPIRED` | Too long since preview creation. Create a new preview. |
| `CREDIT_TRANSACTION_HAS_ALREADY_BEEN_FINALIZED_WITH_DIFFERENT_ITEMS` | Finalize was already called for this transaction id with different items. |

### Idempotency

| Error code | Description |
|---|---|
| `IDEMPOTENCY_KEY_HAS_ALREADY_BEEN_USED_WITH_DIFFERENT_VALUES` | Key reused with a different payload. Generate a new unique key. |

### Validation

| Error code | Description |
|---|---|
| `COULD_NOT_MAP_BIG_DECIMAL_TO_VAT_ENUM_VALUE` | Unsupported VAT rate. |

Supported VAT rates: 0, 1, 2, 3, 4, 5, 5.5, 6, 7, 7.7, 8, 9, 9.5, 10, 11, 12, 13, 14, 15, 16,
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27.

### Refund

| Error code | Description |
|---|---|
| `CREDIT_TRANSACTION_CAN_NO_LONGER_BE_REFUNDED_BY_EXTERNAL_DEVICE` | The 24-hour refund window has passed. |

### Recovery flows

**INSUFFICIENT_CREDIT** (POS without split payment): preview with `CREDIT` → on
`400 INSUFFICIENT_CREDIT`, preview again with `EXTERNAL_PAYMENT` → collect full amount →
finalize.

**Expired preview:** on `CREDIT_TRANSACTION_PREVIEW_HAS_EXPIRED` at finalize, create a new
preview. If the Transaction Key also expired, the user must generate a fresh QR code first.

**Failed external payment:** preview → collect payment → if it fails, call cancel. No credits
are deducted. Never finalize a transaction whose external portion was not collected.

---

## Testing (sandbox)

Base URL `https://api.dev.votesess.com`, test app `https://dev.app.uniqpon.com`.

1. Obtain sandbox API keys — one per terminal — from the Cheers team. One terminal is enough
   for testing.
2. Configure terminals with their `X-API-Key`.
3. Buy test credits: open
   `https://dev.app.uniqpon.com/places/90f374e2-2007-4b30-8aa6-95cf7e5b7b42/credit-offer`,
   register with an email address (registration = login on the test platform), pay with the
   Barion test card `4444 8888 8888 5559`, then verify at
   `https://dev.app.uniqpon.com/hu/profile/credits`.
4. Generate a Transaction Key: log in to the test app → `https://dev.app.uniqpon.com/hu/profile`
   → QR code button → scan the dynamic QR to extract `X-Transaction-Key`.
5. Run the flow: preview → finalize → verify the balance dropped in the test app → refund →
   verify credits are restored.

Then repeat the same flow against production (`https://api.uniqpon.com`, app
`https://cheersapp.io`) with production keys and real accounts.
