# Message & Error Codes

Read first: [QR Code Loyalty Guide](04-qr-code-loyalty-guide.md)

This is the full catalog of the codes the **In-Store Loyalty API** returns: the **message** codes your integration must display to the cashier, and the business **error** codes it must handle. Field-level definitions live in the spec — the `Message` schema and the per-operation response schemas in [In-Store Loyalty API specification](/in-store/api/) — this page adds the meaning and the recommended handling.

## 1. The golden rule

A non-200 HTTP status is **not** how the in-store API tells you a loyalty operation failed. Business outcomes — "QR already scanned", "order already closed", "phone flow disabled" — are carried *inside* the response body, and they arrive with HTTP 200. Non-200 statuses are reserved for transport-, auth-, and field-validation-level problems:

- **HTTP 403** — authentication failed (bad/missing `SaltId`, reused salt, wrong `AuthorizationToken`/`Signature`). The body is `{"message": "..."}`, not a loyalty result. The auth layer emits this *before* the request reaches any business logic.
- **HTTP 400** — request-body validation failure. The body is a JSON object mapping each invalid field's JSON-Pointer path to a description, e.g. `{"/order/menuItems/0/itemId": "instance type (integer) does not match ..."}`. Fix the request and resend.
- **HTTP 503** — service temporarily unavailable (a downstream dependency such as Redis is unreachable). The body is an `ErrorResponse`. Retry after a short backoff.

Everything else is a **business outcome** delivered in the 200 body. How you read the outcome depends on the operation:

| Operation | Authoritative success/failure signal | Detail field |
|---|---|---|
| [`processQrCode`](/in-store/api/) | `accepted` boolean; on rejection a single `messages[]` entry of `type: error` carries the numeric `code` | `messages[].code` (see Section 3 for the mapping) |
| [`orderClosed`](/in-store/api/), [`refundOrder`](/in-store/api/) | `accepted` boolean — **not** `errorCode` | `errorText` (human-readable rejection reason) |

The numeric `messages[].code` values returned by `processQrCode` follow the monolith's message-code numbering, so a POS that already knows those codes can reuse its display logic (`ORDER_ALREADY_CLOSED` → `506`, the same `506` listed on the `Message` schema). This numbering applies to `processQrCode` only; `orderClosed`/`refundOrder` do not return these numeric codes (see the Note below).

> **Note:** For `orderClosed`/`refundOrder` the authoritative signal is the boolean `accepted` field — **not** `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`; `errorCode` stays `0`. Always branch on `accepted`.

## 2. Cashier-facing messages

Each entry in the `processQrCode` response `messages[]` array has a `type` (`info`, `warn`, `error`), a numeric `code`, and a human-readable `text`. **Every message must be shown** — rendered on the self-service kiosk screen for the customer, or shown to the cashier at the cash register / POS terminal. That is how the customer learns the scan succeeded, a reward was added, or the code was rejected.

The full message-code catalog, as defined on the `Message` schema:

| Code | Type | Meaning |
|---|---|---|
| 200 | info | Generic information message |
| 201 | info | Bonus card was accepted |
| 202 | info | Reward was added |
| 203 | info | (reserved) |
| 500 | error | Generic error message |
| 501 | error | QR code was generated for another partner |
| 502 | error | QR code was generated for another profile |
| 503 | error | QR code invalid |
| 504 | error | QR code was already scanned |
| 505 | error | QR code already validated |
| 506 | error | Order already closed |
| 507 | error | Discount already exists in the order |
| 508 | error | Reward was not added to the order |
| 509 | error | Phone number has the wrong format |
| 510 | error | The phone / `publicClientId` flow is not available for this outlet |
| 600 | warn | Generic warning message |

Codes 200–203, 500–510, and 600 are all defined on the spec `Message` schema; codes 509 and 510 are additionally produced by the service's `ErrorCodeMapping`.

> **Note:** The legacy public message-codes page stops at `508` and `600`, but the current spec `Message` schema lists `509` (`PHONE_NUMBER_WRONG_FORMAT`) and `510` (`PHONE_FLOW_DISABLED`) too; both are produced by the current service for the phone / `publicClientId` flows. Display the `text` the in-store API sends rather than hard-coding strings against the code — the `text` is localized and authoritative.

## 3. Error codes

This mapping applies to **`processQrCode`**. When a business rule blocks a scan, the in-store API raises an internal error code and maps it to one of the numeric `messages[].code` values above (via `GlobalExceptionHandler` → `ErrorCodeMapping`). It does **not** populate the `errorCode` field of `orderClosed`/`refundOrder` — those report a business rejection as `accepted: false` with `errorCode: 0` (see Section 1). The table below lists the error codes `ErrorCodeMapping` defines, plus two error codes that are thrown but unmapped (the last two rows):

