Webhooks & HMAC verification
Subscribing to vendor events, the delivery envelope, and a verified signature-verification code sample.
Event types
Section titled “Event types”| Event type | Fired when | Payload |
|---|---|---|
vendor.scent.order.assigned |
A fulfilment group is newly assigned to you. | { groupId } |
vendor.scent.offer.review-decision |
One of your offers is approved, rejected, or a pending cost change is approved/rejected. | { offerId, decision, reason } — decision is one of Approved/Rejected/CostApproved/CostRejected; reason is present only for the two rejection outcomes. |
Both event types carry the product segment (scent.) after the vendor. plane prefix, matching
the platform’s product-scoped naming convention — a future product’s webhooks would use their own
segment here. Both payloads are deliberately minimal — the same data-minimization posture the
partner-facing webhook platform uses: neither carries the reselling partner’s identity, and neither
repeats your own vendorId (a delivery is POSTed to your own subscription endpoint, so you already
know which vendor it is). Fetch full details (shipping address, lines, current offer state) via the
existing poll endpoints (GET /api/v1/scent/orders/{groupId}, GET /api/v1/scent/offers/{id})
rather than expecting them in the notification itself.
Each event type has its own capability owner (see the
Vendor Integration Standard §3):
subscribing to vendor.scent.order.assigned requires Orders; subscribing to
vendor.scent.offer.review-decision requires Offers. Checked per event type in the request, not
once for the whole subscription — an Offers-only credential CAN subscribe to
vendor.scent.offer.review-decision (just not to vendor.scent.order.assigned, which it would
still need to poll for). Every OTHER webhook action (list/get/update-the-URL-only/deactivate/
rotate-secret/delete/test-fire/list deliveries) requires no capability at all — ownership of the
subscription is the only gate.
Creating a subscription
Section titled “Creating a subscription”curl -X POST https://api-omni-vendors-stg.linra.net/api/v1/scent/webhooks \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-integration.example.com/webhooks/linra", "eventTypes": ["vendor.scent.order.assigned", "vendor.scent.offer.review-decision"] }'{ "state": "CREATED", "payload": { "subscription": { "id": "...", "url": "...", "eventTypes": [...], "isActive": true, "...": "..." }, "secret": "whsec_5f8a2e1c9b3d4a6f8e0c2b1a7d9e3f4c" }}The signing secret is shown in this response ONLY — store it immediately, there is no
“reveal secret” endpoint. subscription carries no vendorId — redundant on a self-only surface,
you already know which vendor it is. Up to 5 active subscriptions per
vendor (BUSINESS_VENDOR_WEBHOOK_SUBSCRIPTION_LIMIT beyond that); rotate a compromised secret with
POST /api/v1/scent/webhooks/{id}/rotate-secret rather than deleting and recreating the
subscription.
SSRF guardrail
Section titled “SSRF guardrail”Your subscription URL is validated at creation AND on every update to reject a target that resolves to a private, loopback, or link-local address — this protects Linra’s own infrastructure from being used to probe your (or a third party’s) internal network via a webhook URL. Point your subscription at a real, publicly-reachable HTTPS endpoint.
The delivery envelope
Section titled “The delivery envelope”Every delivery is a POST to your subscribed URL with this JSON body:
{ "eventId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "eventType": "vendor.scent.order.assigned", "occurredAt": "2026-08-02T10:32:25Z", "data": { "groupId": "7c9e6679-7425-40de-944b-e07fc1f90ae7" }}eventId is stable across every retry attempt of the SAME logical event — use it as your dedupe
key (it’s also returned as idempotencyKey on the matching row from
GET /api/v1/scent/webhooks/{id}/deliveries).
Verifying the signature
Section titled “Verifying the signature”Two headers accompany every delivery:
| Header | Value |
|---|---|
X-Linra-Timestamp |
Unix timestamp (seconds) when the request was signed. |
X-Linra-Signature |
sha256=<hex>, where hex = HMAC-SHA256(secret, "{timestamp}.{rawBody}"), using your subscription’s own secret. |
Compute the same HMAC over the raw, unparsed request body concatenated with the timestamp
("{timestamp}.{rawBody}", a literal dot separator) and compare in constant time. Also check that
the timestamp is recent — reject anything more than 5 minutes old to bound replay exposure.
Verified code sample (Node.js)
Section titled “Verified code sample (Node.js)”The algorithm below (HMAC-SHA256(secret, "{timestamp}.{rawBody}"), hex-encoded lowercase) was
originally checked against a real signature produced by the production signing code
(Linra.Omni.Products.Scent.Infrastructure.Webhooks.HmacSigner, invoked directly — not a
hand-derived approximation); HmacSigner’s own source is reproduced verbatim in this repo
(ComputeHex: HMACSHA256.HashData(UTF8(secret), UTF8("{timestamp}.{rawBody}")),
Convert.ToHexStringLower) and matches this sample line for line. The digest below was
recomputed with that SAME algorithm for the current envelope shape:
const crypto = require('crypto');
function verifyLinraSignature(secret, timestampHeader, rawBody, signatureHeader) { const signedPayload = `${timestampHeader}.${rawBody}`; const expectedHex = crypto.createHmac('sha256', secret).update(signedPayload, 'utf8').digest('hex'); const expected = `sha256=${expectedHex}`;
// Constant-time comparison — never a plain `===` on secret-derived values. const expectedBuf = Buffer.from(expected, 'utf8'); const actualBuf = Buffer.from(signatureHeader, 'utf8'); if (expectedBuf.length !== actualBuf.length) return false; return crypto.timingSafeEqual(expectedBuf, actualBuf);}
// --- Computed with HmacSigner.ComputeSignatureHeader's exact algorithm, these exact inputs ---const secret = 'whsec_5f8a2e1c9b3d4a6f8e0c2b1a7d9e3f4c';const timestamp = '1732012345';const rawBody = '{"eventId":"3fa85f64-5717-4562-b3fc-2c963f66afa6","eventType":"vendor.scent.order.assigned",' + '"occurredAt":"2026-08-02T10:32:25Z","data":{"groupId":"7c9e6679-7425-40de-944b-e07fc1f90ae7"}}';const signature = 'sha256=601bdef215728959b7a337ae0611f7123d0c7b326ea8985049e9093bc8148b1a';
console.log(verifyLinraSignature(secret, timestamp, rawBody, signature)); // -> trueA common bug: if your framework parses the request body into an object before your webhook handler runs, make sure you verify against the exact ORIGINAL bytes, not a re-serialized version of the parsed object — re-serialization can reorder keys or change whitespace and silently break every verification.
Retries and dead-lettering
Section titled “Retries and dead-lettering”A failed delivery (non-2xx response, or a timeout) is retried with exponential backoff — 30
seconds, 1 minute, 2 minutes, 4 minutes, and so on, capped at 1 hour between attempts — up to a
bounded maximum attempt count, after which the delivery is dead-lettered (visible, along with every
prior attempt, via GET /api/v1/scent/webhooks/{id}/deliveries). Design your handler to be
idempotent on eventId so a retried delivery — including one that actually succeeded on your end
but whose response was lost — never causes a duplicate side effect.
Testing your endpoint
Section titled “Testing your endpoint”POST /api/v1/scent/webhooks/{id}/test-fire with { "eventType": "vendor.scent.order.assigned" }
(must be one of the subscription’s own eventTypes) sends a synthesized payload through the exact same
delivery path a real event uses — same signing, same SSRF guardrails, same retry/delivery-log
behavior. Use it during onboarding to prove your endpoint receives and correctly verifies a signed
delivery before relying on it in production.

