How I Built a Stripe Webhook in Node.js (Full Guide)
Webhooks are essential for modern payment processing systems, allowing real-time notifications about events in your Stripe integration. Here's my comprehensive guide to building a production-ready Stripe webhook handler in Node.js.
Understanding Stripe Webhook Architecture
Stripe webhooks use HTTP POST requests to notify your application about events. The critical components:
- Endpoint URL: Publicly accessible URL where Stripe sends events
- Event Object: JSON payload containing event details
- Signature Verification: HMAC signature for security validation
- Idempotency: Handling duplicate events safely
Initial Setup
First, install required dependencies:
npm install stripe express body-parser crypto
Create a basic Express server:
const express = require('express');
const bodyParser = require('body-parser');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const crypto = require('crypto');
const app = express();
// Middleware to parse raw body for signature verification
app.use(
bodyParser.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString();
},
})
);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
Webhook Endpoint Implementation
Here's the core webhook handler:
app.post('/webhook', async (req, res) => {
const sig = req.headers['stripe-signature'];
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
event = stripe.webhooks.constructEvent(req.rawBody, sig, webhookSecret);
} 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;
// Add more event types as needed
default:
console.log(`Unhandled event type ${event.type}`);
}
res.json({ received: true });
});
Event Processing Functions
Implement specific handlers for different event types:
async function handlePaymentIntentSucceeded(paymentIntent) {
console.log('PaymentIntent was successful!');
// Example: Update your database
try {
await updateOrderStatus(paymentIntent.metadata.orderId, 'paid');
await sendConfirmationEmail(paymentIntent.receipt_email);
} catch (err) {
console.error('Error processing payment success:', err);
// Implement retry logic here
}
}
async function handleChargeFailed(charge) {
console.log('Charge failed:', charge.id);
// Example: Notify customer and update records
try {
await updateOrderStatus(charge.metadata.orderId, 'payment_failed');
await sendPaymentFailedEmail(charge.billing_details.email);
} catch (err) {
console.error('Error processing failed charge:', err);
}
}
Advanced Security Considerations
1. Signature Verification
Stripe signs each webhook request with a timestamped HMAC SHA256 signature. The constructEvent method verifies:
- The signature matches your webhook secret
- The timestamp is recent (prevent replay attacks)
2. Idempotency Handling
Implement idempotency keys to prevent duplicate processing:
const processedEvents = new Set();
async function processEvent(event) {
if (processedEvents.has(event.id)) {
console.log(`Event ${event.id} already processed`);
return;
}
// Process event...
await handleEvent(event);
// Store event ID with TTL (e.g., 24 hours)
processedEvents.add(event.id);
setTimeout(() => processedEvents.delete(event.id), 86400000);
}
3. Rate Limiting and Retries
Stripe automatically retries failed webhook deliveries with exponential backoff. Your endpoint should:
- Process events quickly (<500ms response time)
- Implement proper error handling
- Return appropriate HTTP status codes:
- 200-299: Success
- 400-499: Permanent failure (won't retry)
- 500-599: Temporary failure (will retry)
Testing Webhooks Locally
Use the Stripe CLI to forward events to your local server:
stripe listen --forward-to localhost:3000/webhook
Trigger test events:
stripe trigger payment_intent.succeeded
Production Considerations
1. Webhook Secret Rotation
// Support multiple webhook secrets for rotation
const webhookSecrets = [
process.env.STRIPE_WEBHOOK_SECRET_CURRENT,
process.env.STRIPE_WEBHOOK_SECRET_OLD
];
let event;
for (const secret of webhookSecrets) {
try {
event = stripe.webhooks.constructEvent(req.rawBody, sig, secret);
break;
} catch (err) {
continue;
}
}
2. Queue Processing
For heavy workloads, use a queue system:
const { Worker } = require('bullmq');
// Add event to queue
await eventQueue.add(event.type, event, {
jobId: event.id, // Use event ID for deduplication
});
// Worker process
const worker = new Worker('stripe_events', async job => {
const event = job.data;
// Process event...
});
3. Monitoring and Logging
Implement comprehensive logging:
const { createLogger, transports } = require('winston');
const logger = createLogger({
transports: [
new transports.Console(),
new transports.File({ filename: 'webhook.log' })
]
});
// In your webhook handler
logger.info(`Received event: ${event.type}`, {
eventId: event.id,
livemode: event.livemode,
objectId: event.data.object.id
});
Complete Example
Here's a full implementation with all best practices:
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const bodyParser = require('body-parser');
const crypto = require('crypto');
const { Worker } = require('bullmq');
const app = express();
app.use(bodyParser.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));
// In-memory event tracking (replace with Redis in production)
const processedEvents = new Set();
// Queue setup
const eventQueue = new Queue('stripe_events', {
connection: { host: process.env.REDIS_HOST }
});
// Webhook endpoint
app.post('/webhook', async (req, res) => {
const sig = req.headers['stripe-signature'];
const secrets = [
process.env.STRIPE_WEBHOOK_SECRET_CURRENT,
process.env.STRIPE_WEBHOOK_SECRET_OLD
];
let event;
for (const secret of secrets) {
try {
event = stripe.webhooks.constructEvent(req.rawBody, sig, secret);
break;
} catch (err) {
continue;
}
}
if (!event) return res.status(400).send('Invalid signature');
// Check for duplicate events
if (processedEvents.has(event.id)) {
return res.status(200).send('Event already processed');
}
// Add to processing queue
await eventQueue.add(event.type, event, { jobId: event.id });
processedEvents.add(event.id);
res.status(200).send('Event received');
});
// Worker process
const worker = new Worker('stripe_events', async job => {
const event = job.data;
try {
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentIntentSucceeded(event.data.object);
break;
// Add other event handlers
}
} catch (err) {
console.error(`Error processing ${event.type}:`, err);
throw err; // Will trigger retry
}
});
worker.on('completed', job => {
console.log(`Processed ${job.name} (${job.id})`);
});
worker.on('failed', (job, err) => {
console.error(`Failed ${job.name} (${job.id}):`, err);
});
app.listen(3000, () => console.log('Webhook handler ready'));
Key Takeaways
- Always verify webhook signatures
- Implement idempotency to handle duplicate events
- Use queue systems for reliable processing
- Monitor and log all webhook activity
- Test thoroughly with Stripe's test events
This implementation provides a robust foundation for handling Stripe webhooks in production Node.js applications. Remember to adapt the queue system and event tracking to your specific infrastructure needs.
🚀 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)