| Internal error code | Numeric code | Meaning |
|---|---|---|
| `QR_EXPIRED` | 503 | Mapping entry only — not currently emitted by the in-store API; QR-invalidity is reported via the other 503 codes below |
| `QR_ALREADY_SCANNED` | 504 | QR code already scanned (the `qrCode` already exists) |
| `QR_CODE_ALREADY_VALIDATED` | 505 | QR code already fully validated |
| `ORDER_ALREADY_CLOSED` | 506 | A QR was scanned (`processQrCode`) against an order that is already closed |
| `QR_CODE_WRONG_FORMAT` | 503 | QR string is malformed |
| `QR_INVALID` | 503 | QR code not recognized / invalid |
| `QR_PARSE_ERROR` | 503 | QR string could not be parsed |
| `ESTABLISHMENT_NOT_FOUND` | 500 | `establishmentId` not found |
| `PHONE_FLOW_DISABLED` | 510 | Phone / `publicClientId` flow not available for this outlet (flow disabled, or partner data missing) |
| `PHONE_NUMBER_WRONG_FORMAT` | 509 | Phone number not in the expected format |
| `PARTNER_DATA_MISSING` | 500 | Partner configuration incomplete on the LP side |
| `CLIENT_SERVICE_ERROR` | 500 | Downstream LP client service error |
| `MISSING_REQUIRED_FIELD` | 500 | A required field was absent |
| `CUSTOMER_NOT_FOUND` | 500 | (v1.7 `publicClientId` lookup) no such public ID — unmapped, falls through to default 500 |
| `CUSTOMER_WRONG_PARTNER` | 500 | (v1.7 `publicClientId` lookup) client belongs to a different partner — unmapped, falls through to default 500 |
| *(any other / unmapped)* | 500 | Default — generic error |

> **Note:** `CUSTOMER_NOT_FOUND` and `CUSTOMER_WRONG_PARTNER` (thrown on the v1.7 `publicClientId` lookup) have no dedicated entry in `ErrorCodeMapping`, so both fall through to the default `500`. They are listed in the table above for completeness. Distinguish them by `errorText`/message `text`, not by the numeric code.

## 4. Recommended vendor handling per code

The table groups the outcomes by the action your integration should take. "Show message" always means: display the `text` the in-store API returned.

| Codes | Retry? | Vendor action |
|---|---|---|
| 200, 201, 202 | No | The scan/operation succeeded. Show message; apply any returned rewards, discounts, and payment. |
| 600 (`warn`) | No | A warning — it does **not** by itself assert the operation succeeded. Show message; check `accepted` (and `messages[]`) to decide whether to apply anything. |
| 503 (`QR_INVALID`/`QR_*_FORMAT`/`QR_PARSE_ERROR`) | No — needs new code | Show message; ask the customer to refresh the app and present a fresh QR code — to scan again at the self-service kiosk, or to have the cashier re-scan it at the cash register / POS terminal (per spec, codes are single use and intended to be short-lived). |
| 504 (`QR_ALREADY_SCANNED`), 505 (`QR_CODE_ALREADY_VALIDATED`) | No | Show message; do not re-scan the same code into the same order. The first scan already counts. |
| 506 (`ORDER_ALREADY_CLOSED`) | No | Surfaces on `processQrCode` when a QR is scanned against an already-closed order. Show message; do not re-scan into a closed order. (A repeated `orderClosed` call does **not** return 506 — it returns `accepted: false` with `errorCode: 0`; branch on `accepted` there.) |
| 507 (discount exists), 508 (reward not added) | No | Show message; reconcile the order contents — a discount/reward could not be applied as sent. |
| 509 (`PHONE_NUMBER_WRONG_FORMAT`) | No — fix input | Show message; re-prompt for a valid international phone number — the customer re-enters it on the self-service kiosk, or the cashier re-enters it at the POS / cash register. |
| 510 (`PHONE_FLOW_DISABLED`) | No | The phone number-based flow is off for this outlet. Do not retry; fall back to the QR code flow — the customer scans their QR code at the self-service kiosk, or the cashier scans it at the cash register / POS terminal — and contact LoyaltyPlant if it should be enabled. |
| 500 (`ESTABLISHMENT_NOT_FOUND`, `PARTNER_DATA_MISSING`, `CLIENT_SERVICE_ERROR`, `MISSING_REQUIRED_FIELD`, default; v1.7 `CUSTOMER_NOT_FOUND`/`CUSTOMER_WRONG_PARTNER`) | Only transient | Show message. Config/data errors (wrong `establishmentId`, missing partner data) need a fix or LP escalation, not a retry. A transient `CLIENT_SERVICE_ERROR` may be retried once with a fresh salt. |
| HTTP 403 | No | Auth/transport failure, not a loyalty outcome. Recompute `AuthorizationToken` with a fresh salt; never reuse a salt. See the [QR Code Loyalty Guide](04-qr-code-loyalty-guide.md) troubleshooting table. |
| HTTP 400 | No — fix request | Read the JSON-Pointer paths, correct the listed fields (types/enums), and resend. |
| HTTP 503 | Yes — after backoff | Service temporarily unavailable (e.g. Redis down). Retry the request after a short backoff with a fresh salt. |

> **Warning:** Never treat HTTP 200 as unconditional success. For `processQrCode`, a response can carry `accepted: false` with an `error` message. For `orderClosed`/`refundOrder`, a rejection is signalled by `accepted: false` — **not** by `errorCode`, which is `0` on both success and rejection. All of these arrive as HTTP 200, so always inspect the body and branch on `accepted`.

---

**Next:** [Integration Checklist →](09-integration-checklist.md)
