Back to Blog

SaaS Dunning Management: Failed Payment Recovery, Retry Schedules, and Grace Periods

Build a production SaaS dunning system: implement smart payment retry schedules with Stripe, design grace periods that recover revenue without losing customers, send recovery email sequences, and measure dunning effectiveness.

Viprasol Tech Team
October 11, 2026
13 min read

Involuntary churn โ€” customers who want to keep paying but whose cards fail โ€” accounts for 20โ€“40% of SaaS churn. Most of it is recoverable. The difference between 2% involuntary churn and 8% is the quality of your dunning system: retry logic, grace periods, and recovery email sequences.

Stripe's Smart Retries handle the low-level retry scheduling. Your job is to build the grace period logic, recovery email sequences, and the account access controls that keep customers around long enough for the payment to succeed.


The Dunning State Machine

ACTIVE โ”€โ”€โ–บ PAST_DUE โ”€โ”€โ–บ GRACE_PERIOD โ”€โ”€โ–บ PAUSED โ”€โ”€โ–บ CANCELLED
              โ”‚                              โ”‚
              โ””โ”€โ”€โ”€โ”€ payment succeeds โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                    (back to ACTIVE)

PAST_DUE:     Invoice failed. Stripe retrying. Full access maintained.
GRACE_PERIOD: Stripe retries exhausted. Your dunning period begins.
              Days 1-3: Full access, daily reminders
              Days 4-7: Limited access, urgent reminders
              Day 8+:   Access paused, final notice
PAUSED:       Account disabled. Data preserved. Recovery still possible.
CANCELLED:    After 30 days paused, auto-cancel if no recovery.

Stripe Webhook Handler

// src/webhooks/stripe.handler.ts
import Stripe from "stripe";
import { dunningService } from "../services/dunning.service";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function handleStripeWebhook(
  rawBody: Buffer,
  signature: string
): Promise<void> {
  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      rawBody,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    throw new Error("Invalid webhook signature");
  }

  switch (event.type) {
    // Invoice payment failed โ€” begins the dunning process
    case "invoice.payment_failed": {
      const invoice = event.data.object as Stripe.Invoice;
      const subscription = invoice.subscription as string;

      // Only handle subscription invoices, not one-off charges
      if (!subscription) break;

      // Billing reason: 'subscription_cycle' (renewal) or 'subscription_create' (new)
      if (invoice.billing_reason === "subscription_cycle") {
        await dunningService.onPaymentFailed({
          stripeSubscriptionId: subscription,
          stripeInvoiceId: invoice.id,
          amountDue: invoice.amount_due,
          currency: invoice.currency,
          attemptCount: invoice.attempt_count ?? 1,
          nextAttemptAt: invoice.next_payment_attempt
            ? new Date(invoice.next_payment_attempt * 1000)
            : null,
        });
      }
      break;
    }

    // Payment succeeded โ€” exit dunning state
    case "invoice.payment_succeeded": {
      const invoice = event.data.object as Stripe.Invoice;
      if (invoice.subscription && invoice.billing_reason === "subscription_cycle") {
        await dunningService.onPaymentSucceeded({
          stripeSubscriptionId: invoice.subscription as string,
          stripeInvoiceId: invoice.id,
        });
      }
      break;
    }

    // Subscription status changed by Stripe (e.g., past_due โ†’ unpaid)
    case "customer.subscription.updated": {
      const sub = event.data.object as Stripe.Subscription;
      await dunningService.syncSubscriptionStatus(sub.id, sub.status);
      break;
    }

    // Customer updated payment method โ€” retry immediately
    case "payment_method.attached": {
      const pm = event.data.object as Stripe.PaymentMethod;
      if (pm.customer) {
        await dunningService.onPaymentMethodUpdated(pm.customer as string);
      }
      break;
    }
  }
}

๐Ÿš€ SaaS MVP in 8 Weeks โ€” Seriously

We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ€” all handled by one senior team.

  • Week 1โ€“2: Architecture design + wireframes
  • Week 3โ€“6: Core features built + tested
  • Week 7โ€“8: Launch-ready on AWS/Vercel with CI/CD
  • Post-launch: Maintenance plans from month 3

Dunning Service

// src/services/dunning.service.ts
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// Dunning state stored in your database
interface DunningState {
  tenantId: string;
  subscriptionId: string;
  status: "active" | "past_due" | "grace_period" | "paused" | "cancelled";
  failedAt: Date | null;
  gracePeriodEndsAt: Date | null;
  pausedAt: Date | null;
  cancelAt: Date | null;
  lastReminderSentAt: Date | null;
  reminderCount: number;
}

class DunningService {
  async onPaymentFailed(params: {
    stripeSubscriptionId: string;
    stripeInvoiceId: string;
    amountDue: number;
    currency: string;
    attemptCount: number;
    nextAttemptAt: Date | null;
  }): Promise<void> {
    const tenant = await this.getTenantBySubscription(params.stripeSubscriptionId);
    if (!tenant) return;

    const isFirstFailure = params.attemptCount === 1;

    if (isFirstFailure) {
      // First failure: go to PAST_DUE
      // Stripe Smart Retries will attempt up to 4 times over ~2 weeks
      await this.updateDunningState(tenant.id, {
        status: "past_due",
        failedAt: new Date(),
      });

      await this.sendRecoveryEmail(tenant, "payment_failed_initial", {
        amountDue: params.amountDue / 100,  // Stripe uses cents
        currency: params.currency.toUpperCase(),
        nextAttemptAt: params.nextAttemptAt,
        updatePaymentUrl: await this.generateUpdatePaymentUrl(params.stripeSubscriptionId),
      });
    } else {
      // Subsequent failure during Stripe's retry window
      await this.sendRecoveryEmail(tenant, "payment_failed_retry", {
        attemptCount: params.attemptCount,
        amountDue: params.amountDue / 100,
        updatePaymentUrl: await this.generateUpdatePaymentUrl(params.stripeSubscriptionId),
      });
    }
  }

  async syncSubscriptionStatus(
    stripeSubId: string,
    stripeStatus: string
  ): Promise<void> {
    const tenant = await this.getTenantBySubscription(stripeSubId);
    if (!tenant) return;

    // Stripe marks subscription 'unpaid' when all retries exhausted
    if (stripeStatus === "unpaid") {
      const gracePeriodEndsAt = new Date();
      gracePeriodEndsAt.setDate(gracePeriodEndsAt.getDate() + 7); // 7-day grace period

      await this.updateDunningState(tenant.id, {
        status: "grace_period",
        gracePeriodEndsAt,
      });

      await this.sendRecoveryEmail(tenant, "grace_period_started", {
        gracePeriodEndsAt,
        updatePaymentUrl: await this.generateUpdatePaymentUrl(stripeSubId),
      });

      // Schedule access restriction for day 4 of grace period
      await this.scheduleAccessRestriction(tenant.id, 4);
      // Schedule account pause for day 8
      await this.scheduleAccountPause(tenant.id, 8);
    }
  }

  async onPaymentSucceeded(params: {
    stripeSubscriptionId: string;
    stripeInvoiceId: string;
  }): Promise<void> {
    const tenant = await this.getTenantBySubscription(params.stripeSubscriptionId);
    if (!tenant) return;

    const wasInDunning = await this.isInDunning(tenant.id);

    // Restore full access immediately
    await this.updateDunningState(tenant.id, {
      status: "active",
      failedAt: null,
      gracePeriodEndsAt: null,
      pausedAt: null,
      cancelAt: null,
    });

    await this.restoreFullAccess(tenant.id);

    if (wasInDunning) {
      await this.sendRecoveryEmail(tenant, "payment_recovered", {});
    }
  }

  async onPaymentMethodUpdated(stripeCustomerId: string): Promise<void> {
    // Customer updated their card โ€” immediately retry the outstanding invoice
    const tenant = await this.getTenantByStripeCustomer(stripeCustomerId);
    if (!tenant) return;

    const dunning = await this.getDunningState(tenant.id);
    if (!dunning || dunning.status === "active") return;

    // Find the open invoice and retry it
    const invoices = await stripe.invoices.list({
      customer: stripeCustomerId,
      status: "open",
      limit: 1,
    });

    if (invoices.data.length > 0) {
      try {
        await stripe.invoices.pay(invoices.data[0].id);
        // Payment succeeded webhook will restore access
      } catch (error) {
        // Payment still failed โ€” dunning continues
        console.warn("Immediate retry after payment method update failed:", error);
      }
    }
  }

  // Generate a Stripe Customer Portal link for payment method update
  private async generateUpdatePaymentUrl(stripeSubId: string): Promise<string> {
    const subscription = await stripe.subscriptions.retrieve(stripeSubId);
    const session = await stripe.billingPortal.sessions.create({
      customer: subscription.customer as string,
      return_url: `${process.env.APP_URL}/billing`,
      flow_data: {
        type: "payment_method_update",
      },
    });
    return session.url;
  }
}

export const dunningService = new DunningService();

Access Control During Grace Period

// src/middleware/dunning-gate.middleware.ts
// Check dunning state on every authenticated request

export async function dunningGateMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> {
  const { tenantId } = req.auth;
  const dunning = await getDunningState(tenantId);

  if (!dunning || dunning.status === "active") {
    return next(); // Full access
  }

  if (dunning.status === "paused" || dunning.status === "cancelled") {
    res.status(402).json({
      error: "ACCOUNT_PAUSED",
      message: "Your account is paused due to a failed payment.",
      updatePaymentUrl: `/billing/update-payment`,
    });
    return;
  }

  if (dunning.status === "grace_period") {
    // Calculate how far into grace period
    const daysSincePastDue = dunning.failedAt
      ? Math.floor((Date.now() - dunning.failedAt.getTime()) / (1000 * 86400))
      : 0;

    // Days 1-3: full access with warning header
    if (daysSincePastDue <= 3) {
      res.setHeader("X-Payment-Status", "grace-period");
      res.setHeader("X-Grace-Period-Days-Remaining",
        Math.max(0, 7 - daysSincePastDue).toString()
      );
      return next(); // Full access
    }

    // Days 4-7: read-only access
    if (req.method !== "GET" && req.method !== "HEAD") {
      res.status(402).json({
        error: "PAYMENT_REQUIRED",
        message: `Your account is in a payment grace period. Write access is restricted. ${7 - daysSincePastDue} days remaining.`,
        updatePaymentUrl: `/billing/update-payment`,
      });
      return;
    }

    return next();
  }

  next();
}

๐Ÿ’ก The Difference Between a SaaS Demo and a SaaS Business

Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ€” with architecture that does not need to be rewritten at 1,000 users.

  • Multi-tenant PostgreSQL with row-level security
  • Stripe subscriptions, usage billing, annual plans
  • SOC2-ready infrastructure from day one
  • We own zero equity โ€” you own everything

Recovery Email Sequence

Day 0 (first failure):  "We couldn't charge your card"
  Subject: "Action needed: Payment failed for your [Plan] subscription"
  CTA: Update payment method
  Tone: Informational, not urgent

Day 3 (retry failed):   "Second payment attempt failed"
  Subject: "Still having trouble with your payment"
  CTA: Update payment method
  Tone: Slightly urgent

Day 7 (grace period):   "Your account is on hold"
  Subject: "Your account access will be paused in 24 hours"
  CTA: Update payment method (prominent)
  Tone: Urgent โ€” specific deadline

Day 8 (paused):         "Your account is paused"
  Subject: "Your [Product] account is paused"
  CTA: Reactivate account
  Tone: Clear, non-punitive, easy recovery path

Day 30 (final):         "Your account will be deleted in 7 days"
  Subject: "Final notice: account deletion scheduled"
  CTA: Reactivate or export data
  Tone: Clear deadline, data preservation emphasized

Dunning Recovery Metrics

-- Dunning recovery rate by attempt count
SELECT
  max_attempt_count,
  COUNT(*) AS subscriptions_entered_dunning,
  COUNT(*) FILTER (WHERE recovered) AS recovered,
  ROUND(100.0 * COUNT(*) FILTER (WHERE recovered) / COUNT(*), 1) AS recovery_rate_pct,
  ROUND(AVG(days_to_recover) FILTER (WHERE recovered), 1) AS avg_days_to_recover
FROM (
  SELECT
    d.tenant_id,
    MAX(d.attempt_count) AS max_attempt_count,
    BOOL_OR(d.status = 'active') AS recovered,
    EXTRACT(DAY FROM (
      MIN(d.recovered_at) - MIN(d.failed_at)
    )) AS days_to_recover
  FROM dunning_events d
  WHERE d.failed_at >= NOW() - INTERVAL '90 days'
  GROUP BY d.tenant_id
) sub
GROUP BY max_attempt_count
ORDER BY max_attempt_count;

-- Recovery rate by trigger (what caused recovery)
SELECT
  recovery_trigger,   -- 'card_update', 'smart_retry', 'manual_admin'
  COUNT(*) AS recoveries,
  ROUND(AVG(days_to_recover), 1) AS avg_days
FROM dunning_recoveries
WHERE recovered_at >= NOW() - INTERVAL '90 days'
GROUP BY recovery_trigger
ORDER BY recoveries DESC;

See Also


Working With Viprasol

Involuntary churn recovery is one of the highest-ROI engineering investments a SaaS company can make. We implement dunning systems with smart retry schedules, grace period access controls, recovery email sequences, and analytics that show exactly which intervention recovers the most revenue.

SaaS billing engineering โ†’ | Talk to our engineers โ†’

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

Add AI automation to your SaaS product?

Viprasol builds custom AI agent crews that plug into any SaaS workflow โ€” automating repetitive tasks, qualifying leads, and responding across every channel your customers use.