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.
1. Build the cart
Section titled “1. Build the cart”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.
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”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 ascartIssue.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 overCART_HAS_PENDING_ORDERwhen 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 as422 BUSINESS_PRODUCT_ACCESS_DISABLEDif 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 with422 BUSINESS_PRICE_NOT_AVAILABLE). Adjust the basket (drop a line, or add another) and re-preview.
3. Take a price lock
Section titled “3. Take a price lock”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:
POST /api/v1/scent/cart/cust-1001/price-lockThis 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.
4. Create the order
Section titled “4. Create the order”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.
Shipping fee
Section titled “Shipping fee”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 reads0,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 thenationalAddressfield 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.
Common mistake to avoid
Section titled “Common mistake to avoid”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.

