How I Built a Stripe Webhook in Node.js (Full Guide)
Webhooks are essential for modern payment processing systems, allowing real-time notifications about payment events. In this technical deep dive, I'll walk through building a production-grade Stripe webhook handler in Node.js with proper security, error handling, and idempotency.
Understanding Stripe Webhook Architecture
Stripe webhooks use HTTP POST requests to notify your application about events like:
- Successful payments (
payment_intent.succeeded) - Failed payments (
payment_intent.payment_failed) - Subscription changes (
customer.subscription.updated)
The critical components we'll implement:
- Webhook endpoint verification
- Event processing with idempotency
- Error handling and retry logic
- Queue integration for async processing
Initial Setup
First, install required dependencies:
npm install stripe express body-parser crypto
Configure your Express server with the webhook route:
const express = require('express');
const bodyParser = require('body-parser');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const app = express();
// Use raw body for webhook verification
app.post('/webhook', bodyParser.raw({type: 'application/json'}), handleStripeWebhook);
app.listen(4242, () => console.log('Listening on port 4242'));
Webhook Verification
Security is paramount. We must verify the webhook signature to ensure requests come from Stripe:
async function handleStripeWebhook(req, res) {
const sig = req.headers['stripe-signature'];
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
} catch (err) {
console.error(`Webhook signature verification failed: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Process the verified event
await processEvent(event);
res.json({received: true});
}
Event Processing with Idempotency
Stripe may send duplicate events. Implement idempotency using event IDs:
const processedEvents = new Set();
async function processEvent(event) {
// Skip duplicate events
if (processedEvents.has(event.id)) {
console.log(`Event ${event.id} already processed`);
return;
}
try {
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data.object);
break;
case 'payment_intent.payment_failed':
await handlePaymentFailure(event.data.object);
break;
// Add more event types as needed
default:
console.log(`Unhandled event type: ${event.type}`);
}
// Mark event as processed
processedEvents.add(event.id);
} catch (error) {
console.error(`Error processing event ${event.id}:`, error);
throw error; // Stripe will retry
}
}
Handling Payment Events
Implement business logic for payment events:
async function handlePaymentSuccess(paymentIntent) {
console.log(`Payment succeeded for ${paymentIntent.amount} ${paymentIntent.currency}`);
// Example: Update order status in database
await updateOrderStatus(paymentIntent.metadata.orderId, 'paid');
// Example: Send confirmation email
await sendConfirmationEmail(paymentIntent.customer_email, {
amount: paymentIntent.amount / 100,
currency: paymentIntent.currency
});
}
async function handlePaymentFailure(paymentIntent) {
console.warn(`Payment failed for ${paymentIntent.id}`);
// Example: Notify customer
if (paymentIntent.last_payment_error?.message) {
await sendPaymentFailedEmail(
paymentIntent.customer_email,
paymentIntent.last_payment_error.message
);
}
}
Advanced: Queue Integration
For production workloads, use a queue system like Bull or SQS:
const Queue = require('bull');
const webhookQueue = new Queue('stripe-webhooks', {
redis: process.env.REDIS_URL
});
// Process jobs in worker process
webhookQueue.process(async (job) => {
const { event } = job.data;
await processEvent(event);
});
// Modified webhook handler to enqueue events
async function handleStripeWebhook(req, res) {
// ... verification code ...
// Add to queue instead of processing directly
await webhookQueue.add({
event,
timestamp: new Date()
});
res.json({received: true});
}
Error Handling and Retries
Implement proper error handling for production:
webhookQueue.on('failed', (job, err) => {
console.error(`Job ${job.id} failed:`, err);
if (job.attemptsMade < 3) {
console.log(`Retrying job ${job.id}`);
return true; // Will retry
}
// Permanent failure - alert and log
alertAdmin(`Webhook processing failed after retries: ${err.message}`);
logPermanentFailure(job.data.event);
});
// Exponential backoff configuration
webhookQueue.process(5, {
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000
}
});
Testing Webhooks Locally
Use the Stripe CLI for local testing:
stripe listen --forward-to localhost:4242/webhook
Trigger test events:
stripe trigger payment_intent.succeeded
Production Considerations
- Rate Limiting: Protect your endpoint from abuse
- Logging: Log all webhook events for audit purposes
- Monitoring: Set up alerts for failed webhooks
- Scaling: Use horizontal scaling for high-volume applications
Example rate limiting with Express:
const rateLimit = require('express-rate-limit');
const webhookLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // limit each IP to 100 requests per windowMs
});
app.post('/webhook', webhookLimiter, bodyParser.raw({type: 'application/json'}), handleStripeWebhook);
Complete Example
Here's the complete implementation:
require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const Queue = require('bull');
const app = express();
const processedEvents = new Set();
const webhookQueue = new Queue('stripe-webhooks', { redis: process.env.REDIS_URL });
// Queue processing
webhookQueue.process(async (job) => {
const { event } = job.data;
await processEvent(event);
});
webhookQueue.on('failed', (job, err) => {
console.error(`Job ${job.id} failed:`, err);
if (job.attemptsMade < 3) return true;
alertAdmin(`Webhook processing failed: ${err.message}`);
});
async function processEvent(event) {
if (processedEvents.has(event.id)) return;
try {
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data.object);
break;
// Add other event types...
default:
console.log(`Unhandled event: ${event.type}`);
}
processedEvents.add(event.id);
} catch (error) {
console.error(`Event processing error:`, error);
throw error;
}
}
app.post('/webhook',
bodyParser.raw({type: 'application/json'}),
async (req, res) => {
const sig = req.headers['stripe-signature'];
try {
const event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
await webhookQueue.add({ event });
res.json({received: true});
} catch (err) {
res.status(400).send(`Webhook Error: ${err.message}`);
}
}
);
app.listen(4242, () => console.log('Running on port 4242'));
Conclusion
This implementation provides:
- Secure webhook verification
- Idempotent event processing
- Queue-based async handling
- Comprehensive error recovery
- Production-ready architecture
Remember to:
- Keep your webhook secret secure
- Monitor your queue workers
- Test all event types thoroughly
- Implement proper logging
By following these patterns, you'll have a robust Stripe integration that can handle production workloads reliably.
🚀 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)