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 powerful. In this technical deep dive, I'll walk through building a production-grade Stripe webhook handler in Node.js that's secure, scalable, and maintainable.
Understanding Stripe Webhook Architecture
Stripe webhooks operate on a push model - instead of polling their API, Stripe pushes events to your endpoint when important actions occur. The critical components:
- Event Object: JSON payload containing event metadata and relevant object data
- Signature Verification: HMAC-based security mechanism
- Idempotency: Handling duplicate events safely
Initial Setup
First, install required dependencies:
npm install stripe express body-parser crypto-js
Create a basic Express server:
const express = require('express');
const bodyParser = require('body-parser');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
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(`Webhook listening on port ${PORT}`));
Webhook Endpoint Implementation
Here's the core webhook handler:
app.post('/webhook', async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.rawBody,
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;
case 'customer.subscription.deleted':
await handleSubscriptionCancelled(event.data.object);
break;
// ... handle other event types
default:
console.log(`Unhandled event type ${event.type}`);
}
// Return a 200 response to acknowledge receipt of the event
res.json({ received: true });
});
Event Processing Functions
Let's implement robust handlers for common events:
Payment Intent Succeeded
async function handlePaymentIntentSucceeded(paymentIntent) {
try {
// Important: Implement idempotency
if (await checkIfProcessed(paymentIntent.id)) {
console.log(`Payment ${paymentIntent.id} already processed`);
return;
}
// Business logic here
await fulfillOrder(paymentIntent.metadata.orderId);
await updateAccountingSystem(paymentIntent.amount);
await sendConfirmationEmail(paymentIntent.receipt_email);
// Mark as processed
await recordProcessing(paymentIntent.id);
} catch (err) {
console.error(`Error processing payment ${paymentIntent.id}:`, err);
// Implement retry logic or dead letter queue
await queueForRetry(paymentIntent, err);
}
}
Subscription Cancellation
async function handleSubscriptionCancelled(subscription) {
const customer = await stripe.customers.retrieve(subscription.customer);
try {
await downgradeUserAccess(customer.metadata.userId);
await sendCancellationConfirmation(customer.email);
await scheduleDataRetention(subscription.id);
} catch (err) {
console.error(`Error handling cancelled subscription ${subscription.id}:`, err);
// Critical failures should alert the team
alertTeam(`Subscription cancellation failed for ${subscription.id}`);
}
}
Security Best Practices
Signature Verification
The most critical security measure is properly verifying the webhook signature:
function verifyStripeSignature(rawBody, signature) {
const crypto = require('crypto');
const secret = process.env.STRIPE_WEBHOOK_SECRET;
const hmac = crypto.createHmac('sha256', secret);
hmac.update(rawBody);
const calculatedSignature = `sha256=${hmac.digest('hex')}`;
return crypto.timingSafeEqual(
Buffer.from(calculatedSignature),
Buffer.from(signature)
);
}
Additional Security Measures
- IP Whitelisting: Only accept requests from Stripe's IP ranges
- Rate Limiting: Prevent abuse of your webhook endpoint
- Payload Validation: Validate all incoming data
Error Handling and Retry Logic
Stripe will retry failed webhook deliveries. Implement proper error handling:
const { MongoClient } = require('mongodb');
async function logWebhookAttempt(eventId, status, error = null) {
const client = new MongoClient(process.env.MONGODB_URI);
try {
await client.connect();
const db = client.db('webhooks');
await db.collection('delivery_attempts').insertOne({
eventId,
status,
error: error?.message,
timestamp: new Date(),
});
} finally {
await client.close();
}
}
Testing Webhooks Locally
Use the Stripe CLI for local testing:
stripe listen --forward-to localhost:3000/webhook
Trigger test events:
stripe trigger payment_intent.succeeded
Production Considerations
- Horizontal Scaling: Webhook handlers should be stateless
- Database Optimization: Index event processing logs
- Monitoring: Track processing times and failure rates
- Alerting: Set up alerts for critical failures
Complete Example
Here's a production-ready implementation:
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const { MongoClient } = require('mongodb');
const crypto = require('crypto');
const app = express();
// Middleware
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString();
}
}));
// Webhook handler
app.post('/webhook', async (req, res) => {
const sig = req.headers['stripe-signature'];
try {
const event = stripe.webhooks.constructEvent(
req.rawBody,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
await logWebhookAttempt(event.id, 'received');
// Process event
const processor = eventProcessors[event.type];
if (processor) {
await processor(event);
await logWebhookAttempt(event.id, 'processed');
} else {
await logWebhookAttempt(event.id, 'unhandled');
}
res.json({ received: true });
} catch (err) {
await logWebhookAttempt(
req.body?.id || 'unknown',
'failed',
err
);
res.status(400).send(`Webhook Error: ${err.message}`);
}
});
// Event processors
const eventProcessors = {
'payment_intent.succeeded': async (event) => {
const paymentIntent = event.data.object;
if (await isProcessed(paymentIntent.id)) return;
await fulfillOrder(paymentIntent);
await markAsProcessed(paymentIntent.id);
},
// Add other event handlers
};
// Database helpers
async function isProcessed(eventId) {
const client = new MongoClient(process.env.MONGODB_URI);
try {
await client.connect();
const count = await client.db('webhooks')
.collection('processed_events')
.countDocuments({ eventId });
return count > 0;
} finally {
await client.close();
}
}
app.listen(3000, () => console.log('Webhook server running'));
Performance Optimization
For high-volume applications:
- Batch Processing: Group similar events
- Worker Queues: Offload processing to background workers
- Connection Pooling: Reuse database connections
- Event Filtering: Only subscribe to needed events
Monitoring and Analytics
Track these key metrics:
- Delivery Success Rate
- Processing Time
- Event Volume by Type
- Error Rates
Conclusion
Building a robust Stripe webhook handler requires attention to:
- Security (signature verification)
- Reliability (idempotency, error handling)
- Performance (scalability)
- Maintainability (clean code structure)
The implementation shown handles all these aspects while remaining flexible enough to adapt to your specific business requirements. Remember to thoroughly test your webhook handler with Stripe's test events before going live.
🚀 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)