Skip to content
Linra Omni Vendor Docs

Webhooks & HMAC verification

Subscribing to vendor events, the delivery envelope, and a verified signature-verification code sample.

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.

Terminal window
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.

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.

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).

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.

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)); // -> true

A 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.

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.

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.