Skip to content
Linra Omni Vendor Docs

Offers, pricing and stock

Submitting and editing offers under either commercial model, the field-ownership policy, and the narrow push doors.

Before submitting an offer, check GET /api/v1/scent/agreement (capability-free — any authenticated credential may call it). It returns your own pricing model, and nothing else:

{
"pricingModel": "PercentageOfSale",
"supplierPercentage": 20.0,
"effectiveFrom": "2026-01-01T00:00:00Z"
}

There are two models, and you are on exactly one of them at a time — Linra assigns it, you cannot change it yourself:

  • CostPlusMargin (“Model 1”) — you declare your wholesale cost; Linra adds its own markup on top to set the partner-facing sale price. supplierPercentage is null.
  • PercentageOfSale (“Model 2”) — you declare the FINAL salePrice yourself, already including Linra’s + the reselling partner’s full commission share. Linra + the partner deduct supplierPercentage of it; you keep the rest. Worked example: you set a sale price of 1000 SAR at a 20% supplier percentage — Linra + the partner’s combined share is 200 SAR, and you net 800 SAR.

Which of cost/salePrice an offer write accepts follows your CURRENT model, resolved server-side — never a field you choose. Sending the wrong one for your model returns 422 BUSINESS_OFFER_PRICE_FIELD_NOT_FOR_AGREEMENT:

Your model Required fields Forbidden fields
CostPlusMargin (Model 1) cost + costIsVatInclusive salePrice, salePriceIsVatInclusive
PercentageOfSale (Model 2) salePrice + salePriceIsVatInclusive cost, costIsVatInclusive

POST /api/v1/scent/offers (requires Offers) accepts two identity shapes, independent of which pricing model you’re on:

  • A proposed match — set variantId to an existing, active catalog variant you’re confirming you can supply.
  • A free-form new entry — omit variantId and supply rawBrandName/rawProductName (and optionally rawConcentration/rawSizeMl/rawDescription) as you know the product. An internal reviewer will match it to an existing catalog entry or create a new one.

Either way, every offer must carry the product’s barcode in barcode: its EAN/UPC (GTIN) — 8, 12, 13 or 14 digits with a correct check digit, e.g. 4006381333931. A missing or invalid barcode is a 400 VALIDATION_ERRORS on field Barcode. On an edit (PATCH /api/v1/scent/offers/{id}) you may omit it — the stored barcode is kept, never cleared — and a value you send must be valid. An offer you created before this rule keeps working: its stock, cost and sale price can change without a barcode, and you add the barcode with a body as small as {"barcode": "4006381333931"} (the offer keeps its status and every other field). It needs one before any product-detail change (vendorSku, vendorExternalId, raw*), and before any edit that sends a Rejected or PendingRepricing offer back to review (every edit of a Rejected offer; a price change on a PendingRepricing one; this applies to the stock, cost and sale-price pushes too). In an offer import file, a row that creates a new offer needs the Barcode column; a row that updates one of your existing offers may leave it blank or leave the column out (the stored barcode is kept).

Editing an offer — send only what changes

Section titled “Editing an offer — send only what changes”

PATCH /api/v1/scent/offers/{id} is a partial update. A field you omit (or send as null) keeps its stored value; an empty string ("") clears an optional text field (vendorSku, vendorExternalId, rawConcentration, rawDescription; also rawBrandName/rawProductName once the offer is matched to a catalog variant). A whitespace-only string counts as empty. With no price field in the body, the stored price is kept. A price you do send goes with its entry-mode flag — {"cost": 120, "costIsVatInclusive": false} under Model 1, {"salePrice": 300, "salePriceIsVatInclusive": true} under Model 2. To change stock, use PATCH /api/v1/scent/offers/{offerId}/stock.

Either way, the offer lands in the SAME review queue Linra’s own catalog team works from — nothing you submit is live until a reviewer approves it.

Example — Model-2 vendor, VAT-inclusive sale price

Section titled “Example — Model-2 vendor, VAT-inclusive sale price”
POST /api/v1/scent/offers
{
"variantId": "…",
"salePrice": 1150.00,
"salePriceIsVatInclusive": true,
"declaredInStock": true
}

Stored as net 1000.00 + VAT 150.00 (15% Saudi VAT), mirroring exactly how cost is back-derived under Model 1 — see VAT declaration below.

Set vendorExternalId to your own opaque identifier for this offer (e.g. your internal SKU-to-us mapping key). Re-submitting the exact same vendorExternalId returns the ALREADY-CREATED offer unchanged, rather than creating a duplicate or erroring — safe to retry blindly after a timeout or an unknown outcome. Omit vendorExternalId and every POST creates a brand-new offer, even with identical content.

saveAsDraft: true on create saves the offer with no review-queue entry yet — a “save for later.” Everything else submits for review immediately (matched → PendingReview, unmatched → PendingMatch).

Linra assigns your commercial model (see Your commercial agreement above) and can switch it — for example, moving you from CostPlusMargin to PercentageOfSale. When that happens, any of your offers that were Approved or PendingReview at the time move to a new status, PendingRepricing, and stop being sellable until you act:

  • What triggers it: an admin-side change to your agreement’s pricingModel (not a percentage adjustment within PercentageOfSale — that applies immediately, with no effect on your existing offers or their status).
  • What happens to the offer: its price stays exactly as it was under the OLD model (nothing is deleted), but it is no longer purchasable — the linked supply option is paused. Any price change you had pending review is cleared (you’ll need to resubmit under the new model).
  • How to recover: push a price under your NEW model’s field — salePrice + salePriceIsVatInclusive if you just moved to PercentageOfSale, or cost + costIsVatInclusive if you moved to CostPlusMargin — either via PATCH /api/v1/scent/offers/{id}/sale-price / PATCH /api/v1/scent/offers/{id}/cost (see Cost/sale-price-only and stock-only pushes below) or the general update endpoint. This moves the offer to PendingReview; once a Linra reviewer approves it, the SAME supply option reactivates — no new listing, no lost sales history.
  • Stock pushes are unaffected: PATCH /api/v1/scent/offers/{offerId}/stock keeps working exactly as it always has while an offer sits in PendingRepricing — you can keep your declared quantity accurate even before you’ve re-priced.
  • Check GET /api/v1/scent/agreement any time you’re unsure which model you’re currently on — always the source of truth, never assume it hasn’t changed since your last read.

Not every field on an offer behaves the same way when you edit it — this is deliberate, not an inconsistency. On the request (PATCH /offers/{id} or the narrower push doors below) these fields are flat; on the response, they’re grouped into product/cost/price/stock/review — see Reading price back below:

Field(s) Behavior
declaredInStock, declaredStockQuantity Apply instantly, regardless of the offer’s status. Availability is a real-world fact, not a commercial term subject to review. Response: stock.declaredInStock/stock.declaredQuantity.
cost, costIsVatInclusive (Model 1 only) On a not-yet-Approved offer: applies directly (nothing is live yet regardless). On an Approved offer: becomes a pending change — the response’s cost.pending object populates, the live cost.amount is untouched until a reviewer approves or rejects the change.
salePrice, salePriceIsVatInclusive (Model 2 only) The exact same pending-change shape as cost above, mirrored onto price.pending/price.amount.
barcode Required on create; on an edit, omitting it keeps the stored value. An older offer with none needs one only before a product-detail change. On an Approved offer, CHANGING it resets the offer to PendingReview; filling it in for the first time on an older offer that had none does not. Response: product.barcode.
vendorSku, vendorExternalId, rawBrandName, rawProductName, rawConcentration, rawSizeMl, rawDescription Omitted = kept. On an Approved offer, changing any of these resets the offer to PendingReview — an identity-fact change on a live offer needs re-review. Response: grouped under product (vendorExternalId stays flat at the root — it’s your own idempotency key, not a categorized fact).
variantId Never editable by you. Re-matching an offer to a different catalog variant is exclusively an internal reviewer action. Response: product.variantId.

Cost/sale-price-only and stock-only pushes

Section titled “Cost/sale-price-only and stock-only pushes”

If your integration only needs to keep price or stock current — not manage full offer submissions — use the narrower, single-purpose doors instead of the general update endpoint. Each requires only its own capability, independent of Offers:

  • PATCH /api/v1/scent/offers/{id}/cost (requires Cost, Model 1 only) — carries only cost and costIsVatInclusive. Every other field is left exactly as it was; this endpoint can never touch identity or stock, even by accident.
  • PATCH /api/v1/scent/offers/{id}/sale-price (requires Cost, Model 2 only — the SAME capability as the cost door; pricing is one capability regardless of which model you’re on) — carries only salePrice and salePriceIsVatInclusive. Sending this while you’re on Model 1 returns 422 BUSINESS_OFFER_PRICE_FIELD_NOT_FOR_AGREEMENT.
  • PATCH /api/v1/scent/offers/{offerId}/stock (requires Stock) — carries only declaredStockQuantity and declaredInStock. declaredInStock is required (a body without it returns 400 VALIDATION_ERRORS and changes nothing), and it must agree with a supplied quantity: false with a quantity above 0, or true with a quantity of 0, is rejected. Reachable for ANY of your offers regardless of how they were originally submitted (through this API, or entered manually by a Linra catalog admin on your behalf) — stock is a fact about your SKU, not about which door the offer’s identity entered through, and it’s the SAME door under either pricing model. Declared stock is a sub-resource of the offer, not a sibling top-level resource.

All are idempotent: re-pushing the identical cost/sale price overwrites the same pending value (never a duplicate pending artifact); re-pushing the identical stock state is a pure no-op — you can push stock as often as you like without ever disturbing a pending price change awaiting review.

costIsVatInclusive (Model 1) / salePriceIsVatInclusive (Model 2), on a write (create, update, or either narrow push door), controls how cost/salePrice is interpreted — identically for both fields:

  • false — exclusive: the amount is net; VAT is calculated on top.
  • true — inclusive: the amount is the VAT-inclusive gross figure; the net amount and the input VAT are back-derived from it and returned as cost.inputVat/cost.pending.inputVat (Model
    1. or price.vat/price.pending.vat (Model 2) on the response.

This takes effect immediately regardless of the offer’s Approved/pending gating described above — it’s a declaration about how to interpret the number you sent, not a commercial change to be reviewed on its own.

Important: only use …IsVatInclusive: true when the amount is a genuinely NEW gross figure you are declaring right now. Internally, both cost and sale price are always stored net — there is no way to recover the original gross number you may have entered previously. Resubmitting a number you read back from this API (see below) under …IsVatInclusive: true would incorrectly treat that already-net figure as a fresh gross one and strip VAT off it a second time.

Reading price back — the response’s safe round-trip contract

Section titled “Reading price back — the response’s safe round-trip contract”

The response carries cost (Model 1) OR price (Model 2) — never both; the other is null. Both groups share an identical round-trip contract. amount is always the live, stored NET figure; isVatInclusive on the RESPONSE is always false, regardless of how you originally entered the offer — it is not an echo of what you declared. This is deliberate: false (exclusive) is the one entry mode that is mathematically guaranteed to round-trip as a pure identity — resubmitting the exact amount + …IsVatInclusive: false pair you just read, as part of an unrelated edit (e.g. changing vendorSku), is always safe and never changes the price, no matter how the offer was originally entered.

To see how you actually entered the figure (net/exclusive vs. gross/inclusive), read enteredVatInclusive instead — a separate, display-only field. It is never accepted on any write; it exists purely so your integration (or a human) can tell how the number was originally declared.

// GET response for a Model-1 offer originally entered as cost=115, costIsVatInclusive=true (15% VAT)
{
"pricingModel": "CostPlusMargin",
"cost": {
"amount": 100.00,
"isVatInclusive": false,
"enteredVatInclusive": true,
"inputVat": 15.00,
"pending": null
},
"price": null
}
// GET response for a Model-2 offer originally entered as salePrice=1150, salePriceIsVatInclusive=true (15% VAT)
{
"pricingModel": "PercentageOfSale",
"cost": null,
"price": {
"amount": 1000.00,
"isVatInclusive": false,
"enteredVatInclusive": true,
"vat": 150.00,
"pending": null
}
}

A stock-only push never touches price, regardless of entry mode or model. PATCH /api/v1/scent/offers/{offerId}/stock only ever carries declaredStockQuantity/declaredInStock — cost/sale price is completely untouched by a stock push, no matter how the offer was originally entered or how many times you push stock in a row.

Every offer/cost/sale-price/stock/agreement endpoint reaches ONLY an offer YOU submitted (or, for stock, any offer associated with your vendor id regardless of submission channel) — and GET /agreement returns ONLY your own agreement, never a partner’s rate or another vendor’s data. An offer belonging to another vendor — or one a Linra catalog admin entered manually — is reported as 404 NOT_FOUND_VENDOR_OFFER, indistinguishable from a genuinely nonexistent id. There is no cross-vendor visibility anywhere on this API.