Skip to main content

Order Injection Guide

Read first: General Requirements · Ordering Flows

This guide walks you through implementing the two order endpoints LoyaltyPlant calls on your service once a customer checks out: getHealthcheck (the availability probe LoyaltyPlant runs before every order) and createOrder (the call that injects a paid order into your POS), plus getOrders (the reconciliation read LoyaltyPlant uses to poll order state). Remember the inverted contract: you host these /rest/... endpoints and LoyaltyPlant is the HTTP client — it calls you.

Field-level truth — every field's type, required-ness, and enum values — lives in the Digital Ordering API specification; this guide links to it and never restates schemas. The operations covered here are getHealthcheck, createOrder, and getOrders.

The whole sequence you implement on this side:

The key point of the sequence: the healthcheck always runs first, the create response must carry the response AuthorizationToken header, and getOrders is the safety net LoyaltyPlant uses to catch up on state when a status webhook was missed.

1. Prerequisites

You need four things from LoyaltyPlant, all assigned at onboarding and defined in full in General Requirements: your hosted base URL {vendorBaseUrl}, the numeric SalesOutletId header, and the two keys — the request key apiKey and the response key apiKeyResponse. The request- and response-token mechanics are covered there too; this guide assumes you have already implemented request-token validation and focuses on the order payloads and the one response-token rule that most often goes wrong (§3.6).

2. Step 1 — GET /rest/healthcheck

LoyaltyPlant calls getHealthcheckGET {vendorBaseUrl}/rest/healthcheckimmediately before every order injection, and only proceeds with createOrder if your answer says you can take the order. This is not a periodic liveness ping; it runs on the critical path of each individual order.

Return a flat JSON object whose keys are service names you choose and whose values are UP or DOWN. There is no fixed key set — list the real services whose failure would stop you fulfilling an order (for example your POS, your database, your kitchen system). The full shape is the HealthcheckResponse schema in the spec.

{
"POS": "UP",
"DB": "UP",
"KITCHEN": "UP"
}

The decision rule LoyaltyPlant applies to your answer:

  • All values UP → LoyaltyPlant proceeds to createOrder.
  • Any value DOWN → LoyaltyPlant treats the outlet as unavailable and does not create the order. One DOWN blocks the whole order, regardless of how many services are UP.
  • No response, a non-parseable body, or an error → also treated as unavailable; the order is not created. The availability check is fail-safe — a broken healthcheck stops orders rather than letting them through blindly.

Note: Like /order and /orders, the healthcheck response must carry the AuthorizationToken header equal to SHA256-hex(apiKeyResponse) — LoyaltyPlant verifies it and discards the response if it is missing or wrong. (LoyaltyPlant also sends the request AuthorizationToken to you; the response token is the one you set.)

Warning: Report honestly. A blanket "POS": "UP" while your kitchen system is actually down means LoyaltyPlant will inject orders you cannot fulfil. Wire each reported service to a real liveness check, not a hardcoded constant.

3. Step 2 — POST /rest/order

When the customer has paid and confirmed, LoyaltyPlant injects the order by calling createOrderPOST {vendorBaseUrl}/rest/order. The request body is the full order, wrapped under an order key (CreateOrderRequestOrder in the spec). Your POS creates the order and returns the POS order id and a customer-facing queue number.

The full request example is the pickup / delivery / eatIn named examples on createOrder in the spec, and the response is CreateOrderResponseExample. Rather than repeat the schema, the rest of this section walks the Order payload by concern, so you know what each block means and which fields are mandatory in which case.

Note: Required-ness on the order is direction-dependent and business-driven, so the spec deliberately does not mark most order fields as schema-required. On a createOrder request LoyaltyPlant always sends type, deliveryAt, client, payment (with the charge totals), additionalInfo, and items — plus address when type = DELIVERY. Treat that set as required input even though the schema does not enforce it.

3.1 Items and modifiers

