Skip to content

Webhooks & HMAC verification

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.

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

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

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.

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

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

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.

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.