Webhooks & HMAC verification
Receive push notifications for order and stock events, and verify their signature.
Instead of polling for order status, subscribe to webhook events and Linra Omni will POST them to your URL as they happen.
Event types
Section titled “Event types”order.completedorder.failedorder.cancelledorder.returnedorder.fulfilment-updatedvariant.back-in-stockorder.return-request.updatedorder.return-request.withdrawnorder.line-unfulfillablecart.abandonedThere 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.
Every order.completed / order.failed / order.cancelled / order.returned event shares ONE
unified, thin shape — a status signal only (orderId, externalReference, status, and
failureReason on a failure): no amount, no currency. GET /api/v1/scent/orders/{orderId} is
always the authoritative source for pricing/lines — a webhook is a notification that something
happened, never a place to read (or reconcile) a money figure from. The one remaining money-shaped
field anywhere in the catalogue, cart.abandoned’s estimatedValue, is likewise a directional
snapshot taken at add-time, never an authoritative figure — never post a partner-facing total, or
trigger a payout, purely off a webhook body.
cart.abandoned
Section titled “cart.abandoned”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 — the tracked default is 45 minutes, and it’s an admin-tunable setting on our side, so treat it as “the configured grace window,” not a fixed constant your integration should hard-code — has passed with the cart still abandoned; a checkout that completes late, inside that window, silently cancels the notification. Budget for a delay on the order of the grace window itself 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": "...", "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.
1. Create a subscription
Section titled “1. Create a subscription”Webhook registration is per product — Scent’s own surface is /api/v1/scent/webhooks. A future
product line gets its own /api/v1/{product}/webhooks rather than sharing one neutral surface;
order-lifecycle events are cross-product spine events under the hood, but you subscribe to them
through the product surface whose orders you’re placing.
POST /api/v1/scent/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).
Optional: custom headers on every delivery
Section titled “Optional: custom headers on every delivery”Create/update also accept an optional headers map — extra name/value pairs added to every
outbound delivery, alongside (never instead of) the X-Linra-Signature/X-Linra-Timestamp
pair below. Use it if your own endpoint wants its own bearer token, API key, or basic-auth header
before it will even look at the HMAC signature:
POST /api/v1/scent/webhooks{ "url": "https://your-service.example.com/webhooks/linra", "eventTypes": ["order.completed"], "headers": { "Authorization": "Bearer your-own-inbound-token" }}headers is write-only — it is never returned by any read (GET/list/create/update response),
the same way the signing secret is never re-shown. At most 10 headers, each name up to 128
characters and each value up to 2048 characters, no CR/LF in either. Content-Type, Host,
Content-Length, and our own X-Linra-Signature/X-Linra-Timestamp header names are reserved and
cannot be overridden. On PUT, headers follows the SAME omitted-vs-provided rule as url/
eventTypes: omit it (or send null) to leave your existing headers untouched; send a map
(including an empty {}) to REPLACE the whole set — an empty map clears every custom header.
2. Verify every delivery’s signature
Section titled “2. Verify every delivery’s signature”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.
Verified code sample (Node.js)
Section titled “Verified code sample (Node.js)”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 exampleapp.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'); },);The event envelope
Section titled “The event envelope”Once verified, the body deserializes to:
{ "eventId": "dbb0371fb409771d3cc1d7770cb5aa58f562f10bc630f420765199366d50683b", "eventType": "order.completed", "occurredAt": "2026-08-02T10:00:00Z", "data": { "orderId": "22222222-2222-2222-2222-222222222222", "externalReference": "po-example-0001", "status": "Completed", "failureReason": null }}eventId is a stable, opaque dedup token — a 64-character lowercase-hex SHA-256 digest, always
the same length and shape regardless of event type. It is a one-way hash of Linra’s own internal
dedup key (which in turn embeds identifiers you never see, such as an internal order-global-id), so
it deliberately carries no readable structure of its own — don’t try to parse it, split it, or
infer anything from its contents. The one thing it’s guaranteed to do is stay identical across
every retry attempt of the same logical event, which is what makes it usable 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). Your own correlation id for “which order is this about” is
orderId inside data, never eventId.
occurredAt is when we dispatched this delivery — the moment the webhook row was staged/sent —
not necessarily the exact instant the underlying domain event happened inside our system. For most
events the gap is negligible, but don’t rely on it as a precise domain-event timestamp; if you need
the order’s own timestamps, read them off the order/return-request resource itself.
orderId is null whenever there’s no downstream Scent order to point at yet (e.g. a pre-create
rejection that never produced one) — the same null-id case you’ll see on GET /api/v1/scent/orders.
order.completed / order.failed / order.cancelled / order.returned — one unified shape
Section titled “order.completed / order.failed / order.cancelled / order.returned — one unified shape”All four order.* lifecycle events share the identical data shape above — orderId,
externalReference, status, and failureReason — never a per-event-type variant. status is
exactly one of Completed / Failed / Cancelled / Returned; failureReason is populated only
when status is Failed (a small, fixed, partner-facing code — never a raw internal error string)
and is null for every other status. Notably, order.returned no longer carries its own
returnReference/returnedAmount fields — those dropped off the webhook entirely; the refund/
clawback detail is available via GET /api/v1/scent/orders/{orderId} or the dedicated
order.return-request.updated event below. externalReference on order.returned is your own
value, resolved the same way as the other three events — even though the underlying return event
itself carries no external reference of its own.
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": "accbba60853bcf9c6739922d08bcb037257ddb7fb2fa0f4da2dc9eb337711067", "eventType": "order.fulfilment-updated", "occurredAt": "2026-08-09T10:00:00Z", "data": { "orderId": "22222222-2222-2222-2222-222222222222", "groupId": "33333333-3333-3333-3333-333333333333", "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
Processing/Cancelled transition before anything has shipped). The carrier’s name is shown, never
Linra’s own internal fulfilment details — we don’t expose which internal facility is behind a
delivery, only who’s carrying it. Prefer GET /api/v1/scent/orders/{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 the
same 6-value partner-facing vocabulary the status field on
GET /api/v1/scent/orders/{orderId}/return-requests uses — never a separate, finer webhook-only
vocabulary:
newStatus |
Meaning |
|---|---|
Requested |
Filed, awaiting internal review. Moves no money. |
Accepted |
Staff authorized the return process — inspection has NOT happened yet. |
Processing |
Somewhere between authorization and a refund decision — waybill issued, in transit, received, under inspection, or inspection accepted but not yet refunded. Moves no money. |
Rejected |
Staff declined the request, or inspection found no defect. Terminal. Moves no money. |
Refunded |
This is the transition that means the refund (and any commission clawback) has actually posted. Terminal. |
Withdrawn |
You withdrew the request yourself, while it was still Requested. Terminal. Moves no money. See the dedicated order.return-request.withdrawn event below — it fires alongside this one, specifically for this transition. |
Internally the request travels a finer lifecycle (WaybillIssued → InTransit → Received →
UnderInspection → InspectionAccepted/InspectionRejected) — you’ll get a separate delivery of
this event for each of those internal transitions, but every one of them carries newStatus: "Processing" (or, on rejection, "Rejected") on the wire. If you want a richer timeline than six
values, poll GET /api/v1/scent/orders/{orderId}/return-requests for the full timeline{}/
logistics{}/inspection{} detail instead of trying to infer it from repeated Processing
deliveries.
{ "eventId": "ab12e91813a1cbc8da6687138542a7aeb49c140007b7c89701067398a9b0a54a", "eventType": "order.return-request.updated", "occurredAt": "2026-08-09T10:00:00Z", "data": { "returnRequestId": "33333333-3333-3333-3333-333333333333", "orderId": "22222222-2222-2222-2222-222222222222", "newStatus": "Refunded" }}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.
eventId itself is opaque and tells you nothing about which internal transition produced it, but
the underlying dedup key it’s hashed from is scoped to the internal transition name (e.g.
RefundReleased), not the coarsened newStatus you see in data — that’s intentional: each
distinct internal transition gets its own stable eventId, even though several of them collapse to
the same newStatus value on the wire. In practice this means you’ll see a different eventId
for each of the internal transitions listed above (WaybillIssued, InTransit, Received, …)
even while several of them deliver newStatus: "Processing" back to back.
order.return-request.withdrawn — you withdrew your own request
Section titled “order.return-request.withdrawn — you withdrew your own request”You may withdraw a return request while it is still Requested — before we’ve taken any action
on it (authorized, declined, or anything further downstream). The moment any of that happens,
withdrawal is locked out permanently and the normal lifecycle above applies. Withdrawing moves no
money (nothing before Refunded ever does) and restores the line to full returnable eligibility —
you can file a fresh return request for it immediately.
curl -X PATCH https://api-omni.linra.net/api/v1/scent/return-requests/return-request-guid/withdraw \ -H "Authorization: Bearer $TOKEN"This event fires alongside order.return-request.updated (with newStatus: "Withdrawn") for the
SAME transition — it’s a dedicated, independently-subscribable signal specifically for a withdrawal
you initiated, distinct from every other transition (which are all staff- or system-driven). Subscribe
to whichever shape suits your integration; you don’t need both.
{ "eventId": "a77541c4ccda78278f427e1848f592eb173d5d8aa08a76442c4df07ca946ab1d", "eventType": "order.return-request.withdrawn", "occurredAt": "2026-08-13T10:00:00Z", "data": { "returnRequestId": "33333333-3333-3333-3333-333333333333", "orderId": "22222222-2222-2222-2222-222222222222", "withdrawnAt": "2026-08-13T10:00:00Z" }}Withdrawing a request that already left Pending (any staff action already took place) fails with
422 BUSINESS_RETURN_REQUEST_NOT_PENDING — no event fires, since nothing changed.
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": "c07a54ff7a10abdef26dba89ae50d2d8c9bfaf8dd108efd29b9557f5f5a78a30", "eventType": "order.line-unfulfillable", "occurredAt": "2026-08-11T10:00:00Z", "data": { "noticeId": "44444444-4444-4444-4444-444444444444", "orderId": "22222222-2222-2222-2222-222222222222", "orderLineId": "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):
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/scent/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”POST /api/v1/scent/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.
Managing subscriptions
Section titled “Managing subscriptions”PUT /api/v1/scent/webhooks/{id}— change the URL and/or subscribed event types.PATCH /api/v1/scent/webhooks/{id}/active— pause/resume a subscription without deleting it.POST /api/v1/scent/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/scent/webhooks/{id}/deliveries— inspect recent delivery attempts (retained 30 days).
There is deliberately no DELETE endpoint — subscriptions are deactivate-only. To stop a
subscription you no longer want, PATCH /api/v1/scent/webhooks/{id}/active with isActive: false; it
immediately stops receiving deliveries and no longer counts toward your active-subscription cap,
but the row (and its delivery history) stays retrievable via GET/PUT for later reactivation.
Full request/response shapes are in the API Reference under Webhooks.

