Receiving Webhooks Safely in Node.js (Stripe, GitHub, and Everything Else)
Signature verification, idempotent handlers, and async processing — the pattern that makes duplicate deliveries a non-issue
Senior Developer

Webhooks look simple until production happens. You wire up an endpoint, it works in staging, and then real traffic arrives: retries, duplicate deliveries, out-of-order events, and occasionally the same payment processed twice because Stripe retried after a 30-second timeout and your handler was slow.
This guide covers the right way to receive webhooks — verified, idempotent, and processed asynchronously — so your endpoint is bulletproof regardless of which provider is sending.
The Four Rules Before Any Code
1. Verify every signature. Never process a webhook payload without verifying it came from who it claims. Any unsigned webhook should return 400 and be dropped.
2. Return 200 immediately. Providers time out fast — Stripe times out at 30 seconds and retries up to 72 hours. If your handler is slow, the provider retries, you get duplicate deliveries, and chaos follows. Acknowledge first, process in the background.
3. Make every handler idempotent. Webhooks are delivered at-least-once, not exactly-once. The same event will arrive more than once. Your handler must produce the same result whether it runs once or five times.
4. Store raw body before parsing. Signature verification requires the exact raw bytes as received. Any middleware that parses the body first will break verification. Always access req.rawBody for signature checks.
Setup
npm install stripe @octokit/webhooks bullmq ioredis// src/app.js
// CRITICAL: rawBody must be captured before any JSON parsing
app.use('/webhooks', express.raw({ type: 'application/json' }));
// All other routes get normal JSON parsing
app.use(express.json());Pattern 1 — Stripe Webhooks
// src/routes/webhooks/stripe.js
import Stripe from 'stripe';
import { webhookQueue } from '../../queues/index.js';
import db from '../../lib/db.js';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
router.post('/webhooks/stripe', async (req, res) => {
const signature = req.headers['stripe-signature'];
// Step 1 — Verify the signature
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // Raw bytes — not parsed JSON
signature,
webhookSecret
);
} catch (err) {
req.log?.warn({ error: err.message }, 'Stripe webhook signature invalid');
return res.status(400).json({ error: 'Invalid signature' });
}
// Step 2 — Deduplicate using the event ID
// Stripe guarantees event IDs are unique — store them to detect replays
try {
await db.query(`
INSERT INTO processed_webhooks (event_id, source, received_at)
VALUES ($1, 'stripe', NOW())
ON CONFLICT (event_id) DO NOTHING
`, [event.id]);
} catch (err) {
// If insert fails due to conflict, this is a duplicate — acknowledge and skip
req.log?.info({ eventId: event.id }, 'Duplicate Stripe webhook, skipping');
return res.status(200).json({ received: true, duplicate: true });
}
// Step 3 — Acknowledge immediately, process in background
res.status(200).json({ received: true });
// Step 4 — Queue the actual processing
await webhookQueue.add('stripe-event', {
eventId: event.id,
type: event.type,
data: event.data,
}, {
jobId: `stripe-${event.id}`, // Prevents duplicate jobs in the queue too
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
});
});// src/workers/webhook.worker.js
const stripeHandlers = {
'payment_intent.succeeded': async (data) => {
const { object: paymentIntent } = data;
await db.query(`
UPDATE orders
SET status = 'paid', paid_at = NOW(), stripe_payment_id = $1
WHERE stripe_payment_intent_id = $2
AND status != 'paid' -- Idempotent: skip if already paid
`, [paymentIntent.id, paymentIntent.id]);
const order = await db.query(
'SELECT * FROM orders WHERE stripe_payment_intent_id = $1',
[paymentIntent.id]
);
if (order.rows[0]) {
await emailQueue.add('order-confirmation', {
orderId: order.rows[0].id,
userId: order.rows[0].user_id,
}, {
jobId: `order-confirmation-${order.rows[0].id}`, // No duplicate emails
});
}
},
'payment_intent.payment_failed': async (data) => {
const { object: paymentIntent } = data;
await db.query(`
UPDATE orders SET status = 'payment_failed'
WHERE stripe_payment_intent_id = $1 AND status = 'pending'
`, [paymentIntent.id]);
},
'customer.subscription.deleted': async (data) => {
const { object: subscription } = data;
await db.query(`
UPDATE users SET plan = 'free', subscription_ends_at = NOW()
WHERE stripe_customer_id = $1
`, [subscription.customer]);
},
};
const webhookWorker = new Worker('webhooks', async (job) => {
if (job.name === 'stripe-event') {
const { type, data } = job.data;
const handler = stripeHandlers[type];
if (handler) {
await handler(data);
} else {
// Log unhandled event types for future implementation
logger.info({ eventType: type }, 'Unhandled Stripe event type');
}
}
}, { connection: bullMQConnection });Pattern 2 — GitHub Webhooks
// src/routes/webhooks/github.js
import { createHmac, timingSafeEqual } from 'crypto';
const GITHUB_WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET;
function verifyGitHubSignature(payload, signature) {
if (!signature) return false;
const hmac = createHmac('sha256', GITHUB_WEBHOOK_SECRET);
hmac.update(payload);
const expectedSignature = `sha256=${hmac.digest('hex')}`;
// timingSafeEqual prevents timing attacks
try {
return timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
} catch {
return false; // Buffers different lengths — invalid signature
}
}
router.post('/webhooks/github', async (req, res) => {
const signature = req.headers['x-hub-signature-256'];
const event = req.headers['x-github-event'];
const deliveryId = req.headers['x-github-delivery'];
if (!verifyGitHubSignature(req.body, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Acknowledge immediately
res.status(200).json({ received: true });
const payload = JSON.parse(req.body.toString());
// Queue by delivery ID to prevent duplicate processing
await webhookQueue.add('github-event', {
event,
deliveryId,
payload,
}, {
jobId: `github-${deliveryId}`,
});
});Pattern 3 — Generic Webhook Receiver (Custom HMAC)
If you are building a service that sends webhooks to your customers, or receiving from a provider that uses standard HMAC-SHA256:
// src/lib/webhookVerifier.js
import { createHmac, timingSafeEqual } from 'crypto';
export function verifyHmacSignature({
payload, // Raw request body (Buffer)
signature, // Signature from header
secret, // Shared secret
algorithm = 'sha256',
prefix = '', // Some providers prefix with "sha256="
}) {
const hmac = createHmac(algorithm, secret);
hmac.update(payload);
const expected = `${prefix}${hmac.digest('hex')}`;
try {
return timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
} catch {
return false;
}
}The Idempotency Database Table
CREATE TABLE processed_webhooks (
event_id TEXT NOT NULL,
source TEXT NOT NULL, -- 'stripe', 'github', etc.
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (event_id, source) -- Composite PK handles same ID from different sources
);
-- Automatically clean up old records after 30 days
-- Add to a cron job:
-- DELETE FROM processed_webhooks WHERE received_at < NOW() - INTERVAL '30 days';
CREATE INDEX idx_processed_webhooks_received ON processed_webhooks(received_at);Testing Webhooks Locally
Use the Stripe CLI to forward real webhook events to your local server:
# Install Stripe CLI, then:
stripe listen --forward-to localhost:3000/webhooks/stripe
# Trigger a test event
stripe trigger payment_intent.succeededFor GitHub, use smee.io or ngrok to expose your local server:
# ngrok
ngrok http 3000
# Then set your GitHub webhook URL to:
# https://your-ngrok-url.ngrok.io/webhooks/githubThe Checklist
Every webhook endpoint should pass all of these:
✅ Signature verified before any processing
✅ Returns 200 within 2 seconds
✅ Event ID stored in DB to detect duplicates
✅ Actual processing happens in a background worker
✅ All DB updates use ON CONFLICT DO NOTHING or equivalent
✅ Emails and notifications use jobId deduplication
✅ Raw body captured before JSON parsing
✅ timingSafeEqual used for signature comparison (not ===)
✅ Unhandled event types logged, not erroredWebhooks that follow this pattern are indistinguishable from exactly-once delivery to the rest of your system — even when the provider retries 10 times.
Comments (0)
Login to post a comment.