Webhooks & HMAC verification
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.updatedThere 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.
1. Create a subscription
Section titled “1. Create a subscription”POST /api/v1/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).
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": "evt_01HXAMPLE000000000000001", "eventType": "order.completed", "occurredAt": "2026-08-02T10:00:00Z", "data": { "orderGlobalId": "ORD-EXAMPLE-0001", "partnerId": "11111111-1111-1111-1111-111111111111", "productType": "Scent", "externalReference": "po-example-0001", "amount": 249.0, "currency": "SAR", "failureCode": null }}eventId is stable across every retry attempt of the same logical event — use it 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).
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/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/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/webhooks/{id}— change the URL and/or subscribed event types.PATCH /api/v1/webhooks/{id}/active— pause/resume a subscription without deleting it.POST /api/v1/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/webhooks/{id}/deliveries— inspect recent delivery attempts (retained 30 days).
Full request/response shapes are in the API Reference under Webhooks.