Building Webhooks with Express.js: Best Practices
Learn how to build production-ready webhooks with Express.js. Complete guide covering sending webhooks, receiving webhooks, raw body parsing, signature verification middleware, error handling, and queue integration.

Building Webhooks with Express.js: Best Practices
Express.js provides flexibility for webhooks, but production reliability requires correct patterns: body parsing, response timing, and error handling. This guide covers sending and receiving webhooks. For framework variations, see webhooks with Next.js and serverless patterns.
Sending Webhooks from Express Routes
Basic Webhook Dispatch
const crypto = require('crypto');
const axios = require('axios');
async function sendWebhook(endpoint, secret, eventType, payload) {
const timestamp = Math.floor(Date.now() / 1000);
const body = JSON.stringify({
id: `evt_${crypto.randomUUID()}`,
type: eventType,
created_at: new Date().toISOString(),
data: payload
});
const signature = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${body}`)
.digest('hex');
return axios.post(endpoint, body, {
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': `t=${timestamp},v1=${signature}`
},
timeout: 30000
});
}Triggering Webhooks from Routes
app.post('/api/orders', async (req, res) => {
try {
const order = await createOrder(req.body);
// Respond immediately
res.status(201).json(order);
// Then dispatch webhooks (fire and forget for now)
const subscriptions = await getWebhookSubscriptions(req.user.id, 'order.created');
for (const sub of subscriptions) {
sendWebhook(sub.url, sub.secret, 'order.created', order)
.catch(err => console.error('Webhook delivery failed:', err.message));
}
} catch (error) {
res.status(500).json({ error: 'Failed to create order' });
}
});This works but production systems need retry logic, delivery tracking, and queue-based processing. See webhook implementation guides.
Receiving Webhooks: The Raw Body Requirement
The most common mistake is body parsing. Express's default express.json() discards the original bytes needed for signature verification. See verifying webhooks in Node.js for detailed patterns:
Using express.raw() for Webhook Routes
const express = require('express');
const app = express();
// Regular JSON parsing for most routes
app.use(express.json());
// Raw body parsing specifically for webhooks
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body; // Buffer containing raw bytes
const signature = req.headers['stripe-signature'];
// Verify and process
});Conditional Body Parsing
For cleaner architecture, use conditional middleware:
app.use((req, res, next) => {
if (req.path.startsWith('/webhooks/')) {
express.raw({ type: 'application/json' })(req, res, next);
} else {
express.json()(req, res, next);
}
});Signature Verification Middleware
Create reusable middleware instead of verifying in every handler. See webhook security best practices:
const crypto = require('crypto');
function createWebhookVerifier(getSecret) {
return async (req, res, next) => {
const signature = req.headers['x-webhook-signature'];
if (!signature) {
return res.status(401).json({ error: 'Missing signature header' });
}
try {
// Parse signature components
const elements = Object.fromEntries(
signature.split(',').map(part => part.split('='))
);
const timestamp = elements.t;
const receivedSig = elements.v1;
// Validate timestamp (5-minute tolerance)
const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
if (age > 300) {
return res.status(401).json({ error: 'Request timestamp too old' });
}
// Get secret (may vary by source)
const secret = await getSecret(req);
// Compute expected signature
const payload = req.body.toString();
const expectedSig = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${payload}`)
.digest('hex');
// Timing-safe comparison
const isValid = crypto.timingSafeEqual(
Buffer.from(receivedSig, 'hex'),
Buffer.from(expectedSig, 'hex')
);
if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Parse body for downstream handlers
req.webhookPayload = JSON.parse(payload);
req.webhookTimestamp = timestamp;
next();
} catch (error) {
console.error('Webhook verification error:', error);
res.status(401).json({ error: 'Signature verification failed' });
}
};
}Applying the Middleware
const verifyStripeWebhook = createWebhookVerifier(
() => process.env.STRIPE_WEBHOOK_SECRET
);
const verifyCustomerWebhook = createWebhookVerifier(
async (req) => {
const source = req.headers['x-webhook-source'];
const config = await getWebhookConfig(source);
return config.secret;
}
);
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
verifyStripeWebhook,
handleStripeWebhook
);
app.post('/webhooks/incoming',
express.raw({ type: 'application/json' }),
verifyCustomerWebhook,
handleIncomingWebhook
);Error Handling and Response Timing
Webhook providers expect responses within 30 seconds. Pattern: acknowledge immediately, process asynchronously.
Respond First, Process Later
app.post('/webhooks/payments',
express.raw({ type: 'application/json' }),
verifyWebhook,
async (req, res) => {
const event = req.webhookPayload;
// Acknowledge receipt immediately
res.status(200).json({ received: true });
// Process asynchronously
try {
await processPaymentEvent(event);
} catch (error) {
// Log for manual investigation - don't affect response
console.error('Failed to process webhook:', event.id, error);
await logFailedWebhook(event, error);
}
}
);Comprehensive Error Handling
app.post('/webhooks/orders',
express.raw({ type: 'application/json' }),
verifyWebhook,
async (req, res) => {
const event = req.webhookPayload;
try {
// Quick validation only
if (!event.type || !event.data) {
return res.status(400).json({ error: 'Invalid payload structure' });
}
// Respond with 200 before heavy processing
res.status(200).json({ received: true, id: event.id });
// Queue for processing
await webhookQueue.add('process-order-event', {
eventId: event.id,
eventType: event.type,
payload: event.data,
receivedAt: new Date().toISOString()
});
} catch (error) {
// If we haven't responded yet, send error
if (!res.headersSent) {
res.status(500).json({ error: 'Internal error' });
}
console.error('Webhook handling error:', error);
}
}
);Queue Integration for Reliable Processing
Queue-based processing decouples receipt from processing, enables retries, and prevents timeouts:
Bull Queue Integration
const Queue = require('bull');
const webhookQueue = new Queue('webhooks', process.env.REDIS_URL);
// Producer: your webhook endpoint
app.post('/webhooks/events',
express.raw({ type: 'application/json' }),
verifyWebhook,
async (req, res) => {
const event = req.webhookPayload;
await webhookQueue.add('incoming-webhook', {
id: event.id,
type: event.type,
data: event.data,
headers: {
signature: req.headers['x-webhook-signature'],
source: req.headers['x-webhook-source']
}
}, {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: 100
});
res.status(200).json({ received: true });
}
);
// Consumer: separate process or worker
webhookQueue.process('incoming-webhook', async (job) => {
const { id, type, data } = job.data;
switch (type) {
case 'payment.completed':
await handlePaymentCompleted(data);
break;
case 'subscription.cancelled':
await handleSubscriptionCancelled(data);
break;
default:
console.log(`Unhandled webhook type: ${type}`);
}
});Idempotency: Handling Duplicate Webhooks
Handlers must be idempotent—same webhook twice = same effect as once. See webhook idempotency guide for comprehensive strategies:
const processedWebhooks = new Map(); // Use Redis in production
async function idempotentHandler(eventId, handler) {
// Check if already processed
const existing = await redis.get(`webhook:processed:${eventId}`);
if (existing) {
console.log(`Duplicate webhook ignored: ${eventId}`);
return { duplicate: true };
}
// Process the webhook
const result = await handler();
// Mark as processed with 24-hour TTL
await redis.setex(`webhook:processed:${eventId}`, 86400, JSON.stringify({
processedAt: new Date().toISOString(),
result: 'success'
}));
return result;
}
// Usage in handler
app.post('/webhooks/payments', verifyWebhook, async (req, res) => {
const event = req.webhookPayload;
res.status(200).json({ received: true });
await idempotentHandler(event.id, async () => {
await updateOrderStatus(event.data.orderId, 'paid');
await sendConfirmationEmail(event.data.customerEmail);
});
});Logging for Debugging
function webhookLogger(req, res, next) {
const startTime = Date.now();
const eventId = req.webhookPayload?.id || 'unknown';
console.log(JSON.stringify({
level: 'info',
type: 'webhook_received',
eventId,
eventType: req.webhookPayload?.type,
source: req.headers['x-webhook-source'],
timestamp: new Date().toISOString()
}));
res.on('finish', () => {
console.log(JSON.stringify({
level: res.statusCode >= 400 ? 'error' : 'info',
type: 'webhook_response',
eventId,
statusCode: res.statusCode,
durationMs: Date.now() - startTime
}));
});
next();
}
app.use('/webhooks', webhookLogger);Integrating with Hook Mesh
Hook Mesh simplifies sending and receiving. Push events to Hook Mesh for delivery and retries, or use our SDK for receiving:
Sending Webhooks with Hook Mesh
const { HookMesh } = require('@hookmesh/sdk');
const hookMesh = new HookMesh({ apiKey: process.env.HOOKMESH_API_KEY });
app.post('/api/orders', async (req, res) => {
const order = await createOrder(req.body);
res.status(201).json(order);
// Hook Mesh handles delivery, retries, and logging
await hookMesh.events.send({
type: 'order.created',
data: order,
customerId: req.user.organizationId
});
});Receiving with Hook Mesh Verification
const { verifyWebhook } = require('@hookmesh/sdk');
app.post('/webhooks/incoming',
express.raw({ type: 'application/json' }),
(req, res, next) => {
try {
req.webhookPayload = verifyWebhook(
req.body,
req.headers['x-hookmesh-signature'],
process.env.HOOKMESH_WEBHOOK_SECRET
);
next();
} catch (error) {
res.status(401).json({ error: 'Verification failed' });
}
},
async (req, res) => {
res.status(200).json({ received: true });
await processWebhook(req.webhookPayload);
}
);Summary
Key details for production: raw body parsing, fast responses with async processing, queue integration, and idempotency. Hook Mesh handles delivery reliability, retries, and signature management, letting you focus on product logic. Understanding these patterns ensures robust, secure integrations.
Related Posts
Building Webhooks with Next.js and Hook Mesh
Learn how to send and receive webhooks in Next.js 14+ using App Router conventions. Complete guide covering route handlers, signature verification, edge runtime, and Hook Mesh SDK integration.
Receive and Verify Webhooks in Node.js: A Complete Guide
Learn how to securely receive and verify webhooks in Node.js using Express.js. Covers HMAC signature verification, timestamp validation, replay attack prevention, and common mistakes to avoid.
Send Webhooks with Node.js: A Complete Tutorial
Learn how to send webhooks from your Node.js application. This hands-on tutorial covers payload structure, HMAC signatures, error handling, retry logic, and testing—with complete code examples using modern ES modules and fetch.
Webhook Security Best Practices: The Complete Guide
Learn how to secure your webhook implementations with HMAC signature verification, replay attack prevention, SSRF mitigation, and more. Includes code examples in Node.js and Python.
Webhook Idempotency: Why It Matters and How to Implement It
A comprehensive technical guide to implementing idempotency for webhooks. Learn about idempotency keys, deduplication strategies, and implementation patterns with Node.js and Python code examples.