Skip to content

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.

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.

Get a token.

Terminal window
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.

List scents, filter by brand/concentration/audience, or fetch one by ID:

Terminal window
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.

Every scent has multiple sizes (variants); get a per-variant price preview using your own commission/margin terms before you commit to anything:

Terminal window
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.

Add an item (merges quantity into an existing line for the same variant):

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

Terminal window
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 (ProcessingShippedDelivered) 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).

Once fulfilment is underway, a lighter, delivery-focused read is available — one call per order rather than the whole priced order body:

Terminal window
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.

Poll once to confirm your integration works, then switch to push:

Terminal window
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.

Terminal window
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.

Terminal window
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).