Stripe billing for SaaS: subscription lifecycles, dunning, and proration

Stripe's API is one of the best-documented in the industry, yet billing integrations still regularly end up broken — usually because developers treat it as a simple charge API when it's actually a complex state machine. Understanding the full subscription lifecycle, what events to listen for, and how proration works will save you from the category of bugs that silently give users free access or silently lock out paying customers.

The Stripe object model

Before writing a line of code, understand the hierarchy:

  • Customer — represents a billing entity (an org or user in your system). Has a payment method. Persists across subscriptions.
  • Product + Price — what you're selling and how much it costs. Products are abstract; Prices define recurring/one-time billing intervals and amounts.
  • Subscription — a Customer subscribed to one or more Prices. Has a lifecycle: trialing → active → past_due → canceled.
  • Invoice — generated automatically at each billing cycle. Contains line items, the amount due, and the payment status.
  • PaymentIntent — the actual payment attempt. An invoice generates a PaymentIntent when it needs to collect payment.
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-06-20',
});

// Create a customer when a user signs up (not at checkout)
async function createStripeCustomer(user: User): Promise<string> {
  const customer = await stripe.customers.create({
    email: user.email,
    name: user.name,
    metadata: {
      userId: user.id,
      orgId: user.orgId,
    },
  });
  // Store customer.id in your DB alongside the user
  await db.users.update(user.id, { stripeCustomerId: customer.id });
  return customer.id;
}

// Create a subscription
async function createSubscription(
  customerId: string,
  priceId: string,
  trialDays: number = 14
): Promise<Stripe.Subscription> {
  return stripe.subscriptions.create({
    customer: customerId,
    items: [{ price: priceId }],
    trial_period_days: trialDays,
    payment_settings: {
      save_default_payment_method: 'on_subscription',
    },
    expand: ['latest_invoice.payment_intent'],
  });
}

The subscription state machine

A subscription moves through states based on payment outcomes and explicit actions. The states that matter:

  • trialing — within the trial period, no payment required yet
  • active — paid, access granted
  • past_due — payment failed, Stripe is retrying (Smart Retries). Access decision is yours.
  • canceled — definitively ended, either by cancellation or failed payment exhaustion
  • unpaid — all retries exhausted, invoice still unpaid
  • incomplete — first payment requires 3DS or additional action

Your system should map these states to access decisions in one place:

function hasActiveAccess(subscriptionStatus: Stripe.Subscription.Status): boolean {
  // Grant access during past_due — Stripe is still retrying.
  // Cutting access immediately on first failure is bad UX and increases churn.
  return ['active', 'trialing', 'past_due'].includes(subscriptionStatus);
}

function shouldShowPaymentWarning(status: Stripe.Subscription.Status): boolean {
  return status === 'past_due';
}

Handling invoice.payment_failed

The most important webhook event for billing is invoice.payment_failed. You should listen for it and trigger your dunning flow:

// Webhook handler
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'] as string;
  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return res.status(400).send('Webhook signature verification failed');
  }

  switch (event.type) {
    case 'invoice.payment_failed': {
      const invoice = event.data.object as Stripe.Invoice;
      const sub = await stripe.subscriptions.retrieve(invoice.subscription as string);
      const customer = await db.organizations.findByStripeCustomerId(invoice.customer as string);

      await db.organizations.update(customer.id, {
        subscriptionStatus: sub.status,
        paymentFailedAt: new Date(),
        attemptCount: invoice.attempt_count,
      });

      // Send dunning email — tone varies by attempt count
      await sendDunningEmail(customer, invoice.attempt_count, invoice.next_payment_attempt);
      break;
    }

    case 'invoice.paid': {
      const invoice = event.data.object as Stripe.Invoice;
      const customer = await db.organizations.findByStripeCustomerId(invoice.customer as string);

      await db.organizations.update(customer.id, {
        subscriptionStatus: 'active',
        paymentFailedAt: null,
        attemptCount: 0,
      });
      break;
    }

    case 'customer.subscription.deleted': {
      const sub = event.data.object as Stripe.Subscription;
      const customer = await db.organizations.findByStripeCustomerId(sub.customer as string);
      await db.organizations.update(customer.id, { subscriptionStatus: 'canceled' });
      await sendCancellationEmail(customer);
      break;
    }
  }

  res.json({ received: true });
});

Smart Retries and dunning strategy

Stripe's Smart Retries uses ML to pick the optimal retry timing for each card issuer and payment type. Enable it in your Dashboard settings — it consistently outperforms fixed retry schedules.

Your dunning email sequence should complement the retry schedule. A practical 3-email sequence:

  1. Attempt 1 fails — Friendly "payment issue" email with a direct link to update payment method. Tone: helpful, not alarming.
  2. Attempt 2 fails — More urgent. Name the risk of access loss. Show remaining retry attempts.
  3. Final warning (day before cancellation) — "Your account will be canceled in 24 hours." Give a specific time. Include a prominent "Update card" button.

Proration: plan upgrades and downgrades

When a user changes plans mid-billing-cycle, Stripe prorates the charges automatically. Use the proration preview to show users what they'll be charged before confirming the change:

// Preview what a plan change will cost
async function previewProration(
  subscriptionId: string,
  newPriceId: string
): Promise<{ immediateCharge: number; nextInvoice: number }> {
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);

  const preview = await stripe.invoices.retrieveUpcoming({
    customer: subscription.customer as string,
    subscription: subscriptionId,
    subscription_items: [{
      id: subscription.items.data[0].id,
      price: newPriceId,
    }],
    subscription_proration_date: Math.floor(Date.now() / 1000),
  });

  // Find the immediate proration line items (positive = charge, negative = credit)
  const prorationItems = preview.lines.data.filter(
    line => line.proration && line.period.start === Math.floor(Date.now() / 1000)
  );
  const immediateCharge = Math.max(0, preview.amount_due);

  return {
    immediateCharge: immediateCharge / 100, // convert cents to dollars
    nextInvoice: preview.total / 100,
  };
}

// Execute the plan change
async function changePlan(subscriptionId: string, newPriceId: string): Promise<void> {
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);

  await stripe.subscriptions.update(subscriptionId, {
    items: [{
      id: subscription.items.data[0].id,
      price: newPriceId,
    }],
    proration_behavior: 'always_invoice', // charge/credit immediately
  });
}

Metered billing

For usage-based pricing (per API call, per active user, per GB), Stripe offers metered billing via usage records. Report usage at the end of each billing period:

// Report usage for a billing period
async function reportUsage(
  subscriptionItemId: string,
  quantity: number,
  timestamp: number = Math.floor(Date.now() / 1000)
): Promise<void> {
  await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
    quantity,
    timestamp,
    action: 'set', // 'set' replaces; 'increment' adds to existing
  });
}

// In practice, batch this — report daily totals, not every event
// Use a cron job or queue to aggregate and report at consistent intervals
Store Stripe event IDs you've processed and check for duplicates before handling them. Stripe guarantees at-least-once delivery, not exactly-once. A customer.subscription.deleted event handled twice could cause incorrect cleanup actions.