Webhook Design: Event-Driven API Integration
Webhooks are HTTP callbacks that enable real-time, event-driven communication between systems. Instead of repeatedly asking 'has anything changed?' through polling, webhooks push notifications to...
Key Insights
- Webhooks eliminate wasteful polling by pushing events to consumers in real-time, reducing server load by up to 99% compared to frequent API polling
- Proper webhook security requires HMAC signature verification, HTTPS enforcement, and idempotency handling to prevent replay attacks and duplicate processing
- Production webhook systems need exponential backoff retry logic, dead letter queues, and comprehensive delivery logging to achieve 99.9%+ reliability
Introduction to Webhooks vs. Polling
Webhooks are HTTP callbacks that enable real-time, event-driven communication between systems. Instead of repeatedly asking “has anything changed?” through polling, webhooks push notifications to your application the moment an event occurs.
Consider a payment processing scenario. With polling, you might check payment status every 30 seconds:
// Polling approach - inefficient
setInterval(async () => {
const payment = await fetch(`/api/payments/${paymentId}`);
const data = await payment.json();
if (data.status === 'completed') {
processPayment(data);
}
}, 30000); // Check every 30 seconds
This creates unnecessary load on both client and server. With 1,000 pending payments, you’re making 2,880,000 requests per day, most returning “no change.”
Webhooks flip this model:
// Webhook approach - efficient
app.post('/webhooks/payment', (req, res) => {
const event = req.body;
if (event.type === 'payment.completed') {
processPayment(event.data);
}
res.status(200).send('OK');
});
One request, instant notification, zero wasted cycles. Use webhooks when you need real-time updates, have many clients monitoring resources, or want to reduce infrastructure costs. Stick with polling for simple use cases, when you can’t expose public endpoints, or when events are so frequent that webhooks would overwhelm your system.
Designing a Webhook Provider
A webhook provider needs three core components: registration endpoints, event dispatch logic, and subscription management.
Start with a registration endpoint that lets consumers subscribe to specific event types:
const express = require('express');
const crypto = require('crypto');
const app = express();
// In-memory storage (use database in production)
const subscriptions = new Map();
app.post('/api/webhooks/subscribe', async (req, res) => {
const { url, events, secret } = req.body;
// Validate the webhook URL
if (!url || !url.startsWith('https://')) {
return res.status(400).json({
error: 'HTTPS URL required'
});
}
// Generate a webhook ID and signing secret
const webhookId = crypto.randomBytes(16).toString('hex');
const signingSecret = secret || crypto.randomBytes(32).toString('hex');
// Store subscription
subscriptions.set(webhookId, {
id: webhookId,
url,
events: events || ['*'], // Subscribe to all events by default
secret: signingSecret,
createdAt: new Date(),
active: true
});
res.status(201).json({
webhookId,
secret: signingSecret,
url,
events
});
});
// Event dispatch function
async function dispatchWebhook(eventType, payload) {
const timestamp = Date.now();
for (const [id, subscription] of subscriptions) {
// Check if subscription wants this event
if (!subscription.active) continue;
if (!subscription.events.includes('*') &&
!subscription.events.includes(eventType)) continue;
const event = {
id: crypto.randomBytes(16).toString('hex'),
type: eventType,
timestamp,
data: payload
};
await sendWebhook(subscription, event);
}
}
Implementing a Webhook Consumer
Receiving webhooks securely requires signature verification to ensure requests actually come from the provider:
const crypto = require('crypto');
const express = require('express');
const app = express();
// Use raw body for signature verification
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString('utf8');
}
}));
// Store processed event IDs for idempotency
const processedEvents = new Set();
app.post('/webhooks/receive', async (req, res) => {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
const webhookSecret = process.env.WEBHOOK_SECRET;
// Verify signature
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(`${timestamp}.${req.rawBody}`)
.digest('hex');
if (signature !== expectedSignature) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Verify timestamp to prevent replay attacks
const age = Date.now() - parseInt(timestamp);
if (age > 300000) { // 5 minutes
return res.status(400).json({ error: 'Timestamp too old' });
}
const event = req.body;
// Idempotency check
if (processedEvents.has(event.id)) {
return res.status(200).json({ status: 'already_processed' });
}
try {
// Process the event
await handleEvent(event);
// Mark as processed
processedEvents.add(event.id);
// Respond quickly (within 5 seconds)
res.status(200).json({ status: 'received' });
} catch (error) {
console.error('Event processing failed:', error);
res.status(500).json({ error: 'Processing failed' });
}
});
async function handleEvent(event) {
switch (event.type) {
case 'payment.completed':
await processPayment(event.data);
break;
case 'user.created':
await sendWelcomeEmail(event.data);
break;
default:
console.log('Unknown event type:', event.type);
}
}
Security Best Practices
Never trust incoming webhook data without verification. Implement these security layers:
const crypto = require('crypto');
class WebhookSecurity {
static generateSecret() {
return crypto.randomBytes(32).toString('base64');
}
static signPayload(payload, secret, timestamp) {
const message = `${timestamp}.${JSON.stringify(payload)}`;
return crypto
.createHmac('sha256', secret)
.update(message)
.digest('hex');
}
static verifySignature(payload, signature, secret, timestamp) {
const expected = this.signPayload(payload, secret, timestamp);
// Use timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
static validatePayload(payload, schema) {
// Sanitize and validate against expected structure
const allowedKeys = new Set(Object.keys(schema));
for (const key in payload) {
if (!allowedKeys.has(key)) {
throw new Error(`Unexpected field: ${key}`);
}
}
return true;
}
}
// Rate limiting middleware
const rateLimit = require('express-rate-limit');
const webhookLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per window
message: 'Too many webhook requests'
});
app.use('/webhooks', webhookLimiter);
Always use HTTPS in production, rotate secrets periodically, and implement IP whitelisting when possible.
Reliability Patterns
Webhook delivery can fail due to network issues, service downtime, or bugs. Implement robust retry logic:
class WebhookDelivery {
constructor(maxRetries = 5) {
this.maxRetries = maxRetries;
this.baseDelay = 1000; // 1 second
}
async sendWebhook(subscription, event, attempt = 0) {
try {
const signature = WebhookSecurity.signPayload(
event,
subscription.secret,
event.timestamp
);
const response = await fetch(subscription.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
'X-Webhook-Timestamp': event.timestamp.toString(),
'X-Webhook-ID': event.id
},
body: JSON.stringify(event),
timeout: 5000
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
await this.logDelivery(event.id, 'success', attempt);
return true;
} catch (error) {
await this.logDelivery(event.id, 'failed', attempt, error.message);
if (attempt < this.maxRetries) {
const delay = this.calculateBackoff(attempt);
console.log(`Retry ${attempt + 1} in ${delay}ms`);
await this.sleep(delay);
return this.sendWebhook(subscription, event, attempt + 1);
} else {
// Move to dead letter queue
await this.moveToDeadLetter(subscription, event, error);
return false;
}
}
}
calculateBackoff(attempt) {
// Exponential backoff with jitter
const exponential = this.baseDelay * Math.pow(2, attempt);
const jitter = Math.random() * 1000;
return Math.min(exponential + jitter, 60000); // Max 1 minute
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async logDelivery(eventId, status, attempt, error = null) {
// Log to database or monitoring system
console.log({
eventId,
status,
attempt,
error,
timestamp: new Date()
});
}
async moveToDeadLetter(subscription, event, error) {
// Store failed events for manual review
console.error('Dead letter:', {
subscription: subscription.id,
event: event.id,
error: error.message
});
}
}
Testing and Debugging Webhooks
Local development requires exposing your localhost to the internet. Use ngrok:
# Install ngrok
npm install -g ngrok
# Start your local server
node server.js
# Expose port 3000
ngrok http 3000
Create a comprehensive testing setup:
const express = require('express');
const app = express();
// Request logging middleware
app.use((req, res, next) => {
console.log({
timestamp: new Date().toISOString(),
method: req.method,
path: req.path,
headers: req.headers,
body: req.body
});
next();
});
// Test webhook endpoint
app.post('/webhooks/test', (req, res) => {
console.log('Received webhook:', JSON.stringify(req.body, null, 2));
// Simulate processing delay
setTimeout(() => {
res.status(200).json({
received: true,
processedAt: new Date().toISOString()
});
}, 100);
});
// Webhook simulator for testing your provider
app.post('/simulate/event', async (req, res) => {
const { eventType, data } = req.body;
await dispatchWebhook(eventType, data);
res.json({
message: 'Event dispatched',
eventType
});
});
app.listen(3000, () => {
console.log('Webhook test server running on port 3000');
console.log('Use ngrok to expose this server for testing');
});
Real-World Implementation
Here’s a production-ready webhook system with database persistence:
const express = require('express');
const { Pool } = require('pg');
const crypto = require('crypto');
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
// Database schema (run once)
async function setupDatabase() {
await pool.query(`
CREATE TABLE IF NOT EXISTS webhook_subscriptions (
id VARCHAR(32) PRIMARY KEY,
url TEXT NOT NULL,
secret VARCHAR(64) NOT NULL,
events TEXT[] NOT NULL,
active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS webhook_deliveries (
id SERIAL PRIMARY KEY,
subscription_id VARCHAR(32) REFERENCES webhook_subscriptions(id),
event_id VARCHAR(32) NOT NULL,
event_type VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL,
attempts INTEGER DEFAULT 0,
last_attempt_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_deliveries_status ON webhook_deliveries(status);
CREATE INDEX idx_deliveries_event_id ON webhook_deliveries(event_id);
`);
}
class ProductionWebhookSystem {
async subscribe(url, events) {
const id = crypto.randomBytes(16).toString('hex');
const secret = crypto.randomBytes(32).toString('hex');
await pool.query(
'INSERT INTO webhook_subscriptions (id, url, secret, events) VALUES ($1, $2, $3, $4)',
[id, url, secret, events]
);
return { id, secret };
}
async dispatch(eventType, payload) {
const result = await pool.query(
'SELECT * FROM webhook_subscriptions WHERE active = true AND ($1 = ANY(events) OR \'*\' = ANY(events))',
[eventType]
);
const event = {
id: crypto.randomBytes(16).toString('hex'),
type: eventType,
timestamp: Date.now(),
data: payload
};
const delivery = new WebhookDelivery();
for (const subscription of result.rows) {
await delivery.sendWebhook(subscription, event);
}
}
}
setupDatabase().catch(console.error);
This implementation provides the foundation for a scalable webhook system. Add monitoring with tools like Datadog or Sentry, implement circuit breakers for failing endpoints, and consider using a message queue like RabbitMQ or AWS SQS for high-volume scenarios.
Webhooks transform how systems communicate, but they require careful implementation. Focus on security, reliability, and observability to build webhook systems your users can depend on.