Integration journey
One continuous path from a credential to a delivered order — every step, what to call, what comes back, and how it fails.
This is the path a real integration follows, start to finish. Each step names the plain thing you’re doing, the exact call, a real example response, where to go next, and the failure modes you’ll actually hit — not the full reference (that’s the API Reference), just enough to keep moving.
1. Get credentials
Section titled “1. Get credentials”There’s no credential-creation endpoint on this API — credentials are issued and managed on the
Linra Omni Portal dashboard, a separate surface from this API entirely. See
Credentials for why. You’ll end up with a clientId (live_...) and a
clientSecret, shown once — store both in your own secret manager immediately.
Next: exchange them for a token.
2. Authenticate
Section titled “2. Authenticate”Get a token.
curl -X POST https://api-omni.linra.net/api/v1/auth/token \ -H "Content-Type: application/json" \ -d '{"clientId": "live_9f8c...", "clientSecret": "your-secret"}'const res = await fetch('https://api-omni.linra.net/api/v1/auth/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId: CLIENT_ID, clientSecret: CLIENT_SECRET }),});const { payload } = await res.json();// payload.accessToken, payload.expiresIn (seconds — always 300 today){ "state": "SUCCESS", "payload": { "accessToken": "eyJhbGciOi...", "tokenType": "Bearer", "expiresIn": 300 }}The token is valid for 5 minutes — and that is exact: an expired token is rejected within at
most 30 seconds of clock skew, with 401 UNAUTHORIZED_TOKEN_EXPIRED. Cache it and refresh
proactively (a little before expiry) — see Authentication & tokens for
the pattern.
Fails as: 401 UNAUTHORIZED_INVALID_CREDENTIALS (wrong id/secret — the error deliberately
doesn’t say which), 403 FORBIDDEN_NOT_YOUR_DESCENDANT/FORBIDDEN_DELEGATION_TARGET_INVALID (only
if you’re using onBehalfOf delegation for a sub-partner and got the target wrong).
Next: browse the catalogue.
3. Browse the catalogue
Section titled “3. Browse the catalogue”List scents, filter by brand/concentration/audience/note/accord, or fetch specific ones with ?id= (repeatable):
curl https://api-omni.linra.net/api/v1/scent/scents?pageSize=20 \ -H "Authorization: Bearer $TOKEN"{ "state": "SUCCESS", "payload": { "items": [{ "id": "8f2c...", "brandId": "3a1b...", "name": "Example EDP", "images": [], "pricing": { "currency": "SAR", "from": null }, "info": { "productType": "Perfume", "concentration": "Edp", "audience": "Unisex" } }], "pagination": { "page": 1, "pageSize": 20, "totalCount": 412, "totalPages": 21 } }}Keeping a local mirror in sync? Use changedSince (an ISO 8601 timestamp; without an offset it is
read as UTC, and a date-only value means 00:00Z) instead of re-fetching everything — see
Catalog delta sync. Add include=profile for the full fragrance profile, or
include=pricing for your lowest variant price (pricing.from).
Fails as: lists don’t 404 — an ?id= that doesn’t exist, or a filter with no matches, is just an
empty items array. Query input is strict: an unknown parameter name, a value that doesn’t parse, an
unknown include token or a pageSize outside 1–100 returns 400 (VALIDATION_UNKNOWN_QUERY_PARAMETER,
VALIDATION_INVALID_QUERY_PARAMETER, VALIDATION_UNKNOWN_INCLUDE, VALIDATION_INVALID_PAGE_SIZE) —
see Errors.
Next: price it.
4. Get a price
Section titled “4. Get a price”Every scent has multiple sizes (variants). Ask the variants list for the scent you want with
include=pricing to get your own per-variant price before you commit to anything:
curl "https://api-omni.linra.net/api/v1/scent/variants?scentId=8f2c...&include=pricing,availability" -H "Authorization: Bearer $TOKEN"{ "state": "SUCCESS", "payload": { "items": [ { "id": "1a2b...", "scentId": "8f2c...", "sku": "EX-EDP-100", "sizeMl": 100, "isSample": false, "pricing": { "currency": "SAR", "unitPrice": 320.0, "goodsVatAmount": 48.0, "totalWithVat": 368.0, "rrp": null, "commission": { "amount": 48.0, "vatAmount": 7.2, "netAfterCommission": 272.0 } }, "availability": { "state": "Available", "availableQuantity": null } } ], "pagination": { "page": 1, "pageSize": 20, "totalCount": 1, "totalPages": 1 } }}pricing.unitPrice is what YOU pay per unit, ex-VAT — that’s the figure to use. This example is a
Retail-mode price, so commission is filled and rrp is null; in Wholesale mode it’s the other
way round (rrp filled, commission null). The mode is your own settlement arrangement with Linra —
see the Scent product page. This is a read-only preview — nothing is reserved
yet. The number can still move by the time you check out (stock and pricing are live); that’s
exactly what step 6’s checkout preview re-validates.
Fails as: 400 VALIDATION_UNKNOWN_INCLUDE for an include token other than pricing,
availability or tax. A variant without a price for your account keeps its row, with the
pricing amounts null.
Next: add it to a cart.
5. Build a cart
Section titled “5. Build a cart”Add an item (merges quantity into an existing line for the same variant). Every cart call
carries a required customerRef path segment — an opaque reference for whichever of your own end
customers this cart belongs to:
curl -X POST https://api-omni.linra.net/api/v1/scent/cart/cust-1001/items \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"variantId": "1a2b...", "quantity": 2}'{ "state": "SUCCESS", "payload": { "id": "cart-guid", "customerRef": "cust-1001", "status": "Open", "items": [{ "variantId": "1a2b...", "quantity": 2 }], "totals": { "chargedWithVat": 736.0 } }}The cart row is created automatically on this first add — there’s no separate “open a cart” call
(see Idempotency for why cart operations themselves don’t need a separate
idempotency key the way order-create does). PUT .../items/{variantId} sets a quantity outright,
or adjusts it by action: "increment"/"decrement" (±1); DELETE .../items/{variantId} removes
one line; DELETE .../items clears the whole cart.
Fails as: 400 field validation, 404 on an unknown variantId, 422 if the variant can’t
be added right now (BUSINESS_VARIANT_UNAVAILABLE, BUSINESS_VARIANT_NOT_PRICED,
BUSINESS_INSUFFICIENT_STOCK).
Next: check out.
6. Check out
Section titled “6. Check out”Always price the cart first — this re-validates every line’s pricing and stock, and tells you exactly what’s wrong per line if anything is. This call is pure-read; it never takes a lock itself:
curl https://api-omni.linra.net/api/v1/scent/cart/cust-1001/checkout-preview \ -H "Authorization: Bearer $TOKEN"{ "state": "SUCCESS", "payload": { "cartId": "cart-guid", "canCheckout": true, "lines": [{ "variantId": "1a2b...", "quantity": 2, "ok": true, "pricing": { "unitPrice": 320.0, "...": "..." } }], "totals": { "chargedWithVat": 736.0 } }}A line’s issue field (UNAVAILABLE / INSUFFICIENT_STOCK / PRICE_NOT_AVAILABLE / NOT_PRICED / VARIANT_NOT_ASSIGNED)
tells you exactly why canCheckout is false for that line — surface it to whoever’s building the
order, don’t just retry blindly. Once canCheckout is true, take a price lock with a SEPARATE
call to freeze the previewed prices for 15 minutes:
curl -X POST https://api-omni.linra.net/api/v1/scent/cart/cust-1001/price-lock \ -H "Authorization: Bearer $TOKEN"{ "state": "SUCCESS", "payload": { "id": "lock-guid", "expiresAt": "2026-08-05T10:15:00Z" } }Pass the returned id as lockId on create so a slow checkout can’t get re-priced out from under
you — see Cart preview & price locks for the full mechanics.
Then create the order — note that order LINES are never part of this request; they come from your already-priced open cart:
curl -X POST https://api-omni.linra.net/api/v1/scent/orders \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "externalReference": "your-own-order-ref-001", "customerRef": "cust-1001", "shippingAddress": { "recipient": "Jane Doe", "line1": "1 Example St", "city": "Riyadh", "nationalAddress": "RAKB2837", "countryCode": "SAU", "phone": "+966500000000" }, "lockId": "lock-guid" }'shippingAddress.nationalAddress is required — the customer’s KSA national short address code
(up to 64 characters, e.g. RAKB2837). It’s the mandatory basis for delivery-zone determination:
city is what actually decides whether the order qualifies for the (cheaper/free) “within Riyadh”
zone, but nationalAddress verifies the address itself rather than trusting free text. Omitting it
fails validation before the order is ever priced. countryCode is an ISO 3166-1 alpha-3 code
("SAU", not the older 2-letter "SA"). The order’s charged total also includes an order-level
shipping fee computed from that zone — see
Cart preview & price locks for how it’s computed,
its VAT treatment, and why it’s never refunded on a return.
lockId is required — no live-priced create. Pass the lockId from step 5’s locked preview;
it’s single-use, so this specific lock can fund exactly this one order and no other.
{ "state": "CREATED", "payload": { "id": "order-guid", "externalReference": "your-own-order-ref-001", "customerRef": "cust-1001", "status": "Processing", "pricing": { "currency": "SAR", "subtotal": 640.0, "vat": 96.0, "total": 736.0, "...": "..." }, "modeBreakdown": { "Retail": 640.0 } }}The response is a 201 with state: "CREATED" and a Location header holding the order’s absolute
URL (https://api-omni.linra.net/api/v1/scent/orders/{orderId}). modeBreakdown sums the order’s
line totals (ex-VAT) per settlement mode — here the two lines at 320.00 in your Retail mode give
{"Retail": 640.0}, matching pricing.subtotal. Each order line carries the same value in its own
pricingMode (Wholesale or Retail).
Don’t send partnerId, geoScopeId, or sagaId — none is a request field: your partner
identity comes from the bearer token, and neither geoScopeId nor sagaId exists on this
surface at all (both were internal, server-populated fields on an older shape). externalReference
is your own idempotency key — the one id you DO generate. It never double-charges you, but a
retry against an order that already succeeded now REJECTS with 409 CONFLICT_ORDER_DUPLICATE_REFERENCE rather than replaying the original order — see
Idempotency for the full recovery protocol (the existing order’s id rides
along in the error’s details.orderId). The order’s own id going forward is id — read it back
via GET /api/v1/scent/orders/{orderId}, where orderId is this same value. status here describes
whether the create/charge itself succeeded, NOT the order’s ongoing fulfillment state — you’ll see
the fuller lifecycle (Processing → Completed/Cancelled/(Partially)Returned) on the
order-detail GET next; see Order states & errors for the full state list.
Fails as: 400 (a missing/invalid lockId, an expired lock, or ordinary validation), 409 CONFLICT_ORDER_DUPLICATE_REFERENCE/CONFLICT_ORDER_IN_PROGRESS/CONFLICT_ORDER_REQUIRES_INTERVENTION
(a prior attempt with this externalReference), 409 CONFLICT_PRICE_LOCK_ALREADY_USED (this
lockId already funded a different order), 409 CONFLICT_CART_HAS_PENDING_ORDER /
CONFLICT_CART_CHECKOUT_RACE (a duplicate or racing checkout on the same cart), 422 BUSINESS_PRICE_NOT_AVAILABLE (a price moved since you priced the cart — re-run the preview and, if
the customer still wants it, check out again at the new price).
See Order states & errors for the full catalogue.
Next: track it.
7. Track the order
Section titled “7. Track the order”curl https://api-omni.linra.net/api/v1/scent/orders/order-guid \ -H "Authorization: Bearer $TOKEN"{ "state": "SUCCESS", "payload": { "id": "order-guid", "externalReference": "your-own-order-ref-001", "status": "Processing", "pricing": { "currency": "SAR", "subtotal": 640.0, "vat": 96.0, "total": 736.0, "...": "..." }, "fulfillmentGroups": [{ "id": "group-guid", "status": "Shipped", "deliveredAt": null }], "deliveryRollup": { "totalGroups": 1, "deliveredGroups": 0 } }}By-id is always the FULL shape — lines/fulfillmentGroups are always included here (unlike the
list endpoint, which needs ?include=lines). fulfillmentGroups are the shipment(s) your
order splits into — one order can ship as more than one parcel (different warehouses, different
carriers); each group has its own status (Processing → Shipped → Delivered) independent of
the others. status on the order itself is the coarse, unified view — see
Order states & errors for every value and what it means — while each
fulfillmentGroups[].status tracks the finer shipment-level detail. If status is Failed, a
sanitized failureReason field is also present (never otherwise) — see that same guide for the
possible values.
There is no orderGlobalId, no sagaId, and no partnerId on this response — none of the three
is ever partner-facing; your own id for this order is the id field above (and the orderId you
already hold from step 6’s create response — the same value).
Fails as: 404 NOT_FOUND_ORDER — unknown id, or one belonging to another partner (no existence
leak — always 404, never 403).
Dedicated delivery tracking
Section titled “Dedicated delivery tracking”Once fulfilment is underway, a lighter, delivery-focused read is available — one call per order rather than the whole priced order body:
curl https://api-omni.linra.net/api/v1/scent/orders/order-guid/tracking \ -H "Authorization: Bearer $TOKEN"{ "state": "SUCCESS", "payload": { "status": "Processing", "deliveryRollup": { "totalGroups": 1, "deliveredGroups": 0 }, "shipments": [ { "shipmentId": "33333333-3333-3333-3333-333333333333", "status": "InTransitToCustomer", "trackingNumber": "TRK-1234567890", "carrierDisplayName": "Aramex", "isConsolidated": false, "packedAt": "2026-08-09T08:00:00Z", "dispatchedAt": "2026-08-09T09:00:00Z", "deliveredAt": null } ] }}There is no order id on this body at all — the order is already addressed by the orderId path
segment. shipments[].status includes the exception states an order can land in mid-transit —
FailedDelivery, RefusedReceipt, ReturnedToSender, and Lost (a shipment that goes missing
entirely) — check for these, not just Delivered, if you surface delivery status to your own
customers. A Lost shipment triggers Linra’s own loss-handling process (a shortfall cancel and
refund) automatically — you’ll see the result via the order.line-unfulfillable/order.returned
webhooks and this same tracking read, not as a separate event type of its own.
carrierDisplayName is the carrier’s name — never
Linra’s own internal fulfilment details, which stay internal — and we never redirect you (or your
customer) to a carrier-branded tracking page; this endpoint is the tracking surface. A consolidated
shipment (isConsolidated: true) represents more than one of your order’s fulfilment groups
travelling together as one parcel.
Fails as: 404 NOT_FOUND_ORDER — unknown id, or one belonging to another partner.
Next: stop polling — subscribe to webhooks instead.
8. Webhooks
Section titled “8. Webhooks”Poll once to confirm your integration works, then switch to push:
curl -X POST https://api-omni.linra.net/api/v1/scent/webhooks \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"url": "https://your-integration.example.com/webhooks/linra", "eventTypes": ["order.completed", "order.cancelled"]}'{ "state": "SUCCESS", "payload": { "subscription": { "id": "sub-guid", "url": "https://your-integration.example.com/webhooks/linra", "eventTypes": ["order.completed", "order.cancelled"], "isActive": true }, "secret": "whsec_5f8a2e1c9b3d4a6f8e0c2b1a7d9e3f4c" }}The secret is shown exactly once — store it immediately, it’s your HMAC signing key. Full
verified signature-checking code (Node.js, tested against a real delivery) lives in
Webhooks & HMAC verification — don’t hand-roll the verification step from
scratch, copy that sample.
Fails as: 422 BUSINESS_WEBHOOK_SUBSCRIPTION_LIMIT (5 active subscriptions max per partner —
rotate or delete one first).
Next: handle the case where the customer wants their money back.
9. Returns
Section titled “9. Returns”curl -X POST https://api-omni.linra.net/api/v1/scent/orders/order-guid/return-requests \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "fulfillmentGroupId": "group-guid", "lines": [{ "scentOrderLineId": "line-guid", "quantity": 1, "reasonCode": "ChangedMind" }] }'{ "state": "SUCCESS", "payload": { "id": "return-request-guid", "orderId": "order-guid", "fulfillmentGroupId": "group-guid", "status": "Requested", "timeline": { "requestedAt": "2026-08-09T10:00:00Z" }, "lines": [{ "scentOrderLineId": "line-guid", "quantity": 1, "reasonCode": "ChangedMind" }] }}Return requests are per-line, not per-order — lines is required and non-empty, even for a
single-item return. reasonCode is one of a fixed set, not free text: Damaged, WrongItem,
NotAsDescribed, ChangedMind, Other. Both ids come from responses you already hold:
fulfillmentGroupId is the SAME shipment id from step 7’s fulfillmentGroups[].id (a return is
requested against a specific shipment, since different shipments on the same order can be at
different delivery stages), and scentOrderLineId is the id field on that same order-detail
response’s lines[] array. orderId on the response is the order this return belongs to — the
same Scent order GUID you’ve been using throughout this walkthrough. Submitting a request moves no
money by itself.
Every return now travels a full inspection lifecycle before any money moves — filing a request
only reaches Requested, and inspection is mandatory for every return, with no bypass (there’s no
one-shot “approve = refunded” action anymore). GET .../return-requests lists every return request
filed against an order. One status vocabulary for returns, used everywhere: this REST response’s
own status and the order.return-request.updated webhook’s newStatus are the SAME coarse,
partner-safe subset — Requested/Accepted/Processing/Refunded/Rejected/Withdrawn — you
never see the finer internal RMA lifecycle (WaybillIssued/InTransit/UnderInspection/
InspectionAccepted/…) on either surface; several of those internal steps all collapse to
Processing on the wire. See the order.return-request.updated section of
Webhooks & HMAC verification for the full mapping, and the money shape below
(refund{}) for what actually lands once a refund releases.
Once a refund has genuinely landed (status: "Refunded"), the response also carries a nested
refund{} block mirroring the order’s own pricing{} shape:
{ "refund": { "currency": "SAR", "goods": { "net": 111.11, "vat": 16.67, "total": 127.78 }, "commissionClawback": 12.78, "shippingFeeRefund": null, "total": 127.78 } }You can change your mind: PATCH .../return-requests/{id}/withdraw withdraws a request while
it’s still Requested — before we’ve taken any action on it — with no money moved and no
penalty; the line becomes immediately eligible for a fresh request. The moment we act on it
(authorize, decline, or anything further downstream), withdrawal locks out permanently with 422 BUSINESS_RETURN_REQUEST_NOT_PENDING. See the order.return-request.withdrawn section of
Webhooks & HMAC verification for the payload and its dedicated event.
If a line becomes unfulfillable after you’ve already paid
Section titled “If a line becomes unfulfillable after you’ve already paid”Occasionally a line can’t be fulfilled after the order is charged — a receiving-time
shortage/damage that couldn’t be replaced in time, or a lost shipment. You’ll be notified via the
order.line-unfulfillable webhook and get 72 hours to choose CancelWhole (full refund of the
line) or ShipAvailable (ship what’s available, cancel only the shortfall) — miss the window and a
documented default is applied for you. See the order.line-unfulfillable section of
Webhooks & HMAC verification for the payload, the response endpoint, and the
default-on-timeout behavior.
Next: reconcile what you’re owed.
10. Statements
Section titled “10. Statements”curl "https://api-omni.linra.net/api/v1/statements?pageSize=10" \ -H "Authorization: Bearer $TOKEN"Returns your own persisted, immutable, weekly-generated statements as lean summary rows — fetch
one by ID (GET /api/v1/statements/{id}) for its full rendered document. For an ad-hoc window
instead of waiting for the next persisted one, use GET /api/v1/statements/report?from=...&to=...
— you get back a live, never-persisted provisional report (isProvisional: true) covering exactly
that range; useful for reconciliation, not a substitute for the real statement. See
Billing statements for the full shapes.
You’ve now covered the full loop: credential → token → catalogue → price → cart → checkout → track → webhook → return → statement. From here, the API Reference has the complete wire shape for every field on every endpoint above, and the guides in the sidebar go deep on any one step (idempotency, price locks, rate limits, error codes, delta sync).