The paid order lines arrive in items[]. Each line carries an id (the menu item id you published in GET /rest/menu), a price, a count, and an optional modifiers[] array; each modifier carries its own id (a menu modifier id), price, and count. The only strictly required field on each line and modifier is id — match it to your catalog and build the kitchen ticket from it.

Note: The id on an order line maps to the menu item's global id (the id field, not the response-scoped refId) from your getMenu response. Resolve orders against that product identity, never against the response-scoped refId. See the Menu Sync Guide for the id / code / refId distinction.

Note: Which menu identity that id carries is a per-outlet onboarding choice. By default LoyaltyPlant sends the menu item's id; if your outlet is configured with the sendPosCode option, LoyaltyPlant instead sends the menu item's code (the outlet-local product code) in the very same id field. The field is always named id on the wire — there is no separate code field on an order line — so the switch is invisible in the payload shape. It applies uniformly to items, rewards, and modifiers. Your getMenu response publishes both id and code on every entity (both required; see the Menu Sync Guide §3.2), so choose whichever identity is more natural for your POS — the one it already keys products by — and have LoyaltyPlant set the outlet to match at onboarding, then resolve order lines against it.

3.2 The client

client carries the customer's contact details: name, email, and phoneNumber are always sent (and are marked required on the Client schema); lastName is optional. Use these for delivery contact and order identification — they are the customer as entered in the LoyaltyPlant app.

3.3 Payment methods and the charge breakdown

payment.method.type is one of three payment instruments, and your POS must record the right tender against the order:

  • CASH — the customer pays cash on collection or delivery; nothing is charged in-app.
  • BANK_CARD_IN_APP — the card was already charged inside the LoyaltyPlant app; the order arrives prepaid. Masked card details may be present in payment.method.cardInfo (cardHolderName, masked pan — never a full clear PAN).
  • BANK_CARD_IN_STORE — the customer will pay by card at your POS; collect payment on your side.

The money breakdown is in payment.charge. On a create request, tax, subtotal, and total are always sent (and are the required fields on the Charge schema). tips, deliveryFee, and discount are optional and appear when relevant — deliveryFee typically only for delivery, discount when a discount was applied in-app. The total is the grand total the customer pays; build your POS check to match it rather than recomputing from line items, which may differ after app-side discounts and rounding.

Note: Monetary fields are JSON numbers (decimals) with no declared currency, scale, or rounding rule at the protocol level. The outlet's currency and the decimal precision to use are agreed with LoyaltyPlant at onboarding; confirm them and verify at certification. If your POS works in integer minor units (e.g. cents), convert on the way in — these are decimal values (in production, up to 3 fractional digits have been observed) and charge.total is authoritative.

3.4 Discounts, gifts, and presents

A discount already applied in the app reaches you as the single charge.discount total — it is reflected in the total you receive; do not re-apply it.

Reward items the customer redeemed with loyalty points arrive in a separate presents[] array (not in items[]). They share the OrderItem shape, but your POS must charge 0 for them — a present never increases the total the customer pays. Add them to the order at zero price.

Note: Combos have no generic wire model in the Digital Ordering API. Where a combo exists it is handled per-POS as ordinary items plus modifier groups (and, for the iiko adapter, a buy-one-get-one flag) — model it as an item with embedded modifier groups; there is no generic combo contract to implement.

3.5 Order type, timing, delivery address, and metadata

Order type. type decides the fulfilment experience. The supported types are:

  • PICKUP — the customer collects at the counter and shows the queue number; no address.
  • DELIVERY — courier delivery; the address block (with coordinates and addressString1) is required, and a deliveryFee may be present.
  • EAT_IN — dine-in, optionally tied to a table via additionalInfo.tableNumber.
  • DRIVE_THROUGH — the customer collects from a drive-through lane; like PICKUP, no delivery address is sent. Applies to outlets that have a drive-through lane.

