Webhooks & HMAC verification
Event types
Section titled “Event types”| Event type | Fired when | Payload |
|---|---|---|
vendor.order.assigned |
A fulfilment group is newly assigned to you. | { groupId, orderGlobalId, vendorId } |
vendor.offer.review-decision |
One of your offers is approved, rejected, or a pending cost change is approved/rejected. | { offerId, vendorId, decision, reason } — decision is one of Approved/Rejected/CostApproved/CostRejected; reason is present only for the two rejection outcomes. |
Both are deliberately minimal — data minimization, the same posture the partner-facing webhook
platform uses. Fetch full details (shipping address, lines, current offer state) via the existing
poll endpoints (GET /api/v1/vendor/orders/{groupId}, GET /api/v1/vendor/offers/{id}) rather than
expecting them in the notification itself.
Subscription management requires the Orders capability (see the
Vendor Integration Standard §3) —
an Offers-only credential cannot subscribe to vendor.offer.review-decision today and must poll
instead.
Creating a subscription
Section titled “Creating a subscription”curl -X POST https://api-omni-stg.linra.net/api/v1/vendor/webhooks \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-integration.example.com/webhooks/linra", "eventTypes": ["vendor.order.assigned", "vendor.offer.review-decision"] }'{ "state": "CREATED", "payload": { "subscription": { "id": "...", "vendorId": "...", "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. Up to 5 active subscriptions per vendor
(BUSINESS_VENDOR_WEBHOOK_SUBSCRIPTION_LIMIT beyond that); rotate a compromised secret with
POST /api/v1/vendor/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.order.assigned", "occurredAt": "2026-08-02T10:32:25Z", "data": { "groupId": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "orderGlobalId": "ORD-2026-000481", "vendorId": "a1b2c3d4-e5f6-4789-a012-3456789abcde" }}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/vendor/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)”This sample was 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) for the exact envelope shown above:
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);}
// --- Verified against HmacSigner.ComputeSignatureHeader with these exact inputs ---const secret = 'whsec_5f8a2e1c9b3d4a6f8e0c2b1a7d9e3f4c';const timestamp = '1732012345';const rawBody = '{"eventId":"3fa85f64-5717-4562-b3fc-2c963f66afa6","eventType":"vendor.order.assigned",' + '"occurredAt":"2026-08-02T10:32:25Z","data":{"groupId":"7c9e6679-7425-40de-944b-e07fc1f90ae7",' + '"orderGlobalId":"ORD-2026-000481","vendorId":"a1b2c3d4-e5f6-4789-a012-3456789abcde"}}';const signature = 'sha256=01092a7a5746bd7255e4b61cbe9efc292f363e211b016ddb0e2e8cfd8d514528';
console.log(verifyLinraSignature(secret, timestamp, rawBody, signature)); // -> trueBoth HmacSigner.ComputeSignatureHeader(secret, timestamp, rawBody) on the .NET side and this
Node.js sample independently computed the identical hex digest
(01092a7a5746bd7255e4b61cbe9efc292f363e211b016ddb0e2e8cfd8d514528) for the exact secret,
timestamp, and body above — the sample is not a guess at the algorithm, it’s confirmed
byte-for-byte against the real signer.
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.
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/vendor/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/vendor/webhooks/{id}/test-fire with { "eventType": "vendor.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.