Webhook verification
The platform delivers outbound webhooks for domain events —
transaction.recorded, transaction.reversed, redemption.committed, redemption.fulfilled,
redemption.cancelled, and webhook.test (a synthetic event your endpoint’s “send test event” action fires, bypassing
your endpoint’s own event filter). Each webhook endpoint you register gets its own signing secret
(whsec_ + 48 hex characters), shown in full only once at creation time — see the
API reference for the current endpoint-management routes and response shapes.
Envelope
Every delivery’s JSON body is the same shape regardless of event type:
{
"id": "01J6Z3R8Q6TQXW2K6V9F1YB7ME",
"type": "transaction.recorded",
"created_at": "2026-07-06T12:00:00.000Z",
"data": { "...": "the corresponding API response shape for this event type" }
}
id is a ULID and is the delivery’s identity — it’s the same value sent in the
X-Loyalty-Delivery header (below), and every redelivery of the same underlying event (retries,
or your own re-fetch of a delivery) carries this same id.
Headers
| Header | Value |
|---|---|
X-Loyalty-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA-256> — see below |
X-Loyalty-Event |
The event type, e.g. transaction.recorded |
X-Loyalty-Delivery |
The envelope’s own id |
Signature scheme
X-Loyalty-Signature: t=1783771200,v1=5257a869e7bcf7...
The signature is an HMAC-SHA-256, hex-encoded, computed over the literal string
`${t}.${body}` — the timestamp, a literal dot, then the exact raw request body bytes, not
a re-serialized/re-parsed version of the JSON — keyed with your endpoint’s signing secret.
Verification, step by step:
- Parse the header against
^t=(\d+),v1=([0-9a-f]+)$. Reject anything that doesn’t match. - Reject if
|now − t| > 300seconds (5 minutes, in either direction) — this is replay protection, not just staleness detection: it also blocks a signature crafted for a future timestamp. - Recompute the HMAC over
`${t}.${rawBody}`with your endpoint’s secret. - Compare the recomputed digest to
v1in constant time. Check the lengths match first —timingSafeEqualthrows on mismatched buffer lengths rather than returningfalse, so an unguarded call is itself a subtle bug, not just a style nit.
Verify sample (plain Node, no dependencies)
This mirrors verifyWebhookSignature in packages/webhooks/src/sign.ts line for line:
const crypto = require("node:crypto");
function verifyLoyaltySignature(secret, header, rawBody, toleranceSeconds = 300) {
const match = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header);
if (!match) return false;
const [, tRaw, v1] = match;
const timestamp = Number(tRaw);
const now = Math.floor(Date.now() / 1000);
if (!Number.isSafeInteger(timestamp)) return false;
if (Math.abs(now - timestamp) > toleranceSeconds) return false;
const expected = Buffer.from(
crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"),
"hex",
);
const actual = Buffer.from(v1, "hex");
if (expected.length !== actual.length) return false;
return crypto.timingSafeEqual(expected, actual);
}
// In your handler — rawBody MUST be the untouched request body bytes/string, captured
// before any JSON-parsing middleware discards them (a very common integration bug).
if (!verifyLoyaltySignature(endpointSecret, req.headers["x-loyalty-signature"], rawBody)) {
return res.status(401).end();
}
const event = JSON.parse(rawBody);
Delivery is at-least-once — dedupe on id
Deliveries are at-least-once, not exactly-once: your endpoint can legitimately receive the
same id more than once (a retry after your server accepted it but the response was lost in
transit, a redelivery racing a periodic re-drive sweep, etc). Treat processing as idempotent —
track envelope ids you’ve already handled (or use X-Loyalty-Delivery, same value) and skip
repeats, the same way this platform’s own inbound ingestion dedupes on delivery_id
(see the generic connector guide).
Retry schedule
A failed delivery (endpoint unreachable, non-2xx response, or timeout — 10 seconds) is retried with full-jitter backoff: each retry waits a random delay up to a ceiling that grows with the attempt number.
| Attempt | Backoff ceiling before this attempt |
|---|---|
| 1st retry (2nd attempt overall) | up to 1 minute |
| 2nd retry | up to 5 minutes |
| 3rd retry | up to 30 minutes |
| 4th retry | up to 2 hours |
| 5th attempt fails | marked DEAD — no further retries |
The 5th consecutive failed delivery attempt is terminal: the outbound webhook’s state goes
straight to DEAD at that point, rather than waiting out a further ~8 hour ceiling and trying a
6th time. If your endpoint has been down long enough to accumulate DEAD deliveries, you’ve
missed them — there’s no automatic replay past that point (a manual replay/redrive is a
webhook-console concern, not something this delivery loop does on its own).
A delivered event’s outbox row moves PENDING → DELIVERED (success) or
PENDING/FAILED → FAILED (with a next_retry_at) → … → DEAD (retries exhausted). Successful
delivery and reaching DEAD are both terminal — a redelivery that lands on an already-terminal
row is a safe no-op on the sending side too.
Endpoint URL requirements
Registered endpoint URLs must be https:// — plain http:// is only accepted for
localhost/127.0.0.1, for local development.
Next steps
- Generic connector — the inbound counterpart: ingesting POS events with the same at-least-once, dedupe-on-id philosophy.
- API reference — the full webhook envelope and endpoint-management schemas.