The exact wire tokens are PICKUP, DELIVERY, EAT_IN, and DRIVE_THROUGH (note PICKUP, not PICK_UP). The OrderingType enum additionally defines CATERING, which is code-backed but has no current production usage — do not implement against it unless LoyaltyPlant confirms your outlet uses it. For dine-in at a table use EAT_IN with additionalInfo.tableNumber. The authoritative enum is OrderingType in the spec.

Delivery address. When type = DELIVERY, address is sent with coordinates (latitude/longitude) and addressString1 (the primary single-line address) at minimum; zipCode, country, city, street, house, flat, and addressString2 may also be present. Use the coordinates for routing and the address strings for the courier.

Timing — deliveryAt. Every order says when it is wanted: type = ASAP (prepare as soon as possible) or type = CUSTOM with a datetime (ISO 8601, in the outlet's timezone). On ASAP the datetime is omitted; schedule preparation against whichever mode you receive.

Metadata — additionalInfo. Carries tableNumber (dine-in), loyaltyPlantOrderId (LoyaltyPlant's internal numeric order id — useful for support correlation), and customFields (free-form string key/value pairs agreed at onboarding). Store loyaltyPlantOrderId against your POS order so support can cross-reference.

3.6 The response — posId, queueNumber, and the mandatory response token

On success, return HTTP 200 with the POS order id and the queue number (CreateOrderResponse):

{
"posId": "POS-9F3A21",
"queueNumber": "A17"
}
  • posId — your POS's own id for the created order. LoyaltyPlant stores it and uses it on getOrders and the status webhook, so it must be the id you can later look the order up by.
  • queueNumber — the customer-facing pickup/queue number, declared as a string (numeric values are accepted).

Once LoyaltyPlant accepts your response, it marks the order CONFIRMED_WAITING_FOR_COOKING and the customer is told the order is on its way to the kitchen. From there, status flows back via the webhook (see Status Webhook Guide).

Warning: Your 200 response to createOrder MUST carry an AuthorizationToken response header equal to SHA256-hex(apiKeyResponse). If the header is missing or wrong, LoyaltyPlant discards your entire response and treats the order as failed — even though your POS already created it. This is the single most common cause of a "my order was lost" report: the kitchen has the ticket, but LoyaltyPlant never saw a valid response, so the customer's order shows as failed. Set this header on the success response, computed from apiKeyResponse (not apiKey). The header rules are detailed in General Requirements.

Note: On acceptance, the create response is the bare { "posId": ..., "queueNumber": ... } object — no order wrapper and no requestId. (LoyaltyPlant's deserializer also tolerates an order-wrapped form with an optional requestId, but the bare object is the contract.) Return the bare object shown here; for the rejection shape see §3.7.

3.7 Rejecting an order at injection (structured failure)

If your POS cannot accept the order, return a structured rejection instead of a posId: HTTP 200, still carrying the response AuthorizationToken header, with a state block whose status is FAILED, plus a free-text reason and an optional structured posErrorType:

{
"state": {
"status": "FAILED",
"reason": "Item 42 is out of stock",
"posErrorType": "ITEM_UNAVAILABLE"
}
}

LoyaltyPlant then marks the order FAILED, records the posErrorType against it (surfaced in LP CRM and reports — see Message & Error Codes §2.3), and does not confirm the customer. posErrorType is optional but recommended; choose the closest value from the curated list, and if none fits send UNKNOWN with a descriptive reason. Omit posErrorType entirely and LoyaltyPlant classifies the failure itself.

Warning: A rejection is still a response — it must carry the response AuthorizationToken header (SHA256-hex(apiKeyResponse)). Without it, the rejection is indistinguishable from a transport failure and is discarded (the token rule in §3.6 applies to rejections too, not only to acceptances).

4. Step 3 — POST /rest/orders

getOrdersPOST {vendorBaseUrl}/rest/orders — is how LoyaltyPlant pulls the current state of orders it already created. It is a read, expressed as a POST because the id list travels in the body. The request is a list of posId values under orderPosIds; the response is one Order per id, with the current state filled in.

Request (GetOrdersRequest):

{
"orderPosIds": ["POS-9F3A21", "POS-9F3A22"]
}

For each requested posId, return its Order with at least id (echo the posId), queueNumber, and state.status; on a read, those plus the line items are the meaningful fields. The full response is the GetOrdersResponseExample / GetOrdersCanceledExample on getOrders in the spec.

Note: Order.id means different things by direction. On the getOrders response (this endpoint), Order.id is your posId — echo back the same posId LoyaltyPlant sent in orderPosIds, because LoyaltyPlant keys the poll match on it (it reads order.getId() and matches each returned order to the posId it requested). This is the opposite of the createOrder request, where order.id is LoyaltyPlant's order id. So: on the create request, order.id = LoyaltyPlant's id; on the getOrders response (and the webhook), id = your posId. (The getOrders response is the bare { "orders": [...] } object — each entry an Order with id, queueNumber, and state. A server-generated requestId is optional and not required; the live integrations observed omit it.)

4.1 The status enum

state.status is one of nine OrderStatus values — the same set used by the status webhook:

StatusMeaning
PROCESSINGOrder received at the POS.
CONFIRMED_WAITING_FOR_COOKINGAccepted and sent to the kitchen.
COOKINGBeing prepared.
AWAITING_PICKUPReady, awaiting customer pickup.
AWAITING_DELIVERYReady, awaiting a courier.
BEING_DELIVEREDHanded to the courier / out for delivery.
PROCESSEDSuccessfully closed at the POS (terminal success).
CANCELEDCanceled at the POS (terminal; set reason).
FAILEDError creating/processing the order (terminal; set reason).

When the status is CANCELED or FAILED, also set state.reason — a free-text explanation. It is internal information not shown to the customer, but it helps LoyaltyPlant and you diagnose the case. The reason is required on those two terminal statuses. You may additionally set the structured state.posErrorType (the same curated enum used on a createOrder rejection — see Message & Error Codes §2.3); LoyaltyPlant records it against the order alongside the free-text reason.

Note: Your /orders response must also carry the response AuthorizationToken header (SHA256-hex(apiKeyResponse)) — the same rule as createOrder (§3.6). Without it, LoyaltyPlant discards the read and learns nothing about the orders' state.

5. Message codes in responses

The success contract for these three endpoints is HTTP 200. There is no per-operation business-error envelope on getHealthcheck / createOrder / getOrders — LoyaltyPlant treats a missing/unparseable body, a DOWN healthcheck, or a missing/wrong response token as a transport-level failure and does not create or update the order (see §7–§8). The one structured business outcome carried on these endpoints is a createOrder rejection — a token-valid 200 whose body is state.status = FAILED with an optional posErrorType (see §3.7) — and the matching posErrorType on a CANCELED / FAILED getOrders read. The one place the Digital Ordering API defines explicit error codes is the status webhook your POS sends to LoyaltyPlant (READ_ERROR / INVALID_JSON / VALIDATION_ERROR / INTERNAL_ERROR). Those codes, their HTTP statuses, and how to react to each are catalogued in Message and Error Codes.

6. Test transcripts

You can exercise both order endpoints against a mock LoyaltyPlant client with curl, simulating what LoyaltyPlant sends to your service. Substitute your real {vendorBaseUrl}, SalesOutletId, and tokens from onboarding.

Note: The base URL, SalesOutletId, and the request/response API keys are provided by LoyaltyPlant at onboarding. Substitute your real values for the placeholders below.

VENDOR_BASE_URL="{vendorBaseUrl}"
SALES_OUTLET_ID=1234
API_KEY="<your apiKey>"

# LoyaltyPlant computes the request token as SHA256-hex(apiKey)
REQ_TOKEN=$(printf '%s' "$API_KEY" | shasum -a 256 | cut -d' ' -f1)

# --- Step 1: healthcheck (runs before every order) ---
curl -s "$VENDOR_BASE_URL/rest/healthcheck" \
-H "SalesOutletId: $SALES_OUTLET_ID" \
-H "AuthorizationToken: $REQ_TOKEN"
# expect: {"POS":"UP","DB":"UP","KITCHEN":"UP"} (any DOWN blocks the order)

Pickup order (cash) — the simplest happy path:

curl -s -X POST "$VENDOR_BASE_URL/rest/order" \
-H "Content-Type: application/json" \
-H "SalesOutletId: $SALES_OUTLET_ID" \
-H "AuthorizationToken: $REQ_TOKEN" \
-d '{
"order": {
"id": "LP-ORD-1001",
"type": "PICKUP",
"client": { "name": "Maria", "lastName": "Lopez", "email": "maria@example.com", "phoneNumber": "+34911223344" },
"deliveryAt": { "type": "ASAP" },
"payment": { "method": { "type": "CASH" }, "charge": { "subtotal": 6.90, "tax": 0.69, "total": 7.59 } },
"additionalInfo": { "loyaltyPlantOrderId": 5550001 },
"items": [ { "id": "1", "price": 6.90, "count": 1, "modifiers": [ { "id": "9001", "price": 0.00, "count": 1 } ] } ]
}
}'
# Your service must respond 200 { "posId": "POS-9F3A21", "queueNumber": "A17" }
# AND set response header: AuthorizationToken: SHA256-hex(apiKeyResponse)

Delivery order (card-in-app, with a reward present) — note the required address block and the zero-price presents[]:

curl -s -X POST "$VENDOR_BASE_URL/rest/order" \
-H "Content-Type: application/json" \
-H "SalesOutletId: $SALES_OUTLET_ID" \
-H "AuthorizationToken: $REQ_TOKEN" \
-d '{
"order": {
"id": "LP-ORD-1002",
"type": "DELIVERY",
"client": { "name": "John", "lastName": "Smith", "email": "john@example.com", "phoneNumber": "+14155550123" },
"deliveryAt": { "type": "CUSTOM", "datetime": "2026-06-13T19:30:00Z" },
"address": { "coordinates": { "latitude": 40.41678, "longitude": -3.70379 }, "city": "Madrid", "street": "Gran Via", "house": "28", "flat": "4B", "addressString1": "Gran Via 28, 4B", "zipCode": "28013", "country": "ES" },
"payment": { "method": { "type": "BANK_CARD_IN_APP", "cardInfo": { "cardHolderName": "JOHN SMITH", "pan": "411111******1111" } }, "charge": { "subtotal": 13.80, "deliveryFee": 2.50, "tax": 1.38, "tips": 2.00, "total": 19.68 } },
"additionalInfo": { "loyaltyPlantOrderId": 5550002 },
"presents": [ { "id": "42", "price": 0.00, "count": 1 } ],
"items": [ { "id": "1", "price": 6.90, "count": 2 } ]
}
}'

Eat-in order (card-in-store, at a table) — dine-in uses EAT_IN plus additionalInfo.tableNumber:

curl -s -X POST "$VENDOR_BASE_URL/rest/order" \
-H "Content-Type: application/json" \
-H "SalesOutletId: $SALES_OUTLET_ID" \
-H "AuthorizationToken: $REQ_TOKEN" \
-d '{
"order": {
"id": "LP-ORD-1003",
"type": "EAT_IN",
"client": { "name": "Aisha", "email": "aisha@example.com", "phoneNumber": "+971501234567" },
"deliveryAt": { "type": "ASAP" },
"payment": { "method": { "type": "BANK_CARD_IN_STORE" }, "charge": { "subtotal": 7.50, "tax": 0.75, "total": 8.25 } },
"additionalInfo": { "tableNumber": 12, "loyaltyPlantOrderId": 5550003 },
"items": [ { "id": "1", "price": 7.50, "count": 1 } ]
}
}'

Status pull — LoyaltyPlant reconciling two orders by their posId:

curl -s -X POST "$VENDOR_BASE_URL/rest/orders" \
-H "Content-Type: application/json" \
-H "SalesOutletId: $SALES_OUTLET_ID" \
-H "AuthorizationToken: $REQ_TOKEN" \
-d '{ "orderPosIds": ["POS-9F3A21", "POS-9F3A22"] }'
# Your service must respond 200 with one order per id, each carrying state.status,
# AND set response header: AuthorizationToken: SHA256-hex(apiKeyResponse)

These payloads mirror the pickup / delivery / eatIn request examples and the byIds request example on the createOrder and getOrders operations in the spec — keep your fixtures in sync with the spec, not with this guide.

7. Common mistakes

  • Creating the order but not setting the response AuthorizationToken header (or computing it from apiKey instead of apiKeyResponse). LoyaltyPlant discards your 200, the order shows as failed, and the kitchen ticket is orphaned. ✅ Always set AuthorizationToken: SHA256-hex(apiKeyResponse) on the /order and /orders responses.
  • Reporting a hardcoded "UP" healthcheck while a backing service is actually down — LoyaltyPlant injects orders you cannot fulfil. ✅ Wire each service status to a real check.
  • Charging for presents[] — a reward item billed at full price double-charges the customer. ✅ Add presents at price 0.
  • Dropping the address block on a DELIVERY order — the courier has no destination. ✅ For DELIVERY, require coordinates + addressString1.
  • Returning a posId you cannot later look up. LoyaltyPlant uses that exact posId on getOrders and the webhook; a throwaway value breaks reconciliation and status. ✅ Return your durable POS order id.
  • Omitting state.reason on CANCELED/FAILED in a getOrders response — LoyaltyPlant and support cannot diagnose the failure. ✅ Always include a reason on those terminal statuses.
  • Sending an OrderStatus value outside the nine (typo, custom state) on a read or webhook — LoyaltyPlant cannot parse it. ✅ Map your internal states onto exactly the nine OrderStatus values.

8. Troubleshooting

SymptomLikely causeFix
"My order was lost" — the kitchen has the ticket but the customer's order shows as failedYour 200 response to createOrder was missing or had a wrong AuthorizationToken header, so LoyaltyPlant discarded it.Set AuthorizationToken: SHA256-hex(apiKeyResponse) on the success response; verify the key is apiKeyResponse, not apiKey.
LoyaltyPlant never sends createOrderYour getHealthcheck returned a DOWN value, no body, or an unparseable body — any of these blocks the order.Return HTTP 200 with a valid flat { "service": "UP" } map and all services UP when you can actually take orders.
createOrder requests are rejected before reaching your logicThe request AuthorizationToken (which LoyaltyPlant sends as SHA256-hex(apiKey)) failed your validation — wrong key on your side.Validate against SHA256-hex(apiKey); confirm the apiKey value with LoyaltyPlant.
Order created, but BANK_CARD_IN_APP total mismatches your line totalsThe app applied a discount or tip; trust charge.total rather than recomputing.Build the POS check to charge.total; record BANK_CARD_IN_APP as prepaid.
getOrders returns orders but LoyaltyPlant ignores themThe /orders response was missing the response AuthorizationToken header, so LoyaltyPlant discarded the read.Set the response token on /orders exactly as on /order.
Order times out on LoyaltyPlant's side during creationYour POS-creation work exceeded the 60 s read timeout.Acknowledge fast and do slow work asynchronously; respond within the read budget.

Note: LoyaltyPlant retries a failed injection internally — up to 3 attempts (the initial call plus 2 retries) on its PosUnavailableException, 500 ms apart — so a transient DOWN or a dropped response may be re-attempted, but a persistently wrong response token will fail every retry. Treat the response-token rule as non-negotiable.


Next: Status Webhook Guide →