Offers, pricing and stock
Submitting and editing offers under either commercial model, the field-ownership policy, and the narrow push doors.
Your commercial agreement
Section titled “Your commercial agreement”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.supplierPercentageisnull. - PercentageOfSale (“Model 2”) — you declare the FINAL
salePriceyourself, already including Linra’s + the reselling partner’s full commission share. Linra + the partner deductsupplierPercentageof 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 |
Submitting an offer
Section titled “Submitting an offer”POST /api/v1/scent/offers (requires Offers) accepts two identity shapes, independent of which
pricing model you’re on:
- A proposed match — set
variantIdto an existing, active catalog variant you’re confirming you can supply. - A free-form new entry — omit
variantIdand supplyrawBrandName/rawProductName(and optionallyrawConcentration/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.
Idempotent submission
Section titled “Idempotent submission”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.
Draft vs. submitted
Section titled “Draft vs. submitted”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).
Repricing after an agreement change
Section titled “Repricing after an agreement change”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 withinPercentageOfSale— 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+salePriceIsVatInclusiveif you just moved toPercentageOfSale, orcost+costIsVatInclusiveif you moved toCostPlusMargin— either viaPATCH /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 toPendingReview; 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}/stockkeeps working exactly as it always has while an offer sits inPendingRepricing— you can keep your declared quantity accurate even before you’ve re-priced. - Check
GET /api/v1/scent/agreementany 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.
The field-ownership policy
Section titled “The field-ownership policy”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(requiresCost, Model 1 only) — carries onlycostandcostIsVatInclusive. 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(requiresCost, Model 2 only — the SAME capability as the cost door; pricing is one capability regardless of which model you’re on) — carries onlysalePriceandsalePriceIsVatInclusive. Sending this while you’re on Model 1 returns422 BUSINESS_OFFER_PRICE_FIELD_NOT_FOR_AGREEMENT.PATCH /api/v1/scent/offers/{offerId}/stock(requiresStock) — carries onlydeclaredStockQuantityanddeclaredInStock.declaredInStockis required (a body without it returns400 VALIDATION_ERRORSand changes nothing), and it must agree with a supplied quantity:falsewith a quantity above 0, ortruewith 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.
VAT declaration
Section titled “VAT declaration”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 ascost.inputVat/cost.pending.inputVat(Model- or
price.vat/price.pending.vat(Model 2) on the response.
- or
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.
Scoping
Section titled “Scoping”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.

