1. Return 2xx quickly

Treat webhooks like a mailbox: accept, verify, enqueue, return.
app.post("/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
  const event = verifyWebhook(
    req.body,
    String(req.headers["x-webhook-timestamp"] || ""),
    String(req.headers["x-webhook-signature"] || ""),
    secret,
  );

  await queue.push(event);
  return res.sendStatus(200);
});

2. Make handlers idempotent

Retries and manual replays can produce duplicates. Deduplicate on event.id.
const firstTime = await redis.set(
  `gs:event:${event.id}`,
  "1",
  { NX: true, EX: 7 * 24 * 3600 },
);
if (firstTime !== "OK") return;

3. Verify every delivery

Never trust event.type, X-Zquence-Event, or any payload data before signature verification succeeds.

4. Store one secret per endpoint

Each webhook endpoint has its own signing secret. Store it in your secrets manager and keep environments separated.

5. Reject stale timestamps

A 5-minute tolerance window is a strong default for replay protection.

6. Expect all non-2xx responses to retry

Current Zquence behavior retries every non-2xx response while attempts remain. Design your receiver and observability with that behavior in mind.

7. Log delivery identifiers

At minimum, log:
  • event.id
  • event.type
  • tenantId
  • your internal processing result

8. Handle unknown event types gracefully

switch (event.type) {
  case "kyc.provider.completed":
    // ...
    break;
  case "account.status.changed":
    // ...
    break;
  default:
    logger.info({ type: event.type }, "Unhandled webhook event");
}
A new event type should not break your endpoint.

9. Use HTTPS-only endpoints

Webhook deliveries may contain sensitive tenant and workflow metadata. Always terminate over HTTPS.

10. Keep business logic off the request thread

The webhook timeout is 10 seconds. Queue work immediately and let background workers handle the heavy processing.