Handling Webhooks Reliably in Node.js: Patterns That Won't Lose Data
Battle-tested patterns for processing webhooks from Shopify, Stripe, WhatsApp, and other services without losing events or processing duplicates.
Why Webhooks Fail
Webhooks are the backbone of modern integrations. Shopify sends order events, Stripe sends payment updates, WhatsApp sends message notifications. But webhooks are inherently unreliable — your server might be down, your handler might crash, or the sender might retry causing duplicates.
After integrating webhooks from five different services across production applications, here are the patterns that prevent data loss.
Pattern 1: Respond First, Process Later
The most common mistake is processing the webhook synchronously before responding. If processing takes more than a few seconds, the sender times out and retries.
// BAD - Processing before responding
app.post('/webhooks/shopify', async (req, res) => {
await syncOrder(req.body); // Might take 5+ seconds
await updateInventory(req.body); // Might take 3+ seconds
res.sendStatus(200); // Too late, Shopify already retried
});
// GOOD - Respond immediately, process asynchronously
app.post('/webhooks/shopify', async (req, res) => {
res.sendStatus(200); // Respond within milliseconds
try {
await processWebhook(req.body);
} catch (err) {
console.error('Webhook processing failed:', err);
await queueForRetry(req.body);
}
});
Pattern 2: Idempotent Processing
Every webhook service retries on failure. Your handler must process the same event multiple times without side effects:
async function processWebhook(event) {
const eventId = event.id || generateEventId(event);
// Check if already processed
const existing = await db.query(
'SELECT id FROM webhook_events WHERE event_id = $1',
[eventId]
);
if (existing.rowCount > 0) {
console.log(`Skipping duplicate event: ${eventId}`);
return;
}
// Process and record atomically
await db.query('BEGIN');
try {
await db.query(
'INSERT INTO webhook_events (event_id, type, payload, processed_at) VALUES ($1, $2, $3, NOW())',
[eventId, event.type, JSON.stringify(event)]
);
await handleEvent(event);
await db.query('COMMIT');
} catch (err) {
await db.query('ROLLBACK');
throw err;
}
}
Pattern 3: Signature Verification
Always verify that webhooks actually come from the claimed sender:
Shopify HMAC Verification
const crypto = require('crypto');
function verifyShopifyWebhook(req) {
const hmac = req.headers['x-shopify-hmac-sha256'];
const hash = crypto
.createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET)
.update(req.rawBody)
.digest('base64');
return crypto.timingSafeEqual(
Buffer.from(hash),
Buffer.from(hmac)
);
}
Stripe Signature Verification
function verifyStripeWebhook(req) {
try {
return stripe.webhooks.constructEvent(
req.rawBody,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
throw new Error(`Webhook signature verification failed: ${err.message}`);
}
}
Getting the Raw Body
Signature verification needs the raw request body, but Express middleware (like express.json()) parses it. Capture both:
app.use('/webhooks', express.raw({
type: 'application/json',
verify: (req, res, buf) => {
req.rawBody = buf;
}
}));
Pattern 4: Dead Letter Queue
When webhook processing fails after retries, store it for manual review:
async function queueForRetry(event, error) {
await db.query(
`INSERT INTO webhook_dead_letters
(event_id, payload, error, attempts, created_at)
VALUES ($1, $2, $3, 1, NOW())`,
[event.id, JSON.stringify(event), error.message]
);
}
// Retry dead letters periodically
async function retryDeadLetters() {
const deadLetters = await db.query(
`SELECT * FROM webhook_dead_letters
WHERE attempts < 5 AND created_at > NOW() - INTERVAL '24 hours'
ORDER BY created_at ASC LIMIT 10`
);
for (const dl of deadLetters.rows) {
try {
await processWebhook(JSON.parse(dl.payload));
await db.query('DELETE FROM webhook_dead_letters WHERE id = $1', [dl.id]);
} catch (err) {
await db.query(
'UPDATE webhook_dead_letters SET attempts = attempts + 1, error = $2 WHERE id = $1',
[dl.id, err.message]
);
}
}
}
Pattern 5: Webhook Event Log
Log every incoming webhook for debugging:
app.use('/webhooks', (req, res, next) => {
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
path: req.path,
headers: {
'content-type': req.headers['content-type'],
'x-shopify-topic': req.headers['x-shopify-topic'],
'stripe-signature': req.headers['stripe-signature'] ? '[present]' : '[missing]'
},
bodySize: req.rawBody?.length || 0
}));
next();
});
Service-Specific Tips
| Service | Timeout | Retry Policy | ID Field |
|---------|---------|-------------|----------|
| Shopify | 5 seconds | Up to 19 retries over 48h | X-Shopify-Webhook-Id header |
| Stripe | 20 seconds | Up to 3 days | event.id |
| WhatsApp | 20 seconds | Exponential backoff | entry[0].id |
| Shopee | 10 seconds | 3 retries | Request body hash |
Monitoring
Set up alerts for:
- Webhook handler response time exceeding 1 second
- Dead letter queue size exceeding threshold
- Signature verification failures (potential attacks)
- Missing expected webhooks (sender-side issues)
Conclusion
Reliable webhook handling isn't glamorous, but it's the foundation of every integration. Respond immediately, verify signatures, process idempotently, and log everything. These patterns have prevented data loss across thousands of webhook events in production.