Implementing Webhooks: Receiving, Validating, and Retrying Events
Webhooks are how Stripe, GitHub, and every modern API notify your server of events. Getting them right requires signature validation, idempotency, and retry handling.
The Basics
// Basic webhook receiver
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
// CRITICAL: Use raw body for signature validation
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Process event
switch (event.type) {
case 'payment_intent.succeeded':
await handlePayment(event.data.object);
break;
}
res.json({ received: true });
});
Signature Validation (Generic)
import { createHmac, timingSafeEqual } from 'crypto';
function validateWebhookSignature(payload: string, signature: string, secret: string): boolean {
const expected = createHmac('sha256', secret)
.update(payload)
.digest('hex');
// timingSafeEqual prevents timing attacks
return timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
Idempotency — Process Each Event Once
// Store processed event IDs to prevent duplicate processing
async function processWebhookIdempotently(eventId: string, handler: () => Promise<void>) {
const existing = await db.processedWebhook.findUnique({ where: { eventId } });
if (existing) {
console.log(`Skipping duplicate event: ${eventId}`);
return;
}
await handler();
await db.processedWebhook.create({
data: { eventId, processedAt: new Date() }
});
}
Async Processing with a Queue
// Return 200 immediately, process async
app.post('/webhooks', async (req, res) => {
// Validate signature first
if (!isValidSignature(req)) return res.status(401).send('Invalid signature');
// Acknowledge immediately
res.json({ received: true });
// Process in background
await queue.add('process-webhook', { event: req.body });
});
// Worker processes events without blocking the HTTP response
queue.process('process-webhook', async (job) => {
await processEvent(job.data.event);
});
Retrying Failed Events
// Store events that fail processing for retry
async function handleWebhookWithRetry(event: WebhookEvent) {
try {
await processEvent(event);
} catch (error) {
await db.failedWebhook.create({
data: {
eventId: event.id,
payload: JSON.stringify(event),
error: error.message,
nextRetryAt: new Date(Date.now() + 5 * 60 * 1000), // 5 min
retryCount: 0,
}
});
}
}
// Cron job retries failed events with exponential backoff
async function retryFailedWebhooks() {
const events = await db.failedWebhook.findMany({
where: { nextRetryAt: { lte: new Date() }, retryCount: { lt: 5 } }
});
for (const event of events) {
try {
await processEvent(JSON.parse(event.payload));
await db.failedWebhook.delete({ where: { id: event.id } });
} catch {
const backoff = Math.pow(2, event.retryCount) * 5 * 60 * 1000;
await db.failedWebhook.update({
where: { id: event.id },
data: { retryCount: { increment: 1 }, nextRetryAt: new Date(Date.now() + backoff) }
});
}
}
}
Next.js Route Handler
// app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
const body = await request.text(); // Raw body needed
const sig = request.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response('Invalid signature', { status: 400 });
}
await handleStripeEvent(event);
return Response.json({ ok: true });
}
Stripe webhooks ship pre-wired (with signature validation, idempotency, and delivery confirmation) in the AI SaaS Starter Kit. $99 at whoffagents.com.
Top comments (0)