Building a Stripe Dunning System in NestJS That Actually Recovers Revenue
A NestJS architecture for Stripe subscription dunning that syncs Postgres state, handles 3D Secure retries, and recovers failed payments instead of just canceling users.
Building a Stripe Dunning System in NestJS That Actually Recovers Revenue
Somewhere between 20% and 40% of subscription cancellations aren't customers deciding to leave. They're expired cards, a bank flagging a charge, or a 3D Secure prompt that never reached anyone. Call it involuntary churn, because that's what it is: money you didn't have to lose.
Most teams handle this badly in one of two ways. Either they revoke access the second a charge fails, which punishes people whose bank just held the transaction for a day, or they let Stripe's automatic retries run in the background and hope for the best while their own database quietly drifts out of sync with what Stripe actually knows.
Neither is a strategy. Here's one that is, built around a NestJS webhook handler and a Postgres table that actually reflects subscription reality.
Why naive dunning fails
Three things go wrong when you don't build this deliberately:
- You cancel access on the first failed attempt, before the customer's bank even finishes its retry logic.
- A bank demands 3D Secure / SCA verification on a recurring charge, and the customer never sees the authentication link because nothing sent it to them.
- Your webhook handler misses an
invoice.payment_failedevent, and now a customer has full access in your app while their invoice sits unpaid on Stripe.
That third one is the sneaky one. It's not a payments problem, it's a state-sync problem, and it's the one that actually shows up in support tickets three weeks later when someone notices the numbers don't add up.
The lifecycle you're actually building against
Stripe's failure path looks like this once a recurring charge doesn't go through:
Monthly charge fails
→ invoice.payment_failed fires
→ NestJS webhook: status = PAST_DUE, 5-day grace period starts, in-app banner shows
→ Stripe Smart Retries kick in (4 automatic attempts)
→ succeeds → invoice.payment_succeeded → status back to ACTIVE
→ exhausted → customer.subscription.deleted → revoke entitlements in Postgres
The part worth internalizing: Stripe will retry the charge for you automatically. What it won't do is talk to your database or your customer. That's the gap this architecture fills.
The webhook handler
This is the core of it — a service that catches the failure event, opens a grace period, and writes enough context to Postgres that you can actually debug a disputed cancellation later.
// 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}`);
// Grace period: 5 days from the failure, matching Stripe's retry window
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 — this is the event people forget to handle
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;
}) {
// Hand off to SendGrid, Postmark, or Resend
this.logger.log(`Dunning email sent to customer ${params.customerId}. Pay at: ${params.hostedInvoiceUrl}`);
}
private async sendScaActionRequiredEmail(customerId: string, authUrl: string) {
this.logger.log(`3D Secure authorization email sent to customer ${customerId}: ${authUrl}`);
}
}
The handlePaymentActionRequired method is the one worth pausing on. It's easy to build a dunning flow that only listens for invoice.payment_failed and call it done — and then wonder why a chunk of "failed" payments are actually just customers who never got a 3D Secure link. Wire up invoice.payment_action_required from day one.
Stop building your own card-update form
If there's one piece of unsolicited advice here, it's this: don't hand-roll a "update your card" page. Stripe's Billing Customer Portal already does it, handles PCI compliance for you, and takes about ten lines of code.
// 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 };
}
}
Send the customer here from your dunning email instead of the raw hosted invoice link, and you get a self-serve flow that's already tested against every edge case Stripe has seen.
What each approach actually buys you
| Strategy | Customer experience | Involuntary churn recovered | Engineering work |
|---|---|---|---|
| Immediate access revocation | Poor — punishes legitimate users | ~0% | Low |
| Smart Retries only, no comms | Moderate — silent | ~20–30% | None (Stripe handles it) |
| Grace period + in-app dunning banners | Strong — clear path to resolution | ~60–75% | Moderate (webhooks + DB) |
| Customer Portal self-serve | Seamless | High, on top of the above | Low (pre-built by Stripe) |
The jump from "Smart Retries only" to "grace period plus dunning banners" is the one that matters most. Smart Retries alone leaves you around 20-30% recovery because nobody actually told the customer anything went wrong. Add a grace period, an in-app banner, and an email with a direct link, and you're recovering most of what would've otherwise been silent churn — for the cost of one webhook handler and a few database columns.
Where this actually breaks in production
The failure mode I'd watch for isn't the happy path above, it's webhook delivery. If your endpoint is down for even a few minutes during a burst of invoice.payment_failed events, you'll have subscriptions stuck in whatever state they were in before the failure — active in your database, unpaid on Stripe. Register for Stripe's webhook retry behavior, but also run a periodic reconciliation job that pulls subscription status directly from the Stripe API and corrects any drift. Webhooks should be your fast path, not your only path.
Accelerate your Backend & Payments Modernization Roadmap
Need custom architecture auditing, automated OpenAPI contract generation, or zero-downtime microservice migration guidance for your engineering team?
Frequently Asked Questions
What is dunning in the context of Stripe subscriptions?
Dunning is the process of communicating with a customer after a recurring payment fails, giving them a grace period and a way to fix their payment method, instead of canceling their subscription immediately.
How long should a grace period be before revoking access?
Five days is a common default that lines up with Stripe's Smart Retries schedule, giving the retry attempts and any 3D Secure authentication time to complete before you cut anyone off.
Subscribe to RenovateAPI
Get weekly architectural guides, API refactoring strategies, and technical SEO updates delivered directly to your inbox.
Discussion (2)
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.
The schema JSON-LD and FAQ block structure really helps with indexing. Great technical detail on entity mentions too.
Suggested Related Articles
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.
Stripe Webhook Signature Verification Keeps Failing in NestJS? Here's the Fix
Stripe webhook signatures fail in NestJS because the global body parser rewrites your JSON before verification runs. Here's how raw body capture fixes it, for Express and Fastify.
Stripe Payments in React: 3D Secure, Apple Pay, and Surviving Network Drops
How to wire Stripe's Payment Element into a React app so 3D Secure, Apple Pay, and Google Pay don't leave you with orphaned orders or duplicate charges.