As developers integrating Stripe into our applications, we often encounter challenges that aren’t immediately obvious from the documentation. Today, I want to share a recent insight sparked by a problem Pieter Levels encountered with Stripe webhooks, and present a comprehensive solution that you can implement in your own projects.
This article is part of a series on Stripe integration with Next.js:
- Stripe Checkout in Next.js: Covers basic Stripe Checkout integration and webhook setup
- Stripe Subscriptions in Next.js: Explores subscription implementation and management
- Current Article: Deep dive into solving webhook race conditions and implementing robust credit management
While the previous articles cover the basics of Stripe integration, this article focuses on solving more advanced challenges that emerge when handling webhook events at scale, particularly around race conditions in credit management systems.
Crazy bug on Photo AI today, and took hours to solve it:
— @levelsio (@levelsio) November 4, 2024
> invoice.paid - No Photo AI user in db exists with Stripe customer ID: cus_12345678
Stripe webhooks or payments (?) seem to have increased in speed so much now that it's started causing problems for me for about 1% of… pic.twitter.com/TlpATADtoN
The Race Condition Problem
Recently, Pieter shared an interesting bug he encountered in his Photo AI application. The issue? A race condition in his Stripe webhook handling that caused about 1% of new signups to fail. Here’s what was happening:
// Traditional approach - prone to race conditions
// Webhook 1: customer.subscription.created
app.post('/webhook/subscription-created', async (req, res) => {
const event = req.body;
const customerId = event.data.object.customer;
// Create user account
await db.createUser({
stripeCustomerId: customerId,
// other user details...
});
res.status(200).send();
});
// Webhook 2: invoice.paid
app.post('/webhook/invoice-paid', async (req, res) => {
const event = req.body;
const customerId = event.data.object.customer;
// Try to find user and add credits
const user = await db.getUserByStripeId(customerId);
if (!user) {
// 💥 This is where it fails!
throw new Error(`No user found for customer: ${customerId}`);
}
await addCreditsToUser(user.id);
res.status(200).send();
});
The problem occurs because:
- When a customer subscribes, Stripe sends
customer.subscription.created
- Almost simultaneously, it sends
invoice.paid
when the payment succeeds - Sometimes,
invoice.paid
arrives BEFORE the user creation fromcustomer.subscription.created
completes - Result: “No user found” error because we’re trying to add credits for a user that doesn’t exist yet
Core Concepts for Robust Stripe Integration
Before diving into the solution, let’s understand three critical concepts:
Idempotency
Webhook events may be delivered multiple times due to network issues or Stripe’s retry mechanism. Your webhook handlers must be idempotent - processing the same event multiple times should have the same result as processing it once. This means:
- Tracking processed events
- Using database transactions
- Handling duplicate events gracefully
Defensive Programming
When handling payments and credits, always:
- Verify the state before operations
- Use database transactions for atomic operations
- Implement proper retry mechanisms
- Handle all possible edge cases
Single Source of Truth
Instead of relying on multiple webhooks, use invoice.paid
as your single source of truth because:
- It only fires when payment is confirmed
- It contains all necessary subscription information
- It provides a reliable trigger for user creation and credit allocation
The Solution: Building a Robust Stripe Manager
Here’s our solution that incorporates these concepts:
class StripeManager {
constructor(config) {
this.stripe = new Stripe(config.secretKey);
this.db = config.database;
this.creditRules = config.creditRules;
}
async handleInvoicePaid(event) {
// Check for duplicate processing
const existingEvent = await this.db.findProcessedEvent(event.id);
if (existingEvent) {
console.log(`Event ${event.id} already processed, skipping`);
return;
}
const invoice = event.data.object;
const customerId = invoice.customer;
// Use transaction for atomicity
await this.db.transaction(async (trx) => {
const user = await this.retryGetOrCreateUser(customerId, trx);
await this.processCredits(user, invoice, trx);
await this.recordProcessedEvent(event.id, trx);
});
}
async retryGetOrCreateUser(customerId, attempts = 3) {
try {
let user = await this.db.getUserByStripeId(customerId);
if (!user) {
const customer = await this.stripe.customers.retrieve(customerId);
user = await this.db.createUser({
email: customer.email,
stripeCustomerId: customerId,
name: customer.name
});
}
return user;
} catch (error) {
if (attempts > 1) {
await new Promise(resolve => setTimeout(resolve, 1000));
return this.retryGetOrCreateUser(customerId, attempts - 1);
}
throw error;
}
}
}
Advanced Billing Scenarios
Real-world subscription systems need to handle complex billing scenarios. Let’s extend our manager to handle these cases:
Plan Changes and Credit Management
When users change plans, we need to handle credits carefully:
-
Upgrades (e.g., Basic → Premium):
- Process immediately
- Calculate prorated credits
- Add credits right away
-
Downgrades (e.g., Premium → Basic):
- Schedule for end of billing period
- Handle remaining credits
- Consider usage limits
Here’s how we implement this:
class StripeManager {
async handleSubscriptionUpdated(subscription) {
const oldPlan = subscription.items.data[0].price.id;
const newPlan = subscription.items.data[0].price.id;
if (oldPlan !== newPlan) {
const isUpgrade = this.creditRules[newPlan].credits >
this.creditRules[oldPlan].credits;
if (isUpgrade) {
await this.handlePlanUpgrade(subscription);
} else {
await this.schedulePlanDowngrade(
subscription,
subscription.current_period_end
);
}
}
}
async handlePlanUpgrade(subscription) {
const user = await this.db.getUserByStripeId(subscription.customer);
const remainingDays = this.calculateRemainingDays(subscription);
const additionalCredits = this.calculateUpgradeCredits(
subscription.items.data[0].price.id,
subscription.items.data[1].price.id,
remainingDays
);
await this.addCreditsToUser(user.id, additionalCredits, {
source: 'plan_upgrade',
subscriptionId: subscription.id
});
}
}
Database Schema for Billing Management
To support these features, we need a robust database schema:
// Events tracking (for idempotency)
{
id: 'uuid',
stripeEventId: 'string',
type: 'string',
processedAt: 'timestamp'
}
// Credit transactions
{
id: 'uuid',
userId: 'uuid',
amount: 'number',
type: 'string', // 'initial', 'recurring', 'upgrade', etc.
metadata: {
source: 'string',
invoiceId: 'string',
subscriptionId: 'string'
},
createdAt: 'timestamp'
}
// Scheduled actions (for downgrades)
{
id: 'uuid',
type: 'string',
userId: 'uuid',
scheduledFor: 'timestamp',
metadata: 'jsonb',
status: 'string'
}
Processing Scheduled Actions with Workers
To execute scheduled actions like plan downgrades, you’ll need a background worker. Here’s how to implement it:
// worker.js
async function processScheduledActions() {
const actions = await db.getScheduledActionsDue();
for (const action of actions) {
try {
if (action.type === 'plan_downgrade') {
await stripeManager.executePlanDowngrade(action);
}
await db.markActionComplete(action.id);
} catch (error) {
console.error(`Failed to process action ${action.id}:`, error);
await db.markActionFailed(action.id, error.message);
}
}
}
// Using node-cron to run every minute
cron.schedule('* * * * *', processScheduledActions);
Implementation and Usage
Let’s see how to implement this solution in a real application:
- First, initialize the Stripe Manager with your configuration:
const stripeManager = new StripeManager({
secretKey: process.env.STRIPE_SECRET_KEY,
database: db,
creditRules: {
'price_monthly': {
initialCredits: 100,
recurringCredits: 100,
overage: 'block' // Block when credits exhausted
},
'price_yearly': {
initialCredits: 1200,
recurringCredits: 1200,
overage: 'metered', // Charge for additional usage
overagePrice: 0.10 // $0.10 per credit
}
}
});
- Set up your webhook endpoint:
app.post('/stripe/webhook', async (req, res) => {
try {
const event = stripe.webhooks.constructEvent(
req.rawBody,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
switch (event.type) {
case 'invoice.paid':
await stripeManager.handleInvoicePaid(event);
break;
case 'customer.subscription.updated':
await stripeManager.handleSubscriptionUpdated(event.data.object);
break;
case 'charge.refunded':
await stripeManager.handleRefund(event.data.object);
break;
}
res.status(200).send();
} catch (error) {
console.error('Webhook error:', error);
res.status(400).send(error.message);
}
});
- Track credit usage in your application:
// In your API endpoints where credits are consumed:
app.post('/api/use-credits', async (req, res) => {
const { userId, amount } = req.body;
try {
await stripeManager.useCredits(userId, amount);
res.status(200).send({ success: true });
} catch (error) {
if (error.code === 'INSUFFICIENT_CREDITS') {
res.status(402).send({ error: 'Insufficient credits' });
} else {
res.status(500).send({ error: 'Internal server error' });
}
}
});
Handling Edge Cases
-
Failed Payments: No credits are added because
invoice.paid
won’t fire. -
Subscription Changes: Handle in
customer.subscription.updated
:
async handleSubscriptionUpdated(subscription) {
// Handle plan changes, upgrades/downgrades
const oldPriceId = subscription.items.data[0].price.id;
const newPriceId = subscription.items.data[0].price.id;
if (oldPriceId !== newPriceId) {
// Calculate and adjust credits based on plan change
const creditAdjustment = this.calculatePlanChangeCredits(
oldPriceId,
newPriceId
);
await this.adjustUserCredits(subscription.customer, creditAdjustment);
}
}
- Refunds: Handle in
charge.refunded
:
async handleRefund(charge) {
// Optionally reverse credits or mark as refunded
await this.handleCreditRefund(charge.customer, charge.amount);
}
Stripe Customer Portal vs Custom Implementation
While Stripe’s Customer Portal provides a quick way to handle subscriptions, complex billing scenarios often require custom solutions. Let’s compare both approaches:
Feature | Stripe Portal | Custom Implementation |
---|---|---|
Basic Subscription Management | ||
Plan Switching | ✅ Basic proration | ✅ Custom proration rules |
Payment Methods | ✅ Built-in UI | ✅ Custom UI needed |
Cancel/Reactivate | ✅ Simple flows | ✅ Custom flows & rules |
Credit Management | ||
Credit Tracking | ❌ Not available | ✅ Full control |
Credit Sharing | ❌ Not available | ✅ Team/pool credits |
Custom Credit Rules | ❌ Not available | ✅ Complex allocation |
Billing Scenarios | ||
Plan Upgrades | ✅ Basic | ✅ Custom rules |
Plan Downgrades | ✅ Basic | ✅ Scheduled/custom |
Usage-Based Billing | ✅ Basic metering | ✅ Complex rules |
Team Features | ||
Seat Management | ✅ Basic | ✅ Custom policies |
Team Billing | ❌ Limited | ✅ Full control |
Role-Based Limits | ❌ Not available | ✅ Custom limits |
Implementation | ||
Setup Time | ✅ Quick | ⚠️ More complex |
Maintenance | ✅ Managed by Stripe | ⚠️ Self-maintained |
Customization | ❌ Limited | ✅ Fully customizable |
When implementing custom billing features, you’ll need to build on top of our StripeManager:
class StripeManager {
// Add custom billing methods
async handleTeamCredits(teamId, amount) {
await this.db.transaction(async (trx) => {
const team = await this.getTeam(teamId, trx);
await this.validateTeamCredits(team, amount, trx);
await this.allocateTeamCredits(team, amount, trx);
});
}
async handleCustomDowngrade(subscription) {
// Implement custom downgrade logic
const remainingCredits = await this.getUserCredits(subscription.customer);
if (remainingCredits > 0) {
await this.scheduleGracePeriod(subscription, remainingCredits);
}
}
}
Choose Stripe’s Portal when you need basic subscription management and quick implementation. Opt for custom implementation when your business requires:
- Complex credit management
- Team features
- Custom billing rules
- Special upgrade/downgrade handling
This way, you maintain full control over your billing logic while still leveraging Stripe’s robust payment infrastructure.
Best Practices and Considerations
-
Credit Management
- Clear rules for credit allocation
- Proper handling of unused credits
- Comprehensive audit trail
-
Error Handling
- Retry mechanisms for network issues
- Clear error logging
- Proper fallback behaviors
-
Monitoring
- Track webhook processing times
- Monitor credit balance changes
- Alert on unusual patterns
Conclusion
Building a robust Stripe integration requires careful attention to:
- Race condition prevention through single source of truth
- Idempotent webhook handling
- Proper credit management during plan changes
- Comprehensive audit trails
Remember:
- Always use
invoice.paid
for payment confirmation - Implement proper retry and idempotency logic
- Handle billing transitions carefully
- Keep detailed transaction logs
Have you encountered similar challenges with Stripe integrations? How did you solve them? Let me know in the comments below!