Back to blog
subscriptionsdunningchurnpaymentssaasstriperevenue recovery

Subscription Lifecycle Management and Dunning for Your SaaS: Recover Revenue You’re Silently Losing

By SaaS Masters30 maart 202611 min read

Subscription Lifecycle Management and Dunning for Your SaaS: Recover Revenue You're Silently Losing

You've spent months acquiring customers, optimizing your onboarding, and reducing voluntary churn. But there's a silent revenue killer lurking in your SaaS: involuntary churn. Failed credit cards, expired payment methods, and insufficient funds silently cancel subscriptions — and most SaaS founders don't realize how much money they're leaving on the table.

Studies show that 5–9% of MRR is at risk from involuntary churn. The good news? With proper subscription lifecycle management and a smart dunning strategy, you can recover 50–70% of those failed payments automatically.

Understanding the Subscription Lifecycle

Before diving into dunning, let's map out the full subscription lifecycle. Every subscription in your SaaS moves through distinct phases:

Trial → Active → Past Due → Canceled/Expired
  ↓        ↓         ↓            ↓
 Convert  Renew    Recover     Win-back

Each transition is a critical moment where you either keep or lose revenue. Let's break down the states:

1. Trial Phase

The customer is evaluating your product. Key decisions here:

  • Trial length: 7 days for simple tools, 14–30 days for complex B2B SaaS
  • Credit card upfront? Increases conversion to paid but reduces trial signups
  • Trial expiry handling: Grace period or hard cutoff?

2. Active Phase

The subscription is healthy. But don't ignore it — this is where you:

  • Send renewal reminders (especially for annual plans)
  • Notify about upcoming price changes
  • Pre-validate payment methods before renewal

3. Past Due Phase

Payment failed. This is where dunning kicks in. The clock is ticking.

4. Canceled / Expired

Either the customer chose to leave (voluntary) or their payment couldn't be recovered (involuntary). Both need different strategies.

Modeling Subscriptions in Your Database

Here's a practical schema that supports the full lifecycle:

CREATE TABLE subscriptions (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  plan_id TEXT NOT NULL REFERENCES plans(id),
  status TEXT NOT NULL DEFAULT 'trialing',
    -- trialing, active, past_due, canceled, expired, paused
  current_period_start TIMESTAMPTZ NOT NULL,
  current_period_end TIMESTAMPTZ NOT NULL,
  cancel_at_period_end BOOLEAN DEFAULT false,
  canceled_at TIMESTAMPTZ,
  cancellation_reason TEXT,
  trial_end TIMESTAMPTZ,
  dunning_started_at TIMESTAMPTZ,
  dunning_attempts INT DEFAULT 0,
  last_payment_error TEXT,
  payment_method_id TEXT,
  stripe_subscription_id TEXT,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

CREATE INDEX idx_subscriptions_status ON subscriptions(status);
CREATE INDEX idx_subscriptions_period_end ON subscriptions(current_period_end);
CREATE INDEX idx_subscriptions_dunning ON subscriptions(dunning_started_at)
  WHERE status = 'past_due';

The key insight: always track current_period_end separately from payment status. A customer whose payment failed at renewal still has access until their paid period ends. Cutting them off immediately is both legally questionable and a terrible user experience.

Building a Dunning Engine

Dunning is the process of attempting to recover failed payments. Here's how to build one that actually works:

The Dunning Sequence

A proven dunning sequence typically spans 14–28 days:

interface DunningStep {
  dayAfterFailure: number;
  action: 'retry_payment' | 'send_email' | 'in_app_banner' | 'downgrade' | 'cancel';
  emailTemplate?: string;
}

const dunningSequence: DunningStep[] = [
  // Day 0: Payment fails
  { dayAfterFailure: 0, action: 'retry_payment' },
  { dayAfterFailure: 0, action: 'send_email', emailTemplate: 'payment_failed_soft' },
  { dayAfterFailure: 0, action: 'in_app_banner' },

  // Day 3: First retry
  { dayAfterFailure: 3, action: 'retry_payment' },
  { dayAfterFailure: 3, action: 'send_email', emailTemplate: 'payment_failed_update_card' },

  // Day 7: Second retry + urgency
  { dayAfterFailure: 7, action: 'retry_payment' },
  { dayAfterFailure: 7, action: 'send_email', emailTemplate: 'payment_failed_urgent' },

  // Day 14: Final attempt
  { dayAfterFailure: 14, action: 'retry_payment' },
  { dayAfterFailure: 14, action: 'send_email', emailTemplate: 'payment_failed_final_warning' },

  // Day 21: Downgrade to free (if you have a free tier)
  { dayAfterFailure: 21, action: 'downgrade' },

  // Day 28: Cancel subscription
  { dayAfterFailure: 28, action: 'cancel' },
];

Implementing the Dunning Worker

Run this as a scheduled job (cron or queue-based):

async function processDunning() {
  const pastDueSubscriptions = await db.subscription.findMany({
    where: { status: 'past_due' },
    include: { user: true, plan: true },
  });

  for (const sub of pastDueSubscriptions) {
    const daysSinceFailure = differenceInDays(
      new Date(),
      sub.dunningStartedAt!
    );

    const pendingSteps = dunningSequence.filter(
      step => step.dayAfterFailure === daysSinceFailure
    );

    for (const step of pendingSteps) {
      switch (step.action) {
        case 'retry_payment':
          await retryPayment(sub);
          break;
        case 'send_email':
          await sendDunningEmail(sub.user, step.emailTemplate!);
          break;
        case 'in_app_banner':
          await setInAppBanner(sub.userId, 'payment_failed');
          break;
        case 'downgrade':
          await downgradeToFree(sub);
          break;
        case 'cancel':
          await cancelSubscription(sub, 'involuntary_churn');
          break;
      }
    }

    await db.subscription.update({
      where: { id: sub.id },
      data: {
        dunningAttempts: { increment: pendingSteps.length },
        updatedAt: new Date(),
      },
    });
  }
}

Smart Retry Timing

Not all retry times are equal. Payment processors report higher success rates when you:

  1. Retry on different days of the week — avoid weekends
  2. Retry at the start of the month — when accounts are more likely to have funds
  3. Retry in the morning — before daily spending occurs
  4. Use exponential backoff — don't hammer the payment processor
function getOptimalRetryTime(attempt: number): Date {
  const now = new Date();
  const baseDelay = [0, 3, 7, 14][attempt] ?? 14;
  const retryDate = addDays(now, baseDelay);

  // Shift to Tuesday or Wednesday morning if weekend
  const day = retryDate.getDay();
  if (day === 0) retryDate.setDate(retryDate.getDate() + 2); // Sun → Tue
  if (day === 6) retryDate.setDate(retryDate.getDate() + 3); // Sat → Tue

  retryDate.setHours(9, 0, 0, 0); // Morning retry
  return retryDate;
}

Integrating with Stripe

If you're using Stripe (and you probably should be), leverage their built-in dunning features while adding your own layer on top:

// stripe-webhook-handler.ts
import Stripe from 'stripe';

async function handleInvoicePaymentFailed(event: Stripe.Event) {
  const invoice = event.data.object as Stripe.Invoice;
  const subscription = await db.subscription.findFirst({
    where: { stripeSubscriptionId: invoice.subscription as string },
  });

  if (!subscription) return;

  // Transition to past_due on first failure
  if (subscription.status === 'active') {
    await db.subscription.update({
      where: { id: subscription.id },
      data: {
        status: 'past_due',
        dunningStartedAt: new Date(),
        dunningAttempts: 0,
        lastPaymentError: invoice.last_payment_error?.message ?? 'Unknown error',
      },
    });

    await analytics.track('subscription.payment_failed', {
      userId: subscription.userId,
      planId: subscription.planId,
      errorType: invoice.last_payment_error?.type,
      amount: invoice.amount_due,
    });
  }
}

async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
  const invoice = event.data.object as Stripe.Invoice;
  const subscription = await db.subscription.findFirst({
    where: { stripeSubscriptionId: invoice.subscription as string },
  });

  if (!subscription) return;

  // Recover from past_due
  if (subscription.status === 'past_due') {
    await db.subscription.update({
      where: { id: subscription.id },
      data: {
        status: 'active',
        dunningStartedAt: null,
        dunningAttempts: 0,
        lastPaymentError: null,
        currentPeriodStart: new Date(invoice.period_start * 1000),
        currentPeriodEnd: new Date(invoice.period_end * 1000),
      },
    });

    await sendEmail(subscription.userId, 'payment_recovered');
    await clearInAppBanner(subscription.userId, 'payment_failed');
  }
}

Stripe Billing Settings to Configure

await stripe.subscriptions.update(subscriptionId, {
  payment_settings: {
    payment_method_options: {
      card: {
        request_three_d_secure: 'automatic',
      },
    },
    save_default_payment_method: 'on_subscription',
  },
  collection_method: 'charge_automatically',
});

Key Stripe settings:

  • Smart Retries: Enable in Stripe Dashboard → Billing → Automatic collection. Stripe uses ML to find the optimal retry time.
  • Failed payment emails: Let Stripe send basic ones, but supplement with your own.
  • Customer portal: Enable self-service card updates at billing.stripe.com.

Pre-Dunning: Prevention is Better Than Cure

The best dunning strategy is avoiding failed payments altogether:

1. Card Expiry Notifications

// Run weekly: find cards expiring within 30 days
async function checkExpiringCards() {
  const customers = await stripe.customers.list({ limit: 100 });

  for (const customer of customers.data) {
    const pm = await stripe.paymentMethods.list({
      customer: customer.id,
      type: 'card',
    });

    for (const method of pm.data) {
      const expiry = new Date(
        method.card!.exp_year,
        method.card!.exp_month - 1
      );
      const daysUntilExpiry = differenceInDays(expiry, new Date());

      if (daysUntilExpiry <= 30 && daysUntilExpiry > 0) {
        await sendEmail(customer.metadata.userId, 'card_expiring_soon', {
          last4: method.card!.last4,
          expiryDate: format(expiry, 'MM/yyyy'),
          updateUrl: `${APP_URL}/settings/billing`,
        });
      }
    }
  }
}

2. Pre-Renewal Reminders for Annual Plans

Annual customers often forget they have a subscription. Send a reminder 7 days before renewal:

async function sendRenewalReminders() {
  const sevenDaysFromNow = addDays(new Date(), 7);
  const renewingSoon = await db.subscription.findMany({
    where: {
      status: 'active',
      currentPeriodEnd: {
        gte: startOfDay(sevenDaysFromNow),
        lte: endOfDay(sevenDaysFromNow),
      },
      plan: { interval: 'year' },
    },
    include: { user: true, plan: true },
  });

  for (const sub of renewingSoon) {
    await sendEmail(sub.user.email, 'annual_renewal_reminder', {
      planName: sub.plan.name,
      renewalDate: format(sub.currentPeriodEnd, 'MMMM d, yyyy'),
      amount: formatCurrency(sub.plan.price),
    });
  }
}

Win-Back Flows: After Cancellation

When a subscription does get canceled (voluntary or involuntary), your job isn't over:

Involuntary Churn Win-Back

const winBackSequence = [
  { daysAfterCancel: 1, template: 'winback_update_payment', offer: null },
  { daysAfterCancel: 7, template: 'winback_we_miss_you', offer: '20_percent_off_3_months' },
  { daysAfterCancel: 30, template: 'winback_final', offer: '30_percent_off_3_months' },
];

Pausing Instead of Canceling

Offer a pause option — it's dramatically more recoverable than a cancellation:

async function pauseSubscription(subscriptionId: string, pauseMonths: number) {
  const resumeDate = addMonths(new Date(), pauseMonths);

  await stripe.subscriptions.update(subscriptionId, {
    pause_collection: {
      behavior: 'void',
      resumes_at: Math.floor(resumeDate.getTime() / 1000),
    },
  });

  await db.subscription.update({
    where: { stripeSubscriptionId: subscriptionId },
    data: { status: 'paused' },
  });
}

Metrics to Track

Build a dashboard that tracks these dunning-specific metrics:

MetricFormulaTarget
Involuntary churn rateLost MRR from failed payments / Total MRR< 1% monthly
Recovery rateRecovered payments / Total failed payments> 60%
Average recovery timeMean days from failure to recovery< 7 days
Dunning email open rateOpens / Sent dunning emails> 40%
Card update rateCards updated after notification / Notifications sent> 25%

Common Pitfalls

  1. Cutting access immediately on payment failure — Always provide a grace period. Locking customers out breeds resentment, not payments.

  2. Generic dunning emails — Personalize them. Include the plan name, what they'll lose access to, and a one-click link to update payment.

  3. Not tracking involuntary vs. voluntary churn separately — They need completely different solutions. Mixing them hides the real problem.

  4. Ignoring timezone in retry logic — A retry at 3 AM in the customer's timezone is wasted. Always localize.

  5. No self-service payment update — If updating a card requires contacting support, you've already lost.

Conclusion

Subscription lifecycle management isn't glamorous, but it's one of the highest-ROI investments you can make in your SaaS. A well-built dunning system recovers revenue that would otherwise silently disappear — and it works 24/7 without any manual intervention.

Start with the basics: handle Stripe webhooks properly, send clear dunning emails, and offer easy card updates. Then layer on smart retry timing, pre-dunning prevention, and win-back flows. Your MRR will thank you.

The difference between a SaaS that grows and one that stalls often isn't acquisition — it's retention. And a huge chunk of retention is simply making sure payments go through.