Stripe Webhooks: Solving Race Conditions and Building a Robust Credit Management System

·9 min read

blog/stripe-webhooks-solving-race-conditions

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:

  1. Stripe Checkout in Next.js: Covers basic Stripe Checkout integration and webhook setup
  2. Stripe Subscriptions in Next.js: Explores subscription implementation and management
  3. 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.

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:

  1. When a customer subscribes, Stripe sends customer.subscription.created
  2. Almost simultaneously, it sends invoice.paid when the payment succeeds
  3. Sometimes, invoice.paid arrives BEFORE the user creation from customer.subscription.created completes
  4. 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:

  1. Tracking processed events
  2. Using database transactions
  3. Handling duplicate events gracefully

Defensive Programming

When handling payments and credits, always:

  1. Verify the state before operations
  2. Use database transactions for atomic operations
  3. Implement proper retry mechanisms
  4. 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:

  1. It only fires when payment is confirmed
  2. It contains all necessary subscription information
  3. 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:

  1. Upgrades (e.g., Basic → Premium):

    • Process immediately
    • Calculate prorated credits
    • Add credits right away
  2. 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:

  1. 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
    }
  }
});
  1. 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);
  }
});
  1. 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

  1. Failed Payments: No credits are added because invoice.paid won’t fire.

  2. 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);
  }
}
  1. 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:

  1. Complex credit management
  2. Team features
  3. Custom billing rules
  4. 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

  1. Credit Management

    • Clear rules for credit allocation
    • Proper handling of unused credits
    • Comprehensive audit trail
  2. Error Handling

    • Retry mechanisms for network issues
    • Clear error logging
    • Proper fallback behaviors
  3. 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!

Enjoyed this article? Subscribe for more!

Stay Updated

Get my new content delivered straight to your inbox. No spam, ever.

Related PostsTags: Stripe, Nodejs, Development

© 2024 Comyoucom Ltd. Registered in England & Wales