TL;DR
Involuntary churn (subscribers lost to card failures, not cancellations) accounts for 20-40% of SaaS churn per Baremetrics industry data. Here's how to auto-recover them with 3 files:
-
app/api/stripe/webhook/route.ts— catchinvoice.payment_failed -
lib/dunning.ts— update Supabase status + generate Billing Portal URL -
lib/emails.ts— send recovery email via Resend
Why Involuntary Churn Is Invisible
When Stripe shows "subscription canceled" in your dashboard, it doesn't tell you why. Was it a deliberate cancellation or just an expired card?
Without dunning management, you lose subscribers who actually want to keep paying. Card failures + smart retry exhaustion = silent revenue drain.
Step 0: Enable Stripe Smart Retries
Before touching code, enable automatic retries in the Stripe dashboard:
Billing → Subscriptions and emails → Manage failed payments → Enable smart retries
Stripe's ML picks the optimal retry timing (up to 4 attempts). After that, the subscription is canceled — which is when customer.subscription.deleted fires.
Step 1: Webhook Handler
// app/api/stripe/webhook/route.ts
import Stripe from 'stripe'
import { NextRequest, NextResponse } from 'next/server'
import { handlePaymentFailed, handlePaymentRecovered } from '@/lib/dunning'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: NextRequest) {
const body = await req.text()
const sig = req.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch {
return NextResponse.json({ error: 'Webhook signature failed' }, { status: 400 })
}
switch (event.type) {
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object as Stripe.Invoice)
break
case 'invoice.paid':
await handlePaymentRecovered(event.data.object as Stripe.Invoice)
break
}
return NextResponse.json({ received: true })
}
Step 2: Supabase Update + Billing Portal
// lib/dunning.ts
import Stripe from 'stripe'
import { createClient } from '@/lib/supabase/server'
import { sendPaymentFailedEmail, sendPaymentRecoveredEmail } from '@/lib/emails'
export async function handlePaymentFailed(invoice: Stripe.Invoice) {
const supabase = createClient()
const customerId = typeof invoice.customer === 'string'
? invoice.customer : invoice.customer?.id
if (!customerId) return
const { data: profile } = await supabase
.from('profiles')
.select('id, email, name')
.eq('stripe_customer_id', customerId)
.single()
if (!profile) return
// Mark as past_due
await supabase
.from('subscriptions')
.update({ status: 'past_due', payment_failed_at: new Date().toISOString() })
.eq('user_id', profile.id)
// Generate Billing Portal URL
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/dashboard`,
})
await sendPaymentFailedEmail({
to: profile.email,
name: profile.name ?? 'there',
billingPortalUrl: session.url,
})
}
export async function handlePaymentRecovered(invoice: Stripe.Invoice) {
const supabase = createClient()
const customerId = typeof invoice.customer === 'string'
? invoice.customer : invoice.customer?.id
if (!customerId) return
const { data: profile } = await supabase
.from('profiles').select('id, email, name').eq('stripe_customer_id', customerId).single()
if (!profile) return
await supabase
.from('subscriptions')
.update({ status: 'active', recovered_at: new Date().toISOString() })
.eq('user_id', profile.id)
await sendPaymentRecoveredEmail({ to: profile.email, name: profile.name ?? 'there' })
}
Step 3: Recovery Email via Resend
Keep the tone helpful, not accusatory. Most users don't know their card expired.
// lib/emails.ts
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY)
export async function sendPaymentFailedEmail({
to, name, billingPortalUrl,
}: { to: string; name: string; billingPortalUrl: string }) {
await resend.emails.send({
from: 'support@yourapp.com',
to,
subject: `Action required: Update your payment method`,
html: `
<p>Hi ${name},</p>
<p>We had trouble processing your payment — this can happen when a card expires or has insufficient funds.</p>
<p>
<a href="${billingPortalUrl}" style="background:#4f46e5;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;display:inline-block;">
Update payment method →
</a>
</p>
<p style="color:#666;font-size:12px;">Stripe will retry automatically, but updating now ensures no service interruption.</p>
`,
})
}
Supabase Schema
ALTER TABLE subscriptions
ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'active',
ADD COLUMN IF NOT EXISTS payment_failed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS payment_failed_count INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS recovered_at TIMESTAMPTZ;
Key Stripe Events to Handle
| Event | When | Action |
|---|---|---|
invoice.payment_failed |
Each failed attempt | Notify user + update DB |
customer.subscription.updated |
Status becomes past_due
|
Optional: restrict access |
invoice.paid |
Successful retry / manual update | Restore active status |
customer.subscription.deleted |
All retries exhausted | Cancel flow + win-back email |
Implementation Cost
- Webhook handler: 2-3 hours
- Resend email: 1-2 hours
- Billing Portal setup + button: 30 min
Total: 4-6 hours to auto-recover failed payments.
Full implementation walkthrough (Japanese) → masatoman.net
Top comments (0)