Building a robust payment system is crucial for any SaaS platform. In this comprehensive guide, I’ll walk you through implementing Stripe payment integration with subscription management and secure webhook handling - knowledge gained from building real estate analytics platforms with complex billing requirements.
Architecture Overview
A production-ready Stripe integration involves several key components:
- Payment Intent Creation for secure payment processing
- Subscription Management for recurring billing
- Webhook Handling for real-time payment status updates
- Idempotency to prevent duplicate transactions
- Error Handling and retry mechanisms
Setting Up Stripe with Node.js
First, install the necessary dependencies:
npm install stripe express dotenv crypto
Create your Stripe service:
// services/stripe.service.js
const Stripe = require("stripe");
const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
class StripeService {
async createCustomer(email, name, metadata = {}) {
try {
const customer = await stripe.customers.create({
email,
name,
metadata,
});
return customer;
} catch (error) {
throw new Error(`Failed to create customer: ${error.message}`);
}
}
async createSubscription(customerId, priceId, trialDays = 0) {
try {
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
trial_period_days: trialDays,
expand: ["latest_invoice.payment_intent"],
metadata: {
created_at: new Date().toISOString(),
},
});
return subscription;
} catch (error) {
throw new Error(`Failed to create subscription: ${error.message}`);
}
}
async updateSubscription(subscriptionId, newPriceId) {
try {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const updatedSubscription = await stripe.subscriptions.update(
subscriptionId,
{
items: [
{
id: subscription.items.data[0].id,
price: newPriceId,
},
],
proration_behavior: "create_prorations",
},
);
return updatedSubscription;
} catch (error) {
throw new Error(`Failed to update subscription: ${error.message}`);
}
}
async cancelSubscription(subscriptionId, cancelAtPeriodEnd = true) {
try {
const subscription = await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: cancelAtPeriodEnd,
});
return subscription;
} catch (error) {
throw new Error(`Failed to cancel subscription: ${error.message}`);
}
}
}
module.exports = new StripeService();
Implementing Secure Webhook Handling
Webhooks are critical for maintaining data consistency. Here’s how to implement them securely:
// routes/webhooks.js
const express = require("express");
const crypto = require("crypto");
const stripeService = require("../services/stripe.service");
const User = require("../models/User");
const router = express.Router();
// Middleware to verify webhook signature
const verifyWebhookSignature = (req, res, next) => {
const signature = req.headers["stripe-signature"];
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
event = stripe.webhooks.constructEvent(req.body, signature, endpointSecret);
req.stripeEvent = event;
next();
} catch (err) {
console.error("Webhook signature verification failed:", err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
};
// Raw body parser for webhook verification
router.use(
"/stripe",
express.raw({ type: "application/json" }),
verifyWebhookSignature,
);
router.post("/stripe", async (req, res) => {
const event = req.stripeEvent;
try {
switch (event.type) {
case "customer.subscription.created":
await handleSubscriptionCreated(event.data.object);
break;
case "customer.subscription.updated":
await handleSubscriptionUpdated(event.data.object);
break;
case "customer.subscription.deleted":
await handleSubscriptionDeleted(event.data.object);
break;
case "invoice.payment_succeeded":
await handlePaymentSucceeded(event.data.object);
break;
case "invoice.payment_failed":
await handlePaymentFailed(event.data.object);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
res.status(200).json({ received: true });
} catch (error) {
console.error("Webhook handler error:", error);
res.status(500).json({ error: "Webhook handler failed" });
}
});
async function handleSubscriptionCreated(subscription) {
const customerId = subscription.customer;
const user = await User.findOne({ stripeCustomerId: customerId });
if (user) {
user.subscriptionId = subscription.id;
user.subscriptionStatus = subscription.status;
user.currentPeriodEnd = new Date(subscription.current_period_end * 1000);
await user.save();
// Send welcome email
await sendWelcomeEmail(user.email);
}
}
async function handlePaymentSucceeded(invoice) {
const subscriptionId = invoice.subscription;
const user = await User.findOne({ subscriptionId });
if (user) {
user.subscriptionStatus = "active";
user.currentPeriodEnd = new Date(invoice.period_end * 1000);
await user.save();
// Log payment for analytics
await logPaymentEvent(user.id, invoice.amount_paid, "succeeded");
}
}
async function handlePaymentFailed(invoice) {
const subscriptionId = invoice.subscription;
const user = await User.findOne({ subscriptionId });
if (user) {
user.subscriptionStatus = "past_due";
await user.save();
// Send payment failure notification
await sendPaymentFailureEmail(user.email, invoice.hosted_invoice_url);
}
}
module.exports = router;
Implementing Idempotent Operations
To prevent duplicate charges and maintain data consistency:
// middleware/idempotency.js
const redis = require("redis");
const client = redis.createClient();
const idempotencyMiddleware = (ttl = 3600) => {
return async (req, res, next) => {
const idempotencyKey = req.headers["idempotency-key"];
if (!idempotencyKey) {
return res.status(400).json({ error: "Idempotency-Key header required" });
}
try {
const cached = await client.get(`idempotency:${idempotencyKey}`);
if (cached) {
return res.json(JSON.parse(cached));
}
// Store response after successful operation
const originalSend = res.json;
res.json = function (data) {
if (res.statusCode < 400) {
client.setex(
`idempotency:${idempotencyKey}`,
ttl,
JSON.stringify(data),
);
}
originalSend.call(this, data);
};
next();
} catch (error) {
console.error("Idempotency check failed:", error);
next();
}
};
};
module.exports = idempotencyMiddleware;
Production Considerations
Error Handling and Retry Logic
const retryPaymentWithExponentialBackoff = async (
operation,
maxRetries = 3,
) => {
for (let i = 0; i < maxRetries; i++) {
try {
return await operation();
} catch (error) {
if (i === maxRetries - 1) throw error;
const delay = Math.pow(2, i) * 1000; // Exponential backoff
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
};
Monitoring and Alerting
// Monitor failed payments and subscription churns
const monitorPaymentMetrics = async () => {
const failedPayments = await getFailedPaymentsCount();
const churnRate = await calculateChurnRate();
if (failedPayments > FAILED_PAYMENT_THRESHOLD) {
await sendAlert("High failed payment rate detected");
}
if (churnRate > CHURN_RATE_THRESHOLD) {
await sendAlert("Churn rate exceeding threshold");
}
};
Testing Your Integration
Always test your webhook endpoints thoroughly:
// Use Stripe CLI for local testing
// stripe listen --forward-to localhost:3000/webhooks/stripe
// Test with different scenarios
const testScenarios = [
"customer.subscription.created",
"invoice.payment_succeeded",
"invoice.payment_failed",
"customer.subscription.deleted",
];
Key Takeaways
- Always verify webhook signatures to ensure security
- Implement idempotency to prevent duplicate operations
- Handle edge cases like failed payments and subscription changes
- Monitor your payment metrics for business insights
- Test thoroughly with different payment scenarios
This approach has proven reliable in production environments handling thousands of transactions. The key is building robust error handling and monitoring from day one, not as an afterthought.
Remember: Payment systems are critical infrastructure. Invest time in getting them right, and your users (and your business) will thank you.