Secure Webhook Handler
Signature verification, idempotency, retry handling, and queue processing
What You’ll Build
After following this guide, you will have a working implementation of secure webhook handler in your project. Handle third-party webhooks (Stripe, GitHub, etc.) securely and reliably. Covers HMAC signature verification to prove the webhook is authentic, idempotency to handle retries without double-processing, and background queue processing for reliability.
Use Cases & Problems Solved
- Build reliable server endpoints that clients can consume consistently
- Handle common backend patterns like routing, middleware, and error handling
- Provide a clean interface between your frontend and data layer
Prerequisites
- Node.js 18+
- Express.js
- Understanding of HMAC signatures
Step-by-Step Implementation
Verify webhook signature (Stripe example)
The following snippet shows how to verify webhook signature (stripe example). Copy this into your project and adjust the values for your environment.
const crypto = require('crypto');
// IMPORTANT: Use raw body, not parsed JSON
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'];
const secret = process.env.STRIPE_WEBHOOK_SECRET;
// Verify HMAC signature
const payload = req.body.toString();
const expectedSig = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
if (sig !== \`sha256=\${expectedSig}\`) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(payload);
await processWebhookEvent(event);
res.status(200).json({ received: true });
}
);
Idempotency — prevent double processing
The following snippet shows how to idempotency — prevent double processing. Copy this into your project and adjust the values for your environment.
async function processWebhookEvent(event) {
// Check if already processed
const existing = await db.webhookEvent.findUnique({
where: { eventId: event.id },
});
if (existing) return; // Already processed, skip
// Mark as processing
await db.webhookEvent.create({
data: { eventId: event.id, type: event.type, status: 'processing' },
});
try {
switch (event.type) {
case 'payment_intent.succeeded':
await fulfillOrder(event.data.object);
break;
case 'customer.subscription.deleted':
await cancelSubscription(event.data.object);
break;
}
await db.webhookEvent.update({
where: { eventId: event.id }, data: { status: 'completed' },
});
} catch (err) {
await db.webhookEvent.update({
where: { eventId: event.id }, data: { status: 'failed', error: err.message },
});
throw err; // Return 500 so the webhook provider retries
}
}
⚠️ Don’t Do This
❌ Processing webhooks without signature verification
// Anyone can POST fake events to this endpoint!
app.post('/webhooks/stripe', express.json(), (req, res) => {
const event = req.body; // No verification!
fulfillOrder(event.data.object); // Attacker triggers fake fulfillment
});
✅ Always verify the signature before processing
// Verify the request actually came from Stripe
const sig = req.headers['stripe-signature'];
try {
const event = stripe.webhooks.constructEvent(req.body, sig, secret);
// Now safe to process
} catch (err) {
return res.status(401).send('Invalid signature');
}
Testing
Add these tests to verify your API endpoints work correctly:
// __tests__/api.test.ts
import { describe, it, expect } from 'vitest';
describe('Secure Webhook Handler', () => {
it('should return 200 for valid requests', async () => {
const res = await fetch('/api/endpoint', { method: 'GET' });
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toBeDefined();
});
it('should return 400 for invalid input', async () => {
const res = await fetch('/api/endpoint', {
method: 'POST',
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
it('should handle errors gracefully', async () => {
const res = await fetch('/api/endpoint/nonexistent');
expect(res.status).toBe(404);
});
});
Verification
# Use Stripe CLI to test locally:
stripe listen --forward-to localhost:3000/webhooks/stripe
stripe trigger payment_intent.succeeded
# Check your logs for successful processing Related Specs
Production-Ready REST API (Express)
Error handling, Zod validation, rate limiting, CORS, and structured logging
Real-Time Updates with SSE
EventSource API, reconnection, broadcasting, and Express/Fastify SSE
API Rate Limiting Strategies
Token bucket, sliding window, Redis-backed, and per-user limits