How I Built a Stripe Webhook in Node.js (Full Guide)
Webhooks are essential for modern payment processing systems, and Stripe's implementation is particularly elegant. In this deep dive, I'll walk through building a production-grade Stripe webhook handler in Node.js with proper security, error handling, and queue processing.
Understanding Stripe Webhook Architecture
Stripe webhooks operate via HTTP POST requests to your endpoint when events occur in your Stripe account. The critical components:
- Event Object: JSON payload containing event metadata and relevant object data
- Signature Verification: HMAC-based security using your webhook secret
- Idempotency: Handling duplicate events safely
- Retry Logic: Built-in exponential backoff from Stripe
Initial Setup
First, install the Stripe Node.js library:
npm install stripe @types/stripe
Configure your environment variables (I recommend using dotenv):
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
Core Webhook Handler Implementation
Here's the foundation of our webhook handler:
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const bodyParser = require('body-parser');
const app = express();
// Stripe requires raw body for signature verification
app.post('/webhook', bodyParser.raw({type: 'application/json'}), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error(`Webhook signature verification failed: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Handle the event
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentIntentSucceeded(event.data.object);
break;
case 'charge.failed':
await handleChargeFailed(event.data.object);
break;
// ... handle other event types
default:
console.log(`Unhandled event type ${event.type}`);
}
// Acknowledge receipt
res.json({received: true});
});
Advanced Security Considerations
Replay Attack Protection
Stripe includes a timestamp in the signature header. We should verify it's recent:
const MAX_TOLERANCE_SECONDS = 300; // 5 minutes
function verifyTimestamp(sig) {
const [, timestamp] = sig.split(',')[0].split('=');
const timeDiff = Math.floor(Date.now() / 1000) - parseInt(timestamp);
return timeDiff <= MAX_TOLERANCE_SECONDS;
}
Secret Rotation
Implement webhook secret rotation:
async function getWebhookSecret(version = 'latest') {
// In production, fetch from secure storage
const secrets = {
'v1': process.env.STRIPE_WEBHOOK_SECRET_V1,
'v2': process.env.STRIPE_WEBHOOK_SECRET_V2,
'latest': process.env.STRIPE_WEBHOOK_SECRET
};
return secrets[version];
}
// Modified verification:
try {
const secret = await getWebhookSecret();
event = stripe.webhooks.constructEvent(req.body, sig, secret);
} catch (err) {
// Try with previous version if latest fails
try {
const secret = await getWebhookSecret('v1');
event = stripe.webhooks.constructEvent(req.body, sig, secret);
} catch (err2) {
throw err;
}
}
Event Processing Architecture
For production workloads, you'll want to:
- Validate the event
- Push to a queue
- Process asynchronously
Here's a Redis-based queue implementation:
const { createClient } = require('redis');
const redisClient = createClient({ url: process.env.REDIS_URL });
async function enqueueStripeEvent(event) {
await redisClient.connect();
try {
await redisClient.lPush(
'stripe_events',
JSON.stringify({
id: event.id,
type: event.type,
data: event.data,
created: event.created
})
);
} finally {
await redisClient.disconnect();
}
}
// Worker process example
async function processStripeEvents() {
await redisClient.connect();
while (true) {
const eventStr = await redisClient.rPop('stripe_events');
if (!eventStr) {
await new Promise(resolve => setTimeout(resolve, 1000));
continue;
}
const event = JSON.parse(eventStr);
try {
await handleEvent(event);
} catch (err) {
console.error(`Failed processing event ${event.id}:`, err);
// Implement retry logic here
}
}
}
Idempotency Handling
Stripe events can be delivered multiple times. Implement idempotency:
const { MongoClient } = require('mongodb');
const client = new MongoClient(process.env.MONGODB_URI);
async function ensureIdempotency(eventId, handler) {
await client.connect();
const db = client.db('stripe_webhooks');
const collection = db.collection('processed_events');
const existing = await collection.findOne({ eventId });
if (existing) {
console.log(`Event ${eventId} already processed`);
return existing.result;
}
try {
const result = await handler();
await collection.insertOne({
eventId,
processedAt: new Date(),
result
});
return result;
} catch (err) {
await collection.insertOne({
eventId,
processedAt: new Date(),
error: err.message
});
throw err;
}
}
// Usage:
await ensureIdempotency(event.id, () => handlePaymentIntentSucceeded(event.data.object));
Testing Webhooks Locally
Use the Stripe CLI for local testing:
stripe listen --forward-to localhost:3000/webhook
For automated tests, mock the signature header:
const crypto = require('crypto');
function generateTestSignature(payload, secret) {
const timestamp = Math.floor(Date.now() / 1000);
const signedPayload = `${timestamp}.${payload}`;
const signature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
return `t=${timestamp},v1=${signature}`;
}
Production Deployment Considerations
- HTTPS: Mandatory for production webhooks
- Rate Limiting: Protect your endpoint
- Scaling: Use Kubernetes or similar for worker processes
- Monitoring: Track event processing metrics
Example monitoring setup:
const Prometheus = require('prom-client');
const eventCounter = new Prometheus.Counter({
name: 'stripe_events_total',
help: 'Count of Stripe webhook events',
labelNames: ['type', 'status']
});
// In your handler:
eventCounter.inc({ type: event.type, status: 'received' });
try {
await handleEvent(event);
eventCounter.inc({ type: event.type, status: 'processed' });
} catch (err) {
eventCounter.inc({ type: event.type, status: 'failed' });
throw err;
}
Complete Production Example
Here's a consolidated production-ready version:
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const { createClient } = require('redis');
const { MongoClient } = require('mongodb');
const crypto = require('crypto');
const bodyParser = require('body-parser');
const app = express();
const redisClient = createClient({ url: process.env.REDIS_URL });
const mongoClient = new MongoClient(process.env.MONGODB_URI);
// Middleware to verify Stripe signature
async function verifyStripeWebhook(req, res, next) {
const sig = req.headers['stripe-signature'];
if (!sig) return res.status(401).send('No signature provided');
try {
const secret = await getWebhookSecret();
req.stripeEvent = stripe.webhooks.constructEvent(
req.body,
sig,
secret
);
next();
} catch (err) {
console.error('Signature verification failed:', err);
return res.status(401).send('Invalid signature');
}
}
// Main webhook handler
app.post(
'/webhook',
bodyParser.raw({ type: 'application/json' }),
verifyStripeWebhook,
async (req, res) => {
try {
await enqueueStripeEvent(req.stripeEvent);
res.json({ received: true });
} catch (err) {
console.error('Event processing failed:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
// Start workers
async function startWorkers(count = 3) {
await redisClient.connect();
await mongoClient.connect();
for (let i = 0; i < count; i++) {
processStripeEvents().catch(err => {
console.error(`Worker ${i} failed:`, err);
});
}
}
startWorkers();
app.listen(3000, () => {
console.log('Webhook handler listening on port 3000');
});
Final Thoughts
Building a robust Stripe webhook handler requires attention to:
- Security: Proper signature verification
- Reliability: Queue processing and idempotency
- Observability: Monitoring and logging
- Scalability: Horizontal scaling for high-volume events
The implementation above addresses all these concerns while maintaining clean, maintainable code. Remember to adjust queue processing and worker counts based on your actual event volume.
For further optimization, consider:
- Event batching for high-volume scenarios
- Dead-letter queues for failed events
- Circuit breakers for downstream service failures
- Canary deployments for webhook handler updates
🚀 Stop Writing Boilerplate Prompts
If you want to skip the setup and code 10x faster with complete AI architecture patterns, grab my Senior React Developer AI Cookbook ($19). It includes Server Action prompt libraries, UI component generation loops, and hydration debugging strategies.
Browse all 10+ developer products at the Apollo AI Store | Or snipe Solana tokens free via @ApolloSniper_Bot.
Top comments (0)