Introduction
Webhooks enable real-time communication between systems. When implemented correctly, they provide instant notifications. When done poorly, they cause missed events and debugging nightmares. This guide covers best practices.
Understanding Webhooks
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ How Webhooks Work โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ Without Webhooks (Polling): โ
โ โ
โ Client Server Client Server โ
โ โ โ โ โ โ
โ โโโโRequestโโโโถโ โโโโRequestโโโโถโ โ
โ โโโโResponseโโโ โโโโResponseโโโโ โ
โ โ โ โ โ โ
โ โโโโRequestโโโโถโ (every 1m)โโโโRequestโโโโถโ โ
โ โโโโResponseโโโโ โโโโResponseโโโโ โ
โ โ
โ With Webhooks (Push): โ
โ โ
โ Server Client Server Client โ
โ โ โ โ โ โ
โ โโโโWebhookโโโโถโ (instant) โโโโWebhookโโโโถโ โ
โ โโโ200 OKโโโโโโโ โโโ200 OKโโโโโโโ โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Webhook Security
1. Signature Verification
// HMAC Signature Verification (Node.js)
import crypto from 'crypto';
interface WebhookPayload {
event: string;
data: Record<string, unknown>;
timestamp: number;
}
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
// Timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// Express middleware
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['stripe-signature'] as string;
const payload = req.body.toString();
if (!verifyWebhookSignature(payload, signature, process.env.STRIPE_WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(payload);
// Process event...
res.json({ received: true });
}
);
2. HMAC-SHA256 Implementation
// Generic HMAC webhook verification
class WebhookVerifier {
private secret: string;
constructor(secret: string) {
this.secret = secret;
}
verify(payload: string, signature: string): boolean {
const computedSignature = crypto
.createHmac('sha256', this.secret)
.update(payload, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(computedSignature)
);
}
sign(payload: object): string {
const payloadString = JSON.stringify(payload);
return crypto
.createHmac('sha256', this.secret)
.update(payloadString, 'utf8')
.digest('hex');
}
}
3. Additional Security Measures
# Webhook security checklist
security:
- "Always verify signatures"
- "Use HTTPS only"
- "Validate payload schema"
- "Implement IP allowlisting"
- "Add timestamp validation"
- "Set maximum payload size"
// IP allowlisting
import { isIP } from 'net';
const ALLOWED_IPS = ['203.0.113.1', '198.51.100.2'];
function checkSourceIP(req: Request): boolean {
const clientIP = req.ip || req.socket.remoteAddress;
return ALLOWED_IPS.includes(clientIP);
}
// Timestamp validation
function isTimestampValid(timestamp: number, maxAgeMs: number = 300000): boolean {
const now = Date.now();
return timestamp > now - maxAgeMs && timestamp < now + maxAgeMs;
}
Retry Strategies
1. Exponential Backoff
// Webhook retry with exponential backoff
const RETRY_DELAYS = [0, 60000, 300000, 900000, 3600000]; // 0s, 1m, 5m, 15m, 1h
async function processWebhookWithRetry(
payload: WebhookPayload,
maxRetries: number = 4
): Promise<void> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
await sendToWebhook(payload);
return;
} catch (error) {
if (attempt === maxRetries) {
console.error('Max retries reached, giving up');
// Send to dead letter queue
await sendToDLQ(payload);
return;
}
console.log(`Retry ${attempt + 1} in ${RETRY_DELAYS[attempt]}ms`);
await sleep(RETRY_DELAYS[attempt]);
}
}
}
2. Webhook Retry Schedule
# Recommended retry schedule
retry_schedule:
- attempt: 1
delay: "0s (immediate)"
reason: "network error"
- attempt: 2
delay: "1 minute"
reason: "temporary failure"
- attempt: 3
delay: "5 minutes"
reason: "still failing"
- attempt: 4
delay: "15 minutes"
reason: "service may be down"
- attempt: 5
delay: "1 hour"
reason: "final attempt before DLQ"
3. Idempotency
// Idempotent webhook processing
import { Redis } from 'ioredis';
const redis = new Redis();
async function processWebhook(eventId: string, payload: WebhookPayload) {
// Check if already processed
const processed = await redis.set(
`webhook:${eventId}`,
'processed',
'EX', 86400, // 24 hour expiry
'NX' // Only set if not exists
);
if (!processed) {
console.log(`Event ${eventId} already processed, skipping`);
return;
}
// Process the webhook
await handleEvent(payload);
}
Receiving Webhooks
1. Express Handler
// Robust webhook endpoint
app.post(
'/webhooks/provider',
express.raw({ type: 'application/json' }),
async (req, res) => {
try {
// 1. Verify signature
const signature = req.headers['x-webhook-signature'] as string;
if (!verifySignature(req.body, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 2. Parse payload
const payload = JSON.parse(req.body.toString());
// 3. Verify timestamp (prevent replay attacks)
if (!isTimestampValid(payload.timestamp)) {
return res.status(400).json({ error: 'Invalid timestamp' });
}
// 4. Process asynchronously (respond quickly)
processWebhook(payload).catch(console.error);
// 5. Return 200 immediately
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook processing error:', error);
res.status(500).json({ error: 'Processing failed' });
}
}
);
2. Queue-Based Processing
// Queue webhook for async processing
import { Queue } from 'bull';
const webhookQueue = new Queue('webhooks', process.env.REDIS_URL);
app.post('/webhooks/provider', async (req, res) => {
// Quick validation
const signature = req.headers['x-webhook-signature'];
if (!verifySignature(req.body, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Add to queue
await webhookQueue.add({
payload: JSON.parse(req.body.toString()),
receivedAt: Date.now(),
});
// Respond immediately
res.status(200).json({ received: true });
});
// Queue processor
webhookQueue.process(async (job) => {
const { payload } = job.data;
await handleWebhookEvent(payload);
});
Testing Webhooks
1. Local Testing
// Use webhook CLI or local server
// npx localtunnel --port 3000
// Or ngrok
// ngrok http 3000
// Test endpoint
app.post('/webhook-test', (req, res) => {
console.log('Received webhook:', req.body);
res.status(200).json({ success: true });
});
2. Webhook Testing Tools
# Webhook testing services
tools:
- "Webhook.site - Inspect and test webhooks"
- "RequestBin - Collect HTTP requests"
- "ngrok - Expose local server"
- "localtunnel - Simple tunneling"
Key Takeaways
- Always verify signatures - Never trust payloads without verification
- Respond quickly - Process asynchronously, return 200 immediately
- Implement retries - Exponential backoff with dead letter queue
- Make idempotent - Prevent duplicate processing
- Validate timestamps - Prevent replay attacks
Comments