Skip to content

Webhooks & HMAC verification

Instead of polling for order status, subscribe to webhook events and Linra Omni will POST them to your URL as they happen.

order.completed
order.failed
order.cancelled
order.returned
order.fulfilment-updated
variant.back-in-stock
order.return-request.updated
order.line-unfulfillable
cart.abandoned

There is deliberately no order.processing event — an order transitioning into an in-progress state is folded into order.completed rather than getting its own event.

Fires when a cart on your Linra account was created and never checked out. This is not immediate: it fires only after a grace window (currently 45 minutes, tunable on our side) has passed with the cart still abandoned — a checkout that completes late, inside that window, silently cancels the notification. Budget for a delay of up to roughly an hour between the actual abandonment and the webhook, not real-time. It fires at most once per cart, and never for a cart we ourselves blocked (e.g. during an account suspension) — only for a genuinely idle one.

{
"cartId": "...",
"partnerId": "11111111-1111-1111-1111-111111111111",
"customerRef": "your-own-opaque-reference-or-null",
"estimatedValue": 249.0
}

customerRef is exactly the opaque reference you supplied when the cart was created — we hold no other end-customer data (no email, no phone, no name), so your own system is the only place that can turn this signal into an outreach to that customer. estimatedValue is an approximate, add-time snapshot reconstructed from the cart’s line history — never a live-priced, checkout-accurate total; treat it as directional, not a quotable price.

Terminal window
POST /api/v1/webhooks
{
"url": "https://your-service.example.com/webhooks/linra",
"eventTypes": ["order.completed", "order.failed", "order.returned"]
}
{
"state": "CREATED",
"payload": {
"subscription": { "id": "...", "url": "...", "eventTypes": ["..."], "isActive": true },
"secret": "whsec_5f1c9e6a2b8d4f0a9c7e3b1d6a8f2c4e"
}
}

The secret is shown exactly once, in this response. Store it immediately — there is no way to retrieve it again later (you can rotate to get a new one, but never recover the old one). Up to 5 active subscriptions per partner are allowed.

Your url must be a public HTTPS endpoint — private, loopback, and link-local addresses are rejected both at creation and re-checked on every single delivery attempt (so an endpoint that later starts resolving to a private address stops receiving deliveries rather than silently succeeding against something unintended).

Every delivery carries two headers:

Header Value
X-Linra-Signature sha256=<hex-encoded HMAC-SHA256>
X-Linra-Timestamp Unix seconds (as a decimal string)

The signature is computed as:

signature = hex( HMAC-SHA256( secret, "{unixTimestamp}.{rawRequestBody}" ) )

— the same GitHub/Stripe-style convention: the timestamp is concatenated with a literal . and the exact raw request body bytes (not a re-serialized/re-formatted version of the JSON — use the body exactly as received, before any JSON parsing), then HMAC-SHA256’d with your webhook secret, then hex-encoded (lowercase).

Binding the timestamp into the signed material (rather than sending it as an unrelated sibling header) is what makes a replay-tolerance window meaningful on your side: a captured, genuine signature cannot be replayed later against a different timestamp, because the timestamp is part of what was signed. We recommend rejecting any delivery whose X-Linra-Timestamp is more than 5 minutes old or in the future — this bounds how long a captured request could be replayed even if somehow re-delivered with its original headers intact.

The following sample was checked against a real signature produced by the production signing code (HmacSigner.ComputeSignatureHeader) — not a hand-derived approximation — and correctly accepts the genuine signature while rejecting a tampered body:

import crypto from 'node:crypto';
function verifyLinraWebhookSignature(secret, timestampHeader, rawBody, signatureHeader) {
// Reject stale/future timestamps first — see the replay-tolerance note above.
const nowSeconds = Math.floor(Date.now() / 1000);
const timestamp = Number(timestampHeader);
if (!Number.isFinite(timestamp) || Math.abs(nowSeconds - timestamp) > 300) {
return false;
}
const signedPayload = `${timestampHeader}.${rawBody}`;
const expectedHex = crypto.createHmac('sha256', secret).update(signedPayload, 'utf8').digest('hex');
const expected = `sha256=${expectedHex}`;
const expectedBuf = Buffer.from(expected, 'utf8');
const actualBuf = Buffer.from(signatureHeader, 'utf8');
// Constant-time comparison — never use `===` or `expected === actual` for a secret comparison.
if (expectedBuf.length !== actualBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, actualBuf);
}
// Express example
app.post(
'/webhooks/linra',
express.raw({ type: 'application/json' }), // IMPORTANT: get the raw, unparsed body
(req, res) => {
const signature = req.header('X-Linra-Signature');
const timestamp = req.header('X-Linra-Timestamp');
const rawBody = req.body.toString('utf8');
if (!verifyLinraWebhookSignature(WEBHOOK_SECRET, timestamp, rawBody, signature)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(rawBody);
// ... handle event.eventType / event.data, then respond quickly (see below) ...
res.status(200).send('ok');
},
);

Once verified, the body deserializes to:

{
"eventId": "evt_01HXAMPLE000000000000001",
"eventType": "order.completed",
"occurredAt": "2026-08-02T10:00:00Z",
"data": {
"orderGlobalId": "ORD-EXAMPLE-0001",
"partnerId": "11111111-1111-1111-1111-111111111111",
"productType": "Scent",
"externalReference": "po-example-0001",
"amount": 249.0,
"currency": "SAR",
"failureCode": null
}
}

eventId is stable across every retry attempt of the same logical event — use it as your deduplication key. Delivery is at-least-once: your handler must be idempotent on eventId, because the same event may arrive more than once (most commonly when your endpoint accepted it but the response was lost in transit).

order.fulfilment-updated — the play-by-play tier

Section titled “order.fulfilment-updated — the play-by-play tier”

Unlike the tier-1 events above (fired once, on a terminal transition), order.fulfilment-updated fires on every fulfilment status change for a Scent order, terminal or not — including the exception states (FailedDelivery, RefusedReceipt, ReturnedToSender):

{
"eventId": "evt_01HXAMPLE000000000000002",
"eventType": "order.fulfilment-updated",
"occurredAt": "2026-08-09T10:00:00Z",
"data": {
"orderGlobalId": "ORD-EXAMPLE-0001",
"partnerId": "11111111-1111-1111-1111-111111111111",
"groupId": "22222222-2222-2222-2222-222222222222",
"newStatus": "Shipped",
"trackingRef": "TRK-1234567890",
"carrierDisplayName": "Aramex"
}
}

trackingRef/carrierDisplayName reflect the underlying shipment’s carrier/tracking number at the moment of this transition — both are null for a signal with no owning shipment yet (e.g. an early PendingSourcing/Cancelled transition before anything has shipped). The carrier’s name is shown, never the shipping supplier’s identity — we don’t expose which vendor/warehouse is behind a delivery, only who’s carrying it. Prefer GET /api/v1/orders/scent/{orderId}/tracking (see the order tracking guide step) over reconstructing delivery state purely from a stream of these events — it’s the authoritative, current per-shipment picture.

order.return-request.updated — the RMA lifecycle

Section titled “order.return-request.updated — the RMA lifecycle”

Fires on every status transition of a partner self-serve return request. data.newStatus is one of ten values (Scent’s own ReturnRequestStatus enum, carried as a string so the wire contract survives independent of either side’s numbering):

newStatus Meaning
Pending Filed, awaiting internal review. Moves no money.
Approved Staff authorized the return process — inspection has NOT happened yet.
Declined Staff declined the request with a reason. Terminal. Moves no money.
WaybillIssued Linra issued the return waybill (and optionally scheduled collection).
InTransit The return shipment is on its way back to Linra (or, under model A, the supplier).
Received The returned item(s) arrived at their destination — a Linra sorting point, or, under model A, the supplier directly.
UnderInspection Inspection has started.
InspectionAccepted The item passed inspection — this is the transition that actually triggers the refund.
InspectionRejected Inspection rejected the return (e.g. opened/used, no defect found). Terminal. Moves no money.
RefundReleased The refund (and any commission clawback) has been posted. Terminal.
{
"eventId": "evt_01HXAMPLE000000000000003",
"eventType": "order.return-request.updated",
"occurredAt": "2026-08-09T10:00:00Z",
"data": {
"returnRequestId": "33333333-3333-3333-3333-333333333333",
"orderGlobalId": "ORD-EXAMPLE-0001",
"partnerId": "11111111-1111-1111-1111-111111111111",
"newStatus": "InspectionAccepted"
}
}

The payload is deliberately minimal — request identity + new status only, no line/quantity/money detail (you already have that from your own create call). Call GET /api/v1/scent/orders/{orderId}/return-requests whenever you need the full current state, including the inspection summary.

order.line-unfulfillable — a 72-hour partner decision is needed

Section titled “order.line-unfulfillable — a 72-hour partner decision is needed”

Fires when a line becomes unfulfillable AFTER you’ve already been charged for it — a receiving-time shortage/damage that couldn’t be replaced in time, or a lost shipment — and again when the notice resolves. data.newStatus distinguishes which moment this is: AwaitingPartnerResponse (just raised — the 72-hour clock is running), Resolved (you responded within the window), or one of two system-applied defaults when nobody responds in time — ExpiredCancelledWhole (nothing had shipped yet, so the whole line is cancelled and refunded) or ExpiredShipAvailable (the shipment had already departed, so what’s available ships and only the shortfall is cancelled).

{
"eventId": "evt_01HXAMPLE000000000000004",
"eventType": "order.line-unfulfillable",
"occurredAt": "2026-08-11T10:00:00Z",
"data": {
"noticeId": "44444444-4444-4444-4444-444444444444",
"orderGlobalId": "ORD-EXAMPLE-0001",
"partnerId": "11111111-1111-1111-1111-111111111111",
"scentOrderLineId": "55555555-5555-5555-5555-555555555555",
"reason": "ReceivingShortfall",
"newStatus": "AwaitingPartnerResponse",
"responseDeadlineAt": "2026-08-14T10:00:00Z"
}
}

When you receive an AwaitingPartnerResponse notice, respond within the window using the notice’s own noticeId (there is no list/read endpoint for this on the partner plane — the webhook is how you learn the id):

Terminal window
PATCH /api/v1/scent/unfulfillable-notices/{noticeId}/respond
{ "choice": "CancelWhole" }

choice is one of CancelWhole (full refund of the line) or ShipAvailable (ship what’s available, cancel only the shortfall). There is no way to respond after the deadline — the system applies the documented default for you (see the newStatus table above), reflected in the next delivery of this same event.

3. Respond quickly, and understand retry/backoff

Section titled “3. Respond quickly, and understand retry/backoff”

Return a 2xx status as soon as you’ve durably accepted the event (e.g. written it to your own queue) — don’t do slow synchronous processing before responding. A failed or timed-out delivery is retried with exponential backoff: 30s, 1m, 2m, 4m, …, capped at 1 hour between attempts, up to 8 attempts total before the delivery is marked dead-lettered and no further retries occur for that specific event.

The response body is never inspected — only the HTTP status code matters. A partner-visible errorSummary on the delivery record (visible via GET /api/v1/webhooks/{id}/deliveries) is always one of a fixed, generic vocabulary — "Timeout", "Connection failed", "Destination rejected", "Delivery failed" — never a raw exception message.

4. Test your integration before going live

Section titled “4. Test your integration before going live”
Terminal window
POST /api/v1/webhooks/{id}/test-fire
{ "eventType": "order.completed" }

This exercises the exact same signing and delivery code path as a real event — the best way to confirm your signature verification is correct before you’re relying on it in production.

  • PUT /api/v1/webhooks/{id} — change the URL and/or subscribed event types.
  • PATCH /api/v1/webhooks/{id}/active — pause/resume a subscription without deleting it.
  • POST /api/v1/webhooks/{id}/rotate-secret — get a new secret; the old one stops verifying immediately (there’s no overlap window — coordinate the swap on your side around this call).
  • GET /api/v1/webhooks/{id}/deliveries — inspect recent delivery attempts (retained 30 days).

Full request/response shapes are in the API Reference under Webhooks.