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:

  1. Parse the header against ^t=(\d+),v1=([0-9a-f]+)$. Reject anything that doesn’t match.
  2. Reject if |now − t| > 300 seconds (5 minutes, in either direction) — this is replay protection, not just staleness detection: it also blocks a signature crafted for a future timestamp.
  3. Recompute the HMAC over `${t}.${rawBody}` with your endpoint’s secret.
  4. Compare the recomputed digest to v1 in constant time. Check the lengths match first — timingSafeEqual throws on mismatched buffer lengths rather than returning false, 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