# Status Webhook Guide

Read first: [General Requirements](02-general-requirements.md) · [Ordering Flows](03-ordering-flows.md) · [Order Injection Guide](05-order-injection-guide.md)

The order-status webhook is the **one** call in the Digital Ordering API that runs from your side to LoyaltyPlant. Everything else inverts: LoyaltyPlant is the HTTP client and calls the five `/rest/*` endpoints you host (see the [Ordering Flows](03-ordering-flows.md) "read this twice" note). This webhook is the exception — *you* POST it to LoyaltyPlant to report that an order's status changed. It is the **push** alternative to LoyaltyPlant polling your [`getOrders`](/digital-ordering/api/) endpoint.

This guide covers the receiving contract: when to send, the exact request, the response and its error codes, what to retry, how the webhook relates to polling, and the common mistakes that break it. Field-level definitions live in the spec — the [`orderStatusWebhook`](/digital-ordering/api/) callback, `OrdersWebhookRequest`, and `WebhookResponse` — and this page never restates schemas.

## 1. When to send

Send the webhook **every time an order's status changes** in your POS, for an order that LoyaltyPlant created via [`createOrder`](/digital-ordering/api/). The order has to exist on LoyaltyPlant's side first — LoyaltyPlant injects it, your POS returns a `posId`, and from then on each status transition is a webhook you push back. The webhook is modeled in the spec as the `orderStatusWebhook` callback on `createOrder` for exactly this reason: it reports the lifecycle of the order that `createOrder` created.

You report transitions across the nine `OrderStatus` values: `PROCESSING`, `CONFIRMED_WAITING_FOR_COOKING`, `COOKING`, `AWAITING_PICKUP`, `AWAITING_DELIVERY`, `BEING_DELIVERED`, and the terminal `PROCESSED`, `CANCELED`, `FAILED`. The status set, with per-value meaning, is the `OrderStatus` enum in the spec.

The webhook is the **push** half of status reporting. The **poll** half is LoyaltyPlant calling your `getOrders` on a schedule. They are alternatives that report the same state; you can run push only, or push with poll as a backstop (recommended — see [§5](#5-relationship-to-polling)). LoyaltyPlant grants the customer loyalty points when the order reaches a terminal success and reverses them on a terminal `CANCELED`/`FAILED`, so prompt, accurate status reporting is what keeps the customer's app and their points correct.

```mermaid
sequenceDiagram
    participant POS as YOUR POS
    participant LP as LoyaltyPlant
    participant App as Customer's app

    Note over POS: Order status changes (e.g. COOKING)
    POS->>LP: POST /digitalordering/pos/v2/integrated
    LP-->>POS: 200 { "accepted": true }
    LP-->>App: Status updates in the app
    Note over POS,LP: On a rejection (400/500) the customer's app did not get the update — retry or rely on polling
```

The key point of the diagram: a status change in your POS only reaches the customer's app once LoyaltyPlant answers `accepted: true`. A rejected webhook is a dropped update — handle it ([§4](#4-retry-policy)).

> **Note:** Only status *changes* need a webhook. Re-sending the same status is harmless (LoyaltyPlant's lifecycle transitions are guarded by the order's current state — re-delivering a terminal status is a no-op, see [§5](#5-relationship-to-polling)), but it adds noise. Send on transition.

## 2. The request

`POST {lpBaseUrl}/digitalordering/pos/v2/integrated`

`{lpBaseUrl}` is LoyaltyPlant's base URL — the real value is provisioned at onboarding (e.g. `https://release.loyaltyplant.com/digitalordering/pos/v2/integrated`). The path `/digitalordering/pos/v2/integrated` is fixed: `digitalordering` is the deployed WAR context, and the controller maps `POST /pos/v2/integrated` under it.

### 2.1 Headers

| Header | Value | Required |
|---|---|---|
| `Content-Type` | `application/json` | Yes |
| `SalesOutletId` | Numeric outlet id (must parse as an integer) | Yes |
| `AuthorizationToken` | `SHA256-hex(apiKey)` for the outlet — see [General Requirements §2](02-general-requirements.md) | Yes |

Webhook authentication is covered in full in [General Requirements §2](02-general-requirements.md) — the webhook `AuthorizationToken` is `SHA256-hex(apiKey)`, the **same** hashed token as every LoyaltyPlant→vendor call.

### 2.2 Payload

The body is `OrdersWebhookRequest`: an `orders[]` array, one entry per order whose status changed. Each entry needs `id`, `queueNumber`, and `state.status`. The full schema is `OrdersWebhookRequest` / `WebhookOrder` in the [spec](/digital-ordering/api/).

```json
{
  "orders": [
    {
      "id": "POS-9F3A21",
      "queueNumber": "A17",
      "state": {
        "status": "COOKING"
      }
    }
  ]
}
```

LoyaltyPlant **acts only on** three fields per order:

- **`id`** — the **`posId`** your POS returned from `createOrder` (not LoyaltyPlant's order id). LoyaltyPlant looks the order up by this.
- **`queueNumber`** — the customer-facing queue / pickup number. LoyaltyPlant updates the stored number from it.
- **`state.status`** — one of the nine `OrderStatus` values, matched exactly. A value that does not match one of the nine (a typo, wrong case, or an unknown status) is **not** rejected: LoyaltyPlant logs it and skips that order, the webhook still returns 200 `accepted: true`, and that order's state is **not** advanced. So a misspelled status is a silent no-op for that order — detectable only via the poll path ([§5](#5-relationship-to-polling)). Send the exact enum value.

> **Note:** Any other order fields (address, payment, items, presents, deliveryAt, comment) are **accepted and ignored** on the webhook — you do not need to send the full order, just the minimal shape above. This is the opposite of `createOrder`, which carries the full order. The one structured field beyond the minimal shape that LoyaltyPlant *does* read is `state.posErrorType` on a `CANCELED` / `FAILED` push — see [§2.3](#23-reporting-a-structured-failure-reason-poserrortype).

You can batch several orders in one `orders[]` array. LoyaltyPlant processes each entry independently — one bad entry does not sink the others ([§4](#4-retry-policy)).

### 2.3 Reporting a structured failure reason (`posErrorType`)

When you push a terminal `CANCELED` or `FAILED`, you may add a structured `state.posErrorType` next to the free-text `reason`. It is the same curated enum used on a `createOrder` rejection — see [Message & Error Codes §2.3](07-message-and-error-codes.md#23-pos-error-reasons-poserrortype) for the value list and the "added by request" rule.

```json
{
  "orders": [
    {
      "id": "POS-9F3A23",
      "queueNumber": "A19",
      "state": {
        "status": "CANCELED",
        "reason": "Kitchen 86'd the item",
        "posErrorType": "ITEM_UNAVAILABLE"
      }
    }
  ]
}
```

Unlike the other non-minimal fields, LoyaltyPlant **does** consume `posErrorType` on a terminal-failure push and records it against the order (surfaced in LP CRM and reports). It is **optional**: omit it and LoyaltyPlant classifies the failure on its own side. An unrecognized value is treated as `UNKNOWN`.

## 3. The response

LoyaltyPlant replies with `WebhookResponse { accepted, errors[] }`. On success: HTTP **200** with `accepted: true`. On failure: HTTP 400 or 500 with `accepted: false` and one `errors[]` entry carrying a `code` and a `description`. The schema is `WebhookResponse` in the [spec](/digital-ordering/api/).

```json
{
  "accepted": true
}
```

A rejection (here, an auth-token failure):

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

The four error codes are wire **string literals** (not a numeric catalog), each with a fixed HTTP status:

| `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 `AuthorizationToken`. |
| `INTERNAL_ERROR` | 500 | A genuinely unexpected server-side failure while processing the request. An *unknown `state.status`* does **not** land here — it is a per-order skip with a 200 (see the Note below). |

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

> **Note:** An unrecognized `state.status` does **not** produce `INTERNAL_ERROR` or any rejection. It is caught per-order, logged, and skipped — the webhook still returns 200 `accepted: true` and that order's state is not advanced. So a typo'd or unknown status is a silent no-op for that order; it is detectable only via the poll path ([§5](#5-relationship-to-polling)). The 400/500 codes above cover request-level failures (read/parse/auth) and unexpected processing failures — never a per-order bad status.
>
> **Note:** `INVALID_REQUEST` is **not** a Digital Ordering API code — it belongs to the separate R-Keeper webhook on a different path. The Digital Ordering webhook returns only the four codes above. The full catalog, with recommended handling, is in [Message & Error Codes](07-message-and-error-codes.md).

## 4. Retry policy

A non-200 response means the customer's app did **not** get that status update. What to do depends on the `code`:

| `code` / outcome | Retry? | Action |
|---|---|---|
| HTTP 200, `accepted: true` | No | Delivered. Done. |
| `VALIDATION_ERROR` (400) | No — fix first | A retry of the identical request fails identically. Check the `AuthorizationToken` is `SHA256-hex(apiKey)` (the same hashed token as every LoyaltyPlant→vendor call, **not** the raw key) and the `SalesOutletId` is correct and numeric, then resend. If the outlet is genuinely unknown to LoyaltyPlant, escalate at onboarding — do not loop. |
| `INVALID_JSON` / `READ_ERROR` (400) | No — fix first | Your serialization is wrong or the body was truncated. Fix the payload (it must be valid `OrdersWebhookRequest` JSON); a byte-identical retry will not help. |
| `INTERNAL_ERROR` (500) | Yes — backoff | A transient server-side error. Retry with backoff. If it persists, let polling pick the state up ([§5](#5-relationship-to-polling)) and report the recurring 500 to LoyaltyPlant. |
| No response / timeout / connection error | Yes — backoff | Treat like a transient failure: retry with backoff. Because LoyaltyPlant's lifecycle transitions are idempotent ([§5](#5-relationship-to-polling)), a retry that duplicates an already-applied status is safe. |

> **Warning:** Do not retry a `VALIDATION_ERROR`, `INVALID_JSON`, or `READ_ERROR` unchanged — they are deterministic and will fail identically until you fix the request. Reserve retries for `INTERNAL_ERROR` (500) and transport failures. In all cases, keep [polling](#5-relationship-to-polling) enabled so a webhook you ultimately cannot deliver still gets reconciled.

A batch (`orders[]` with several entries) is processed per-order: LoyaltyPlant catches and logs a failure on one order and continues with the rest, so an unknown `id` in the batch does not reject the whole webhook — that order is simply skipped (logged, not an error) while the others apply and the response is still `accepted: true`. To detect a skipped order, rely on the poll path.

## 5. Relationship to polling

Push (this webhook) and poll (LoyaltyPlant calling your `getOrders`) report the **same** order state. Polling is LoyaltyPlant's fallback: it periodically calls `POST {vendorBaseUrl}/rest/orders` with a list of `posId`s and reads `state.status` from each returned order, so a missed or undeliverable webhook is eventually reconciled on the next poll cycle. The full poll contract is `getOrders` in the [spec](/digital-ordering/api/) and the [Order Injection Guide](05-order-injection-guide.md).

What this means for how you build the push side:

- **Idempotent by status, not by delivery.** LoyaltyPlant applies a status by comparing it to the order's current state: it cancels only if the order is not already `CANCELED`, and completes only if it is not already `PROCESSED`. Re-delivering a status LoyaltyPlant has already applied is a safe no-op — so retries and a push/poll overlap do not double-count. There is no de-duplication key you need to send.
- **Ordering is your responsibility on the push side.** LoyaltyPlant acts on each webhook as it arrives; it does not reorder out-of-sequence pushes. Send transitions in the order they happen. If pushes can race (e.g. concurrent POS events), polling will still converge LoyaltyPlant on the latest state.
- **Push is faster; poll is the safety net.** Push gives the customer near-real-time updates; poll guarantees eventual consistency even if every webhook for an order is dropped. Supporting both is recommended so a dropped webhook degrades the customer experience instead of breaking it.

> **Note:** Terminal `CANCELED` / `FAILED` must carry a free-text `reason` when reported via the poll path (`getOrders`). On the webhook, LoyaltyPlant acts on `id` / `queueNumber` / `state.status` and additionally consumes the structured `state.posErrorType` when present ([§2.3](#23-reporting-a-structured-failure-reason-poserrortype)); the free-text `reason` itself still reaches LoyaltyPlant through the `getOrders` read. So a cancellation delivered by webhook tells LoyaltyPlant the order is terminal and (if you send it) the structured reason, while the free-text detail is reconciled via polling.

## 6. Common mistakes

- ❌ **Sending the raw, un-hashed `apiKey` on the webhook** (an older rule). ✅ Send `SHA256-hex(apiKey)` as the webhook `AuthorizationToken` — the **same** hashed token as every LoyaltyPlant→vendor call.
- ❌ **Omitting `SalesOutletId`, or sending it non-numeric.** Either fails with `VALIDATION_ERROR` (400) before any order is processed — LoyaltyPlant needs it to resolve the outlet and its key. ✅ Send a numeric `SalesOutletId` header on every webhook.
- ❌ **Sending LoyaltyPlant's order id as `orders[].id`.** LoyaltyPlant looks the order up by the **`posId`** your POS returned from `createOrder`; the wrong id is silently skipped (logged, the order is not updated) and the response can still be `accepted: true`. ✅ Use the `posId` you returned at creation.
- ❌ **Treating any HTTP 200 as "delivered to the customer".** A batch can return `accepted: true` while one entry was skipped (unknown `posId`). ✅ Verify each order id is the correct `posId`, and keep polling enabled so skips are reconciled.
- ❌ **Hammering retries on a 400.** A `VALIDATION_ERROR` / `INVALID_JSON` / `READ_ERROR` is deterministic; retrying unchanged just repeats the failure. ✅ Fix the request, then resend; reserve automatic retries for `INTERNAL_ERROR` (500) and transport failures.

---

**Next:** [Message & Error Codes →](07-message-and-error-codes.md)
