Integration journey
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. 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, or fetch one by ID:
curl https://api-omni.linra.net/api/v1/scent/catalog/scents?pageSize=20 \ -H "Authorization: Bearer $TOKEN"{ "state": "SUCCESS", "payload": { "items": [{ "id": "8f2c...", "name": "Example EDP", "brandId": "3a1b...", "concentration": "Edp" }], "pagination": { "page": 1, "pageSize": 20, "totalCount": 412, "totalPages": 21 } }}Keeping a local mirror in sync? Use changedSince (an ISO timestamp) instead of re-fetching
everything — see Catalog delta sync.
Fails as: 404 NOT_FOUND_SCENT on a bad ID. Lists themselves don’t 404 — an empty items array
just means no matches.
Next: price it.
4. Get a price/quote
Section titled “4. Get a price/quote”Every scent has multiple sizes (variants); get a per-variant price preview using your own commission/margin terms before you commit to anything:
curl https://api-omni.linra.net/api/v1/scent/catalog/scents/8f2c.../quote \ -H "Authorization: Bearer $TOKEN"{ "state": "SUCCESS", "payload": { "scentId": "8f2c...", "name": "Example EDP", "variants": [ { "variantId": "1a2b...", "sizeMl": 100, "listPrice": 340.0, "amount": 320.0, "partnerCommission": 48.0, "currency": "SAR", "totalWithVat": 368.0 } ] }}listPrice is the catalog base price; amount is what YOU actually pay after your own
discount/commission overlay — that’s the figure to use, not listPrice. 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 preview call re-validates.
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):
curl -X POST https://api-omni.linra.net/api/v1/scent/cart/items \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"variantId": "1a2b...", "quantity": 2}'{ "state": "SUCCESS", "payload": { "id": "cart-guid", "status": "Open", "items": [{ "variantId": "1a2b...", "quantity": 2 }], "chargedWithVat": 736.0 }}There is exactly one open cart per partner (or per customerRef if you pass one — 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
(0 removes the line); DELETE /items/{variantId} removes one line; DELETE /items clears the
whole cart.
Fails as: 400 field validation, 404 on an unknown variantId.
Next: check out.
6. Check out
Section titled “6. Check out”Always price the cart first — this re-validates every line’s pricing, stock, and margin, and tells you exactly what’s wrong per line if anything is:
curl https://api-omni.linra.net/api/v1/scent/cart/checkout-preview?lock=true \ -H "Authorization: Bearer $TOKEN"{ "state": "SUCCESS", "payload": { "cartId": "cart-guid", "canCheckout": true, "lines": [{ "variantId": "1a2b...", "quantity": 2, "ok": true, "listPrice": 320.0 }], "lockId": "lock-guid", "lockExpiresAt": "2026-08-05T10:15:00Z", "chargedWithVat": 736.0 }}A line’s issue field (UNAVAILABLE / INSUFFICIENT_STOCK / PRICE_BELOW_FLOOR / NOT_PRICED)
tells you exactly why canCheckout is false for that line — surface it to whoever’s building the
order, don’t just retry blindly. Pass lock=true (only meaningful when canCheckout is already
true) to freeze the previewed prices for lockExpiresAt - now (15 minutes by default); pass the
returned 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/orders/scent \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "externalReference": "your-own-order-ref-001", "geoScopeId": "geo-scope-guid", "shippingAddress": { "recipient": "Jane Doe", "line1": "1 Example St", "city": "Riyadh", "nationalAddress": "RAKB2837", "country": "SA", "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. 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.
{ "state": "SUCCESS", "payload": { "orderId": "order-guid", "orderGlobalId": "ORD-2026-000123", "sagaId": "a1b2c3d4-...", "externalReference": "your-own-order-ref-001", "status": "Completed", "totalAmount": 736.0, "currency": "SAR" }}Don’t send partnerId or sagaId — neither is a request field you fill in: your partner
identity comes from the bearer token, not the body, and sagaId is generated server-side for THIS
order and returned to you on the response above. externalReference is your own idempotency
key — the one id you DO generate — a retried create with the exact same value returns the
original order, never a duplicate charge, even across a dropped connection. sagaId is the
correlation id for this specific order’s create/reserve/charge sequence; keep it (and
orderGlobalId) for support correlation — you’ll see both again verbatim on the order-detail
response in the next step. Note this response’s status (Completed here) describes whether the
create/charge itself succeeded, NOT the order’s ongoing fulfillment state — that’s a separate
vocabulary you’ll see on the order-detail GET next (Processing while it’s being fulfilled).
Fails as: 400 (including an expired/invalid lockId), 409 CONFLICT_CART_HAS_PENDING_ORDER /
CONFLICT_CART_CHECKOUT_RACE (a duplicate or racing checkout on the same cart), 422 BUSINESS_MARGIN_BELOW_FLOOR/BUSINESS_PRICE_BELOW_COST (prices/costs moved since you priced the
cart — re-run the preview and, if the customer still wants it, check out again at the new price).
Next: track it.
7. Track the order
Section titled “7. Track the order”curl https://api-omni.linra.net/api/v1/orders/scent/order-guid \ -H "Authorization: Bearer $TOKEN"{ "state": "SUCCESS", "payload": { "id": "order-guid", "orderGlobalId": "ORD-2026-000123", "sagaId": "a1b2c3d4-...", "status": "Processing", "fulfillmentGroups": [{ "id": "group-guid", "status": "Shipped", "deliveredAt": null }], "totalAmountWithVat": 736.0 }}Three fields worth explaining plainly: sagaId is the server-generated correlation id you
first saw on the create response in step 6, echoed back here too — hand it to support if something
looks wrong. fulfillmentGroups are the shipment(s) your order splits into — one order can
ship as more than one parcel (different vendors, different warehouses); each group has its own
status (Processing → Shipped → Delivered) independent of the others. status on the
order itself is the coarse, unified view — Processing/Completed/Failed/Cancelled/
PartiallyReturned/Returned — while each fulfillmentGroups[].status tracks the finer
shipment-level detail.
Prefer GET /api/v1/orders/{orderGlobalId} when you’re tracking by the global id rather than the
scent-specific one — it describes the same underlying order, though the envelope differs: the
response there is { header: {...}, scentDetail: {...}, detailUnavailableReason: null } —
scentDetail carries this exact shape for a scent order, and detailUnavailableReason is set
instead whenever the detail isn’t available for whatever kind of order it is (this endpoint accepts
every order type the API supports, not only scent).
Fails as: 404 NOT_FOUND_SCENT_ORDER / 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/orders/scent/order-guid/tracking \ -H "Authorization: Bearer $TOKEN"{ "state": "SUCCESS", "payload": { "orderGlobalId": "ORD-2026-000123", "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 } ] }}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
the shipping supplier’s identity, which stays 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_SCENT_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/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-request \ -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", "fulfillmentGroupId": "group-guid", "status": "Pending", "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. Submitting a request moves no money by itself.
Every return now travels a full, ten-state inspection lifecycle before any money moves —
filing a request only reaches Pending, 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, with its own status — see the
order.return-request.updated section of Webhooks & HMAC verification for the
full ten-value state list and, if your integration predates this effort, an important
breaking-change note about what Approved means now.
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 by default. For an ad-hoc
window instead of waiting for the next persisted one, pass mode=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.
You’ve now covered the full loop: credential → token → catalogue → quote → 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).

