Status Webhook Guide
Read first: General Requirements · Ordering Flows · Order Injection Guide
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 "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 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 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. 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). 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.
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).
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), 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 | Yes |
Webhook authentication is covered in full in General Requirements §2 — 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.
{
"orders": [
{
"id": "POS-9F3A21",
"queueNumber": "A17",
"state": {
"status": "COOKING"
}
}
]
}
LoyaltyPlant acts only on three fields per order:
id— theposIdyour POS returned fromcreateOrder(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 nineOrderStatusvalues, 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 200accepted: 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). 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 isstate.posErrorTypeon aCANCELED/FAILEDpush — see §2.3.
You can batch several orders in one orders[] array. LoyaltyPlant processes each entry independently — one bad entry does not sink the others (§4).
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 for the value list and the "added by request" rule.
{
"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.
{
"accepted": true
}
A rejection (here, an auth-token failure):
{
"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.statusdoes not produceINTERNAL_ERRORor any rejection. It is caught per-order, logged, and skipped — the webhook still returns 200accepted: trueand 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). The 400/500 codes above cover request-level failures (read/parse/auth) and unexpected processing failures — never a per-order bad status.Note:
INVALID_REQUESTis 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.
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) 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), a retry that duplicates an already-applied status is safe. |
Warning: Do not retry a
VALIDATION_ERROR,INVALID_JSON, orREAD_ERRORunchanged — they are deterministic and will fail identically until you fix the request. Reserve retries forINTERNAL_ERROR(500) and transport failures. In all cases, keep 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 posIds 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 and the Order Injection Guide.
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 alreadyPROCESSED. 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/FAILEDmust carry a free-textreasonwhen reported via the poll path (getOrders). On the webhook, LoyaltyPlant acts onid/queueNumber/state.statusand additionally consumes the structuredstate.posErrorTypewhen present (§2.3); the free-textreasonitself still reaches LoyaltyPlant through thegetOrdersread. 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
apiKeyon the webhook (an older rule). ✅ SendSHA256-hex(apiKey)as the webhookAuthorizationToken— the same hashed token as every LoyaltyPlant→vendor call. - ❌ Omitting
SalesOutletId, or sending it non-numeric. Either fails withVALIDATION_ERROR(400) before any order is processed — LoyaltyPlant needs it to resolve the outlet and its key. ✅ Send a numericSalesOutletIdheader on every webhook. - ❌ Sending LoyaltyPlant's order id as
orders[].id. LoyaltyPlant looks the order up by theposIdyour POS returned fromcreateOrder; the wrong id is silently skipped (logged, the order is not updated) and the response can still beaccepted: true. ✅ Use theposIdyou returned at creation. - ❌ Treating any HTTP 200 as "delivered to the customer". A batch can return
accepted: truewhile one entry was skipped (unknownposId). ✅ Verify each order id is the correctposId, and keep polling enabled so skips are reconciled. - ❌ Hammering retries on a 400. A
VALIDATION_ERROR/INVALID_JSON/READ_ERRORis deterministic; retrying unchanged just repeats the failure. ✅ Fix the request, then resend; reserve automatic retries forINTERNAL_ERROR(500) and transport failures.
Next: Message & Error Codes →