DEV Community

masato
masato

Posted on • Originally published at masatoman.net

Stripe Dunning for Indie Hackers 2026: Auto-recover Failed Payments with Supabase + Resend

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 — catch invoice.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 })
}
Enter fullscreen mode Exit fullscreen mode

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' })
}
Enter fullscreen mode Exit fullscreen mode

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>
    `,
  })
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)