Skip to content
Linra Omni Developer Docs

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.

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 — 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.

List scents, filter by brand/concentration/audience/note/accord, or fetch specific ones with ?id= (repeatable):

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

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:

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

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:

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

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:

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

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

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

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

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/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.

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

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

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

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