Skip to content
Linra Omni Developer Docs

Cart preview & price locks

Price a scent cart, resolve per-line issues, and freeze prices with a dedicated lock call before checkout.

Scent orders are always created from the caller’s own cart — order lines are never part of the create request itself. The flow is: build a cart, preview it, optionally take a price lock, then create the order.

Every cart call carries a required customerRef path segment — an opaque, partner-namespaced reference for whichever of your own end customers this cart belongs to (max 128 characters). Pick a stable value per end customer and reuse it across every cart/checkout/price-lock call for that customer.

Terminal window
POST /api/v1/scent/cart/cust-1001/items
{ "variantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "quantity": 2 }

The cart row itself is lazy-created — there is nothing to set up ahead of time; the first POST .../items call for a given customerRef creates it.

2. Preview before you ever create an order

Section titled “2. Preview before you ever create an order”
Terminal window
GET /api/v1/scent/cart/cust-1001/checkout-preview
{
"state": "SUCCESS",
"payload": {
"cartId": "...",
"customerRef": "cust-1001",
"lines": [
{
"variantId": "...",
"quantity": 2,
"sku": "...",
"ok": true,
"issue": null,
"pricing": {
"currency": "SAR",
"unitPrice": 249.0,
"line": { "subtotal": 498.0, "goodsVatAmount": 74.7, "totalWithVat": 572.7, "commission": null },
"rrp": null
},
"availability": { "state": "Available", "availableQuantity": null }
}
],
"canCheckout": true,
"cartIssue": null,
"baseCurrency": "SAR",
"totals": {
"chargedWithVat": 602.7,
"commissionWithVat": 0.0,
"netAfterCommission": 602.7,
"shipping": { "amount": 30.0, "vatAmount": 4.5, "amountWithVat": 34.5, "freeThreshold": 499.0, "isFree": false }
},
"pricedAt": "2026-08-02T10:00:00Z"
}
}

Always call this before POST /api/v1/scent/orders. It’s the only way to find out, ahead of time, whether every line in the cart can actually be checked out — it runs the SAME resolution order-create runs (pricing, VAT, availability, and whether each price is still available), so a preview that says canCheckout: true can never independently disagree with what create would actually do. This endpoint is genuinely pure-read — it never reserves anything and never takes a price lock itself (see step 3). canCheckout is true only when every line reports ok: true, the cart is non-empty, and there’s no already-pending order on it. Per-line issue is one of:

Issue Meaning What to do
UNAVAILABLE The variant isn’t currently sellable — out of stock everywhere, or deactivated since it was added to the cart. Don’t offer this line to the customer as-is. Remove it or swap it for an in-stock alternative; retrying the same request won’t change the outcome.
INSUFFICIENT_STOCK Some stock exists, but less than the requested quantity. Show the customer the real ceiling — availability.availableQuantity is populated when availability.state is Limited — and let them lower the quantity rather than resubmitting the same one.
PRICE_NOT_AVAILABLE The line’s price is not available right now — prices moved since it was last priced. This isn’t customer-fixable. Drop the line (or treat it as temporarily unavailable) and don’t keep retrying the same price/quantity; re-preview later if you want to try again.
NOT_PRICED No active price exists for this variant/partner combination. Treat it the same as UNAVAILABLE for checkout purposes — don’t offer it until pricing is configured on your account.
VARIANT_NOT_ASSIGNED The variant was removed from your account’s assortment after it was added to the cart. The line is kept, but it blocks the price lock and order-create. Remove the line. It won’t clear on retry; ask your account manager if you expected the variant to stay in your assortment.

A line with an issue still carries its variantId/quantity/sku so you can identify it, but pricing/availability are both null for NOT_PRICED (there is no price to show).

Three cart-level issues (cartIssue, not per-line) can also block checkout:

  • CART_HAS_PENDING_ORDER — an order already exists against this exact cart and hasn’t reached a terminal state yet. The blocking order’s id is echoed back as cartIssue.pendingOrderReference; poll or subscribe to webhooks for that order and try checkout again once it reaches a terminal status (Completed/Failed/Cancelled) — don’t just retry the checkout itself, it will keep rejecting until the pending order resolves.
  • PRODUCT_ACCESS_DISABLED — Linra has paused this partner’s ability to start anything new on Scent (an internal hold applied at the partner level, independent of any one cart or line). It can appear even on an otherwise-empty cart, and it takes precedence over CART_HAS_PENDING_ORDER when both would otherwise apply — it’s the more fundamental “nothing new right now” condition. Orders already in flight are unaffected and keep fulfilling normally. There’s nothing to retry around: surface a generic “temporarily unavailable” message to the customer and stop attempting new checkouts until the hold is lifted — contact your Linra account manager if you weren’t expecting it. The same condition surfaces as 422 BUSINESS_PRODUCT_ACCESS_DISABLED if you skip the preview and go straight to adding items or creating an order while paused.
  • PRICE_NOT_AVAILABLE — every line is individually fine, but the basket as a whole can’t be priced right now (order-create would reject with 422 BUSINESS_PRICE_NOT_AVAILABLE). Adjust the basket (drop a line, or add another) and re-preview.

Prices can move between when you preview and when you actually create the order — a supply price change, a price-list update, a promotional price update. If your checkout has any delay between “show the customer a price” and “charge them” (a payment step, a confirmation screen), take a price lock — a separate, dedicated call:

Terminal window
POST /api/v1/scent/cart/cust-1001/price-lock

This recomputes the SAME checkout preview internally and only persists a lock when the recomputed preview reports canCheckout: true — a broken basket is never locked. On success it returns:

{
"state": "SUCCESS",
"payload": {
"id": "b8f1c2a0-...",
"expiresAt": "2026-08-02T10:15:00Z"
}
}

If the recomputed preview reports canCheckout: false, the call rejects outright with 422 BUSINESS_CART_NOT_CHECKOUT_READY instead of returning a lock — re-run the checkout preview to see exactly which line/cart issue is blocking it.

The lock is valid for 15 minutes from the moment it’s taken. Any cart mutation (add, change quantity, remove, clear) invalidates the active lock immediately — a lock always reflects the exact cart contents at the moment it was taken, never a moving target. Only one lock can be active per cart at a time; taking a new one supersedes the old.

Terminal window
POST /api/v1/scent/orders
{
"externalReference": "po-2026-04-1029",
"customerRef": "cust-1001",
"shippingAddress": { "recipient": "...", "line1": "...", "city": "...", "nationalAddress": "RAKB2837", "countryCode": "SAU", "phone": "..." },
"lockId": "b8f1c2a0-..."
}

partnerId is never a request field — it comes from your bearer token. There’s no geoScopeId or sagaId either; both were server-only fields on an older shape and were removed from the partner surface entirely. customerRef is optional here (it must match the customerRef you used to build the cart being checked out; omit it only if you built the cart with no customerRef). shippingAddress.countryCode is an ISO 3166-1 alpha-3 code (e.g. "SAU") — an unrecognized code is rejected 400 VALIDATION_INVALID_COUNTRY_CODE.

lockId is required. Every order is placed against a frozen price lock and charged exactly the locked per-line prices — live-priced create (no lockId) is no longer allowed. The locked figures are still checked at create time: if a locked price is no longer available, create rejects with 422 BUSINESS_PRICE_NOT_AVAILABLE. A lock protects you from routine price drift, not from a price being withdrawn.

A price lock is single-use: the moment an order is created from it, the lock is stamped consumed. Reusing an already-consumed lockId — retrying with the same lock after a successful create, for example — is rejected 409 CONFLICT_PRICE_LOCK_ALREADY_USED, with the order it was already used for echoed back in details.orderId so you can GET /api/v1/scent/orders/{orderId} and recover instead of guessing. This is a genuinely different situation from a retried externalReference (see the Idempotency guide) — a lock can only ever fund ONE order, no matter how many times a client retries with it.

A lockId that’s unknown, doesn’t belong to your own cart, has expired, or no longer matches the cart’s current version (because the cart was mutated after the lock was taken) is rejected as 400 VALIDATION_PRICE_LOCK_INVALID or 400 VALIDATION_PRICE_LOCK_EXPIRED — both collapse to a generic “the lock isn’t usable” signal by design (distinct from the single-use CONFLICT_PRICE_LOCK_ALREADY_USED above, which tells you exactly which order already consumed it), so treat either the same way: re-preview, take a fresh lock, and retry.

totals.chargedWithVat on the cart response and the checkout preview is the goods total plus an order-level shipping fee — the same fee is stamped onto the order at create time, so cart, preview, and the created order can never disagree. Both responses carry the fee’s own breakdown nested under totals.shipping:

{
"totals": {
"chargedWithVat": 602.7, // goods + shipping fee, VAT-inclusive — what checkout would charge
"shipping": {
"amount": 30.0, // net (ex-VAT) fee
"vatAmount": 4.5, // standard-rated VAT on the fee
"amountWithVat": 34.5,
"freeThreshold": 499.0, // SAR — the goods total (incl. VAT, before the fee) that waives the fee
"isFree": false
}
}
}
  • The fee is a flat SAR 30 (net, ex-VAT) today — standard-rate VAT is added on top to get amountWithVat. It’s waived entirely (every fee field reads 0, isFree: true) once the cart’s goods total — including VAT, before any discount, and before the fee itself — reaches the SAR 499 free-shipping threshold. Both values are ops-configurable and may change; read the fields, don’t hard-code them.
  • The delivery zone the fee is computed against is the shipping address’s city (checked against a “within Riyadh” allowlist) — see Integration journey, step 6 for the nationalAddress field the address now requires as the basis for that zone determination.
  • The fee is never refunded by the returns path. A full goods return still reverses every goods/commission leg, but the order’s shipping-fee charge (and its VAT) stands — factor this into any refund-amount expectations your integration builds.
  • A price lock freezes the fee along with the goods price — the order created from that lock is charged exactly the frozen fee, even if the free-shipping threshold or the flat fee amount changes before the lock is redeemed.

Don’t skip the preview call “to save a round trip.” Order-create will still enforce every check the preview reports, so skipping it doesn’t make checkout faster — it only means you find out about a PRICE_NOT_AVAILABLE or INSUFFICIENT_STOCK issue from a failed order-create instead of a cheap read, and you lose the ability to show the customer accurate pricing before you charge them.