# Message & Error Codes

Read first: [Status Webhook Guide](06-status-webhook-guide.md)

This is the reference catalog of the codes and statuses that travel across the Digital Ordering API contract: the **HTTP statuses** LoyaltyPlant uses on the webhook, the **order statuses** that move through both directions, the **webhook error codes** LoyaltyPlant returns to your POS, and the **POS error reasons** (`PosErrorType`) you report when an order fails. Field-level definitions live in the spec — the `OrderStatus`, `WebhookErrorCode`, and `WebhookResponse` schemas in the [Digital Ordering API specification](/digital-ordering/api/) — this page adds the meaning and the recommended handling.

## 1. The golden rule: business outcome vs transport error

The Digital Ordering API has two failure surfaces, and they live on opposite sides of the inverted contract. Keep them separate.

- **On the five `/rest/*` endpoints you host, LoyaltyPlant is the client.** There is no LoyaltyPlant-defined error schema for these calls — your service owns its own error surface. LoyaltyPlant treats an order/menu call as *failed* when it cannot get a usable `200` from you: a connection failure, a timeout, an unparseable body, **or** a missing/wrong response `AuthorizationToken` (the `SHA256-hex(apiKeyResponse)` echo required on `/order`, `/orders`, `/menu`, `/menu/revision` — see [General Requirements](02-general-requirements.md)). The healthcheck is the gate: any service reporting `DOWN`, or no answer at all, means LoyaltyPlant does not inject the order. LoyaltyPlant retries `createOrder` (up to 3 attempts, 500 ms apart) on this unavailability before declaring the order not-injected, so a brief healthcheck blip does not necessarily lose the order. None of these is signalled by a code in this catalog — they are transport/availability outcomes.
- **On the one webhook you POST to LoyaltyPlant, LoyaltyPlant is the server** — and *here* it defines an explicit code contract. The HTTP status is authoritative for transport-level acceptance, and the body carries `WebhookResponse { accepted, errors[] }`:
  - **HTTP 200** — `accepted: true`. The update was processed.
  - **HTTP 400** — `accepted: false`, one `errors[]` entry with code `READ_ERROR`, `INVALID_JSON`, or `VALIDATION_ERROR`. A request-shape or auth problem.
  - **HTTP 500** — `accepted: false`, one `errors[]` entry with code `INTERNAL_ERROR`. An unexpected processing failure on LoyaltyPlant's side.

> **Warning:** A webhook `accepted: true` confirms LoyaltyPlant *accepted the request*, not that every order in it was applied. A batch (`orders[]` with several entries) is processed per-order, and a problem with one entry skips just that entry (logged, not an error) while the rest apply — the response is still `200` `accepted: true`. Two per-order skips behave identically: an entry whose `id` is not a known `posId`, and an entry whose `state.status` is not one of the nine `OrderStatus` values (a misspelled or unsupported status). Neither produces an error code; both are silently dropped with a success response. To detect a skipped order, rely on the poll path (`getOrders`) — see [§3](#3-what-loyaltyplant-does-on-each-failure) and the [Status Webhook Guide](06-status-webhook-guide.md).

## 2. The codes the Digital Ordering API actually uses

### 2.1 Order statuses

Every order moves through the nine `OrderStatus` values. Your POS reports them on the webhook (`state.status`) and on `getOrders` reads; LoyaltyPlant maps them to what the customer sees in the app. Per-value meaning is on the `OrderStatus` enum in the spec; the table restates it for reference.

| Status | Terminal? | Meaning |
|---|---|---|
| `PROCESSING` | No | Order received at the POS. |
| `CONFIRMED_WAITING_FOR_COOKING` | No | Accepted and sent to the kitchen. |
| `COOKING` | No | Being prepared. |
| `AWAITING_PICKUP` | No | Ready, awaiting customer pickup. |
| `AWAITING_DELIVERY` | No | Ready, awaiting a courier. |
| `BEING_DELIVERED` | No | Handed to the courier / out for delivery. |
| `PROCESSED` | Yes (success) | Successfully closed at the POS. |
| `CANCELED` | Yes | Canceled at the POS. Set `state.reason`. |
| `FAILED` | Yes | Error creating/processing the order. Set `state.reason`. |

> **Note:** `state.reason` is **required** when the status is `CANCELED` or `FAILED` (it is internal diagnostic text, never shown to the customer). It is consumed on the `getOrders` read; the webhook payload acts on `id` / `queueNumber` / `state.status` and otherwise ignores free-text `reason` — the one exception being a structured `state.posErrorType` on a `CANCELED` / `FAILED` push, which is consumed and recorded (see [§2.3](#23-pos-error-reasons-poserrortype)). A `state.status` string that is **not** one of these nine values does **not** fail the webhook: it is skipped for that one order — logged, not applied — and the response is still `200` `accepted: true`, exactly like the unknown-`posId` case in the [§1 Warning](#1-the-golden-rule-business-outcome-vs-transport-error). `state.status` is read as a plain string, so the body always parses; the enum coercion happens per-order and a failure to recognize the value is caught and the order skipped (it never reaches the `INTERNAL_ERROR` path). Detect a silently skipped status via the `getOrders` poll path — see [§3](#3-what-loyaltyplant-does-on-each-failure).

### 2.2 Webhook error codes

When LoyaltyPlant rejects a status webhook, the `errors[].code` is one of **four wire string literals** (not a numeric catalog, and not a Java enum). Each carries a fixed HTTP status. This is the authoritative catalog; the [Status Webhook Guide](06-status-webhook-guide.md) shows them in the context of the request/response flow.

| `code` | HTTP | Meaning |
|---|---|---|
| `READ_ERROR` | 400 | The request body could not be read off the socket. |
| `INVALID_JSON` | 400 | The body was malformed or incomplete JSON. |
| `VALIDATION_ERROR` | 400 | Header/auth validation failed: missing `AuthorizationToken`, missing `SalesOutletId`, non-numeric `SalesOutletId`, unknown outlet, or a wrong `SHA256-hex(apiKey)` token. |
| `INTERNAL_ERROR` | 500 | An unexpected error escaped while processing the update (an uncaught exception on LoyaltyPlant's side). Note: an unrecognized `state.status` is **not** one of these — it is caught and the order skipped (see [§2.1](#21-order-statuses)), not surfaced as a 500. |

The validation order behind a `VALIDATION_ERROR`, each step producing HTTP 400: missing `AuthorizationToken` → missing `SalesOutletId` → non-numeric `SalesOutletId` → no group/integrated/settings configuration for the outlet → token mismatch (`Invalid AuthorizationToken for salesOutletId=X`).

A rejection body — here, an auth-token failure (HTTP 400). The `description` is free text and varies; the `code` is the stable contract.

```json
{
  "accepted": false,
  "errors": [
    {
      "code": "VALIDATION_ERROR",
      "description": "Invalid AuthorizationToken for salesOutletId=4198"
    }
  ]
}
```

> **Note:** `INVALID_REQUEST` is **not** a Digital Ordering API code. It is returned only by the separate R-Keeper webhook on a different path; the Digital Ordering webhook never emits it. If you see `INVALID_REQUEST`, you are hitting the wrong endpoint. The Digital Ordering webhook returns only the four codes above.
>
> **Note:** On a successful webhook the body is `{ "accepted": true }`; the `errors` field is `null`/absent rather than an empty array. Branch on `accepted` and the HTTP status, not on the presence of `errors`.

### 2.3 POS error reasons (`PosErrorType`)

The codes in [§2.2](#22-webhook-error-codes) describe *transport* problems with a webhook **request**. They say nothing about *why an order failed at the POS*. For that, the Digital Ordering API contract carries an optional, structured **`posErrorType`** on the order `state`, next to the free-text `reason`. You report it at the two moments an order can fail at your side:

- **At injection** — when you cannot accept the order, your `createOrder` response returns `state: { status: FAILED, reason, posErrorType }` instead of a `posId` (see [Order Injection Guide §3.7](05-order-injection-guide.md#37-rejecting-an-order-at-injection-structured-failure)).
- **Later** — when an already-injected order is cancelled or fails, you set `state.posErrorType` on the `CANCELED` / `FAILED` status you report on the webhook and on `getOrders` (see the [Status Webhook Guide](06-status-webhook-guide.md)).

`reason` stays free text (human-readable diagnostic detail); `posErrorType` is the **structured** classification LoyaltyPlant records against the order and surfaces in LP CRM, reports, and alerting. It is internal — never shown to the customer.

The accepted values are a **curated allow-list** of reasons a POS can meaningfully self-report. LoyaltyPlant infers the transport / availability / lifecycle reasons (POS unreachable, rate-limited, restaurant never accepted the order, …) on its own side; you do not send those.

| `posErrorType` | Meaning |
|---|---|
| `ITEM_UNAVAILABLE` | An ordered item is unknown to the POS (wrong/unmapped product id) or out of stock. |
| `ITEM_MODIFIER_INVALID` | A modifier sent for an item is not valid for it. |
| `ITEM_MODIFIER_MISSING` | A required modifier group is not satisfied (below its minimum / a missing mandatory modifier). |
| `ITEM_MODIFIER_EXCESS` | More modifiers sent for a group than it allows. |
| `ITEM_SIZE_INVALID` | An item's size / variant is invalid or unknown to the POS. |
| `ITEM_PRICE_MISMATCH` | An item's price disagrees between LoyaltyPlant and the POS. |
| `DISCOUNT_INVALID` | A discount or coupon could not be applied by the POS. |
| `DISCOUNT_MISMATCH` | The POS applied the discount but its computed amount disagrees with LoyaltyPlant's. |
| `DELIVERY_ADDRESS_INVALID` | Delivery address is malformed / invalid / too long for the POS. |
| `DELIVERY_ADDRESS_UNAVAILABLE` | The address is not within any configured POS delivery zone. |
| `DELIVERY_AREA_UNAVAILABLE` | No delivery zone is configured for the establishment at all. |
| `TIME_UNAVAILABLE` | Requested pickup/delivery time is invalid or cannot be fulfilled. |
| `BAD_REQUEST` | The order was rejected as malformed/invalid (generic) with no more specific cause. |
| `UNKNOWN` | A POS-stage failure you cannot classify into one of the above. |

> **Note:** The list is curated but **not frozen**. If your POS can report a meaningful failure reason that none of these values captures, contact LoyaltyPlant to have it **added to the enum**. Until it is added, send `UNKNOWN` with a descriptive free-text `reason` — do **not** invent your own literal. An unrecognized value is treated as `UNKNOWN` (the order still fails on the `status` you sent). `posErrorType` is **optional**: omitting it falls back to LoyaltyPlant's own classification of the failure, so sending it only ever improves the recorded reason.

## 3. What LoyaltyPlant does on each failure

How LoyaltyPlant reacts — and therefore what your integration should do — depends on which surface failed.

| Situation | What LoyaltyPlant does | What you should do |
|---|---|---|
| Healthcheck reports any service `DOWN`, or does not answer | Retries `createOrder` up to 3 times (500 ms apart), then — if still unavailable — treats the outlet as unavailable and **does not inject** the order (fail-safe). | Report honest `UP`/`DOWN`. The customer is not given a confirmed order, so a true `DOWN` prevents a lost ticket rather than causing one. |
| `createOrder` fails, times out, or returns no usable `200` (including a missing/wrong response `AuthorizationToken`) | Discards the response and treats the order as **not created**; the customer is not confirmed. | Respond within the timeouts and **echo the response `AuthorizationToken`** (`SHA256-hex(apiKeyResponse)`) on `createOrder` (see [General Requirements](02-general-requirements.md)). |
| Webhook returns `VALIDATION_ERROR` / `INVALID_JSON` / `READ_ERROR` (400) | Does not apply the update; the customer's app does not advance. The failure is **deterministic**. | **Fix, then resend** — a byte-identical retry fails identically. Check the `AuthorizationToken` (`SHA256-hex(apiKey)`), the numeric `SalesOutletId`, and the JSON shape. Do not loop. |
| Webhook returns `INTERNAL_ERROR` (500) | Does not apply the update; the error is **transient** on LoyaltyPlant's side. | **Retry with backoff.** If it persists, let polling reconcile the state and report the recurring 500 to LoyaltyPlant. |
| Webhook batch entry has an unknown `posId` | **Skips** that entry (logs it), applies the rest, and still answers `200` `accepted: true`. | Send the **`posId`** you returned from `createOrder` (not LoyaltyPlant's order id), and rely on polling to catch any skipped order. |
| Webhook batch entry has an unrecognized `state.status` (misspelled or unsupported) | **Skips** that entry (logs it), applies the rest, and still answers `200` `accepted: true` — **no `INTERNAL_ERROR`, no 500**. | Send `state.status` as one of the nine `OrderStatus` values exactly. A `200` is **not** proof the status was applied — reconcile via the `getOrders` poll path; do **not** retry-with-backoff (there is no failure to retry). |
| A webhook is never delivered (timeout, connection error, repeated failure) | **Polls** your `getOrders` on a schedule and reconciles to the current state on the next cycle. | Keep the poll path (`getOrders`) working so a dropped webhook degrades the customer experience instead of breaking it. |
| An order reaches terminal `CANCELED` / `FAILED` | Surfaces the terminal state to the customer and **reverses** the loyalty points and rewards tied to the order. | Report the terminal status promptly and include `state.reason` on the `getOrders` read so LoyaltyPlant and you can diagnose. |

> **Note:** LoyaltyPlant's lifecycle transitions are **idempotent by status**: it cancels only if the order is not already `CANCELED`, and completes only if it is not already `PROCESSED`. Re-delivering a status it has already applied — whether via a retry or a push/poll overlap — is a safe no-op, so there is no de-duplication key for you to send.

---

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