RA
RenovateAPIEngineering Hub
Backend & Payments

Stripe Dunning in NestJS: Stop Losing Subscribers to Failed Card Charges

A production dunning architecture for NestJS SaaS apps — grace periods, 3D Secure recovery emails, and webhook-driven state sync that recovers most failed subscription payments.

AAbhishek6 min read
Backend & Payments6 min read

Stripe Dunning in NestJS: Stop Losing Subscribers to Failed Card Charges

RENOVATEAPI ARCHITECTURAL SPEC
CANONICAL GUIDE

Somewhere between 20% and 40% of SaaS cancellations aren't cancellations at all. They're expired cards, a bank that blocked a recurring charge, or a 3D Secure prompt nobody ever saw. Call it involuntary churn, and if your NestJS billing service isn't built to catch it, you're quietly bleeding revenue every month without a single unhappy customer actually clicking "cancel."

Most teams find this out the hard way. A customer.subscription.deleted webhook fires, they kill the user's access, and three days later a support ticket lands: "I never cancelled, why is my dashboard locked?" Their bank flagged the transaction as suspicious, Stripe retried it automatically, and nobody told the customer anything was wrong in the meantime.

Why naive webhook handling makes this worse

Wiring up invoice.payment_failed and revoking access on the first failure feels correct. It isn't. Three things go wrong:

Immediate lockouts punish customers whose bank held the transaction for a day, not customers who actually want to leave. When a bank requires 3D Secure / SCA verification on a recurring charge, the subscription drops into past_due or incomplete — and if you're not surfacing the hosted authentication link, the customer has no idea they need to act. And if you skip processing failure webhooks at all, your Postgres row still says active while Stripe has already marked the invoice unpaid. Now your database and your payment processor disagree about who's actually paying you.

The fix isn't a smarter retry algorithm. It's treating dunning as a state machine with a grace period, not a switch.

The recovery flow

[ Monthly Recurring Subscription Charge Fails ]
                 │
                 ├──► Stripe Event: `invoice.payment_failed`
                 │          │
                 │          ▼ [ NestJS Webhook Handler ]
                 │          - Update DB: status = 'PAST_DUE'
                 │          - Set 5-day Grace Period
                 │          - Send In-App Warning Banner
                 │
                 ├──► Stripe Smart Retries (Auto 4x retries)
                 │          │
                 │          ▼
                 │     Payment Succeeded?
                 │      ├──► YES: `invoice.payment_succeeded` → restore ACTIVE
                 │      └──► NO: Retries exhausted
                 │
                 └──► Stripe Event: `customer.subscription.deleted`
                            ▼
                      Revoke entitlements in PostgreSQL

Stripe's Smart Retries already handle the mechanical part — spacing out reattempts over the following days. Your job is everything around that: setting a grace window, telling the customer what's happening, and keeping Postgres honest about the subscription's real state.

Building the dunning webhook service

Here's the handler that does the actual work. On invoice.payment_failed, it opens a 5-day grace period, writes the failure reason to the row, and fires a recovery email pointing straight at Stripe's hosted invoice page:

// src/billing/stripe-dunning.service.ts
import { Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe';
import { Pool } from 'pg';

@Injectable()
export class StripeDunningService {
  private readonly logger = new Logger(StripeDunningService.name);
  private readonly stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
    apiVersion: '2023-10-16',
  });
  private readonly pool = new Pool({ connectionString: process.env.DATABASE_URL! });

  async handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
    const customerId = invoice.customer as string;
    const subscriptionId = invoice.subscription as string;
    const hostedInvoiceUrl = invoice.hosted_invoice_url;

    this.logger.warn(
      `Invoice payment failed for subscription ${subscriptionId}, customer ${customerId}`,
    );

    const gracePeriodEnd = new Date();
    gracePeriodEnd.setDate(gracePeriodEnd.getDate() + 5);

    await this.pool.query(
      `UPDATE subscriptions
       SET status = 'PAST_DUE',
           grace_period_until = $1,
           last_payment_error = $2,
           payment_action_url = $3
       WHERE stripe_subscription_id = $4`,
      [
        gracePeriodEnd.toISOString(),
        invoice.last_finalization_error?.message || 'Payment method declined',
        hostedInvoiceUrl,
        subscriptionId,
      ],
    );

    await this.sendDunningNotificationEmail({
      customerId,
      hostedInvoiceUrl: hostedInvoiceUrl || '',
      amountDue: (invoice.amount_due / 100).toFixed(2),
      currency: invoice.currency.toUpperCase(),
      gracePeriodDays: 5,
    });
  }

  async handlePaymentActionRequired(invoice: Stripe.Invoice) {
    // Bank is demanding 3D Secure / SCA authentication before it'll release the charge
    const hostedInvoiceUrl = invoice.hosted_invoice_url;
    const customerId = invoice.customer as string;
    await this.sendScaActionRequiredEmail(customerId, hostedInvoiceUrl || '');
  }

  private async sendDunningNotificationEmail(params: {
    customerId: string;
    hostedInvoiceUrl: string;
    amountDue: string;
    currency: string;
    gracePeriodDays: number;
  }) {
    // Wire this to SendGrid, Postmark, or Resend
    this.logger.log(`Dunning email sent to customer ${params.customerId}: ${params.hostedInvoiceUrl}`);
  }

  private async sendScaActionRequiredEmail(customerId: string, authUrl: string) {
    this.logger.log(`3D Secure authorization email sent to customer ${customerId}: ${authUrl}`);
  }
}

Two webhooks matter here, and they're easy to conflate. invoice.payment_failed means the charge bounced — could be an expired card, insufficient funds, or a bank decline. invoice.payment_action_required is different: the bank isn't refusing the charge, it's asking the cardholder to prove they're really the cardholder. Route that one straight to an SCA-specific email with the hosted_invoice_url, because a generic "your payment failed" message won't tell the customer they need to complete a verification step, not just retry a card.

Letting customers fix it themselves

Don't build a custom "update your card" form. Stripe's Billing Customer Portal already does this, handles PCI compliance for you, and takes about ten lines to wire up:

// src/billing/billing-portal.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import Stripe from 'stripe';

@Controller('billing')
export class BillingPortalController {
  private stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
    apiVersion: '2023-10-16',
  });

  @Post('create-portal-session')
  async createPortalSession(@Body('customerId') customerId: string) {
    const portalSession = await this.stripe.billingPortal.sessions.create({
      customer: customerId,
      return_url: 'https://app.yourdomain.com/dashboard/billing',
    });
    return { url: portalSession.url };
  }
}

Drop this link in the dunning email and the in-app banner both, and most customers will just fix their own card without ever opening a support ticket.

What each layer actually buys you

Strategy Customer experience Involuntary churn recovered Engineering effort
Immediate access revocation Poor — punishes active users ~0% Low
Stripe Smart Retries only Moderate — no communication ~20–30% None (Stripe dashboard)
Grace period + in-app dunning banners Good — clear resolution path ~60–75% Moderate (NestJS webhooks)
Customer Portal self-serve updates Seamless High, on top of the above Low (pre-built Stripe UI)

That gap between "Smart Retries only" and "grace period + banners" is the whole argument for building this yourself instead of leaving it to Stripe's defaults. Retries alone recover maybe a third of failed payments. Add a grace period, a clear in-app warning, and an email with a direct action link, and recovery roughly doubles or triples — because most of these customers were never trying to leave. They just didn't know their card had a problem.

The one thing worth getting right

If you only take one piece of this: never let active in your database mean anything other than "Stripe also thinks this is active." The moment those two facts can diverge — because a webhook got missed, retried out of order, or silently failed — you're one support ticket away from either giving away free access or locking out a paying customer. Reconcile state from webhooks, not from client-side assumptions, and the rest of this architecture (grace periods, emails, portal links) is just UX polish on top of a foundation that's actually correct.

RenovateAPI Engineering Suite

Accelerate your Backend & Payments Modernization Roadmap

Need custom architecture auditing, automated OpenAPI contract generation, or zero-downtime microservice migration guidance for your engineering team?

Weekly Engineering Dispatch

Subscribe to RenovateAPI

Get weekly architectural guides, API refactoring strategies, and technical SEO updates delivered directly to your inbox.

Discussion (2)

A
Alex Rivera
2 hours ago

Extremely helpful breakdown of the Strangler Fig pattern! We're currently refactoring a legacy Java monolith at work and the OpenAPI gateway routing tips saved us weeks of experimentation.

S
Sophia Chen
1 day ago

The schema JSON-LD and FAQ block structure really helps with indexing. Great technical detail on entity mentions too.

Suggested Related Articles