Your webhook fires. The receiver is down. The event is lost forever.
The Problem With Fire-and-Forget
Most webhook implementations: serialize payload, POST to URL, move on. If the receiver returns 500 or times out, the event vanishes. No retry. No record. No way to recover.
Webhook Architecture
Event -> Queue -> Delivery Worker -> HTTP POST -> Receiver
| (on failure)
Retry Queue -> Exponential Backoff -> DLQ
Store every event in a database. The delivery worker reads from the queue and attempts delivery. On failure, schedule a retry with exponential backoff.
Signing Webhooks
Never trust the sender without verification. Sign every payload with HMAC-SHA256:
import crypto from "crypto";
function signPayload(payload: string, secret: string): string {
return crypto.createHmac("sha256", secret).update(payload).digest("hex");
}
function verifyWebhook(payload: string, signature: string, secret: string): boolean {
const expected = signPayload(payload, secret);
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
Include timestamp in the signature to prevent replay attacks. Stripe adds t=timestamp to the signature header and rejects events older than 5 minutes.
The Delivery Worker
interface WebhookEvent {
id: string; type: string; payload: unknown;
url: string; secret: string; attempts: number;
nextRetry: Date | null;
status: "pending" | "delivered" | "failed";
}
async function deliver(event: WebhookEvent): Promise<void> {
const body = JSON.stringify(event.payload);
const signature = signPayload(body, event.secret);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const res = await fetch(event.url, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Webhook-Signature": signature, "X-Webhook-ID": event.id },
body, signal: controller.signal,
});
clearTimeout(timeout);
if (res.ok) event.status = "delivered";
else scheduleRetry(event);
} catch { scheduleRetry(event); }
}
function scheduleRetry(event: WebhookEvent): void {
event.attempts++;
if (event.attempts >= 5) { event.status = "failed"; return; }
const delay = Math.min(1000 * Math.pow(2, event.attempts), 3600000);
event.nextRetry = new Date(Date.now() + delay);
}
Idempotency on the Receiver Side
Receivers must handle duplicate deliveries. Use the webhook ID as an idempotency key:
app.post("/webhooks", async (req, res) => {
const webhookId = req.headers["x-webhook-id"];
const exists = await redis.get(`webhook:${webhookId}`);
if (exists) return res.status(200).json({ status: "already_processed" });
await processEvent(req.body);
await redis.setex(`webhook:${webhookId}`, 604800, "processed");
res.status(200).json({ status: "ok" });
});
What Stripe Gets Right
- Event log: Every event stored with full payload, queryable via API
- Automatic retries: Up to 3 days with exponential backoff
- Webhook signatures: HMAC-SHA256 with timestamp for replay protection
- Granular event types: invoice.paid, not just invoice
- Test mode: Separate webhook endpoints for test vs live
Common Mistakes
- No timeout: Receiver hangs forever, blocking your delivery worker
- Synchronous delivery: Sending webhooks in the request path blocks your API
- No signature verification: Accepting any POST to your webhook URL
- No idempotency: Processing the same event twice charges the customer twice
- Logging payloads with PII: Webhook payloads often contain sensitive data ***
Part of my Production Backend Patterns series. Follow for more practical backend engineering.
You Might Also Like
- API Rate Limiting with Redis: Token Bucket, Sliding Window, and Per-Client Limits
- API Rate Limiting with Redis: Token Bucket, Sliding Window, and Per-Client Limits (2026)
- CORS Explained Simply: Why Your Frontend Can't Talk to Your API (Fix in 5 Minutes)
Follow me for more production-ready backend content!
If this helped you, buy me a coffee on Ko-fi!
Top comments (0)