Best practices
A small list of things that prevent the common production fires.
1. Make handlers idempotent
We deliver at-least-once. Network blips and our own retries produce duplicates. Your handler should treat duplicates as a non-event.
Dedup by event ID
The simplest pattern:
const eventId = `${event.verification_id}-${event.type}`;
const inserted = await db.query(
`INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING 1`,
[eventId],
);
if (inserted.rowCount === 0) {
return; // already processed
}
await applyVerification(event);
Use the verification_id from the event, it’s stable across retries.
2. Acknowledge quickly, work async
Return 2xx within a few seconds. If your handler does expensive work, database fan-out, downstream API calls, emails, push to a queue and return immediately.
app.post('/webhooks/self', express.raw({ type: 'application/json' }), async (req, res) => {
const event = SelfWebhooks.verify(req.body, req.headers, secret);
await jobQueue.enqueue('process-verification', event);
res.status(200).end();
});
Slow handlers get retried, which produces duplicates, which compounds. Fast ack + queue is the only stable pattern.
3. Treat each delivery in isolation
verification.completed is the only event delivered, so there’s no cross-event ordering to reason about. Don’t assume anything about the order of deliveries for different verifications either. If you need a verification’s current storage state, read it with sessions.get(...) rather than inferring it from the webhook.
4. Distinguish status carefully
verification.completed fires with four statuses. Don’t lump them:
| Status | What it means | Common handling |
|---|---|---|
valid | Proof verified, predicates passed. | Mark user verified, unlock the gated feature. |
invalid | Proof verified, a predicate failed. | Tell the user why (e.g. age requirement); offer retry. |
error | Technical failure (bad proof, unsupported document). | Tell the user to retry; alert ops if rate spikes. |
expired | Session timed out. | Generate a new session if the user is still around. |
A common bug: treating any non-valid as a final rejection. invalid and error are recoverable; expired definitely is.
5. Verify before doing anything
Don’t read event.type until SelfWebhooks.verify(...) has returned. An attacker can send any body to your endpoint; only the signature proves it came from us.
// Wrong:
app.post('/webhooks/self', (req, res) => {
const event = JSON.parse(req.body); // ← trusting unverified body
if (event.type === 'verification.completed') {
grantAccess(event.external_uuid); // ← attacker can call this
}
});
// Right:
app.post('/webhooks/self', express.raw({ type: 'application/json' }), (req, res) => {
const event = SelfWebhooks.verify(req.body, req.headers, secret);
// ...now safe to act on event
});
6. Return the right status codes
| Your response | What we do |
|---|---|
2xx | Delivery successful. No retry. |
4xx (except 408, 429) | Treated as a permanent rejection. No retry. |
408, 429, 5xx | Retried with backoff. |
| Timeout / connection refused | Retried with backoff. |
So:
- Return
400on signature failure. You want it dropped, not retried. - Return
500(or just throw) on transient errors. You want it retried. - Don’t return
400because of a database hiccup. You’ll silently drop events.
7. Use one endpoint per environment
Don’t share a single endpoint between staging and production. They’ll have different secrets and different traffic profiles. Register one endpoint for test and one for live, each with its own URL and signing secret.
8. Monitor failures on your side
Alert on your own handler’s error rate (the 5xx and signature-failure responses it returns). A handler that’s been silently failing is a known production incident pattern, and you’ll spot it faster from your own metrics than from the deliveries. Failed deliveries are retried automatically, so once you fix the handler the backlog drains on its own.
9. Log the verification ID
Log event.verification_id on every handler invocation:
log.info({ msg: 'webhook_received', verificationId: event.verification_id, type: event.type });
When you ask support about a missing or wrong delivery, that’s the ID to quote.