Payment Failed Recovery

Dunning

Recover failed subscription payments before they churn. Automated dunning emails that help customers update their payment method.

9%
of payments fail monthly
40%
can be recovered
3 days
optimal recovery window

The Problem

A customer's credit card expires or gets declined. Stripe will retry automatically, but without proactive communication, customers don't know their access is at risk. They churn involuntarily. With Retake, they get friendly reminders to update their payment method before it's too late.

Common Failure Reasons

Card Declined
Insufficient funds
Card Expired
Needs update
Processing Error
Bank issue
Retry Scheduled
Auto-retry pending

Step 1. Set Up Stripe Webhook

Listen for invoice.payment_failed events and track them as INTENT. When payment succeeds, send CONVERSION to stop recovery.

Stripe Webhook Handler
typescript
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { retake } from '@/lib/retake';

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

export async function POST(req: Request) {
  const body = await req.text();
  const sig = req.headers.get('stripe-signature')!;
  
  const event = stripe.webhooks.constructEvent(
    body,
    sig,
    process.env.STRIPE_WEBHOOK_SECRET!
  );

  switch (event.type) {
    case 'invoice.payment_failed':
      const invoice = event.data.object;
      const user = await getUserByStripeId(invoice.customer as string);
      
      if (user) {
        await retake.track({
          event: "INTENT",
          type: "payment_failed",
          userId: user.id,
          email: user.email,
          value: invoice.amount_due / 100,
          metadata: {
            attemptCount: invoice.attempt_count,
            nextAttempt: invoice.next_payment_attempt
          }
        });
      }
      break;

    case 'invoice.payment_succeeded':
      // Payment succeeded after retry - cancel recovery
      const paidInvoice = event.data.object;
      const paidUser = await getUserByStripeId(paidInvoice.customer as string);
      
      if (paidUser) {
        await retake.track({
          event: "CONVERSION",
          userId: paidUser.id,
          value: paidInvoice.amount_paid / 100,
          transactionId: paidInvoice.id
        });
      }
      break;
  }

  return Response.json({ received: true });
}

Alternative: Paddle Webhook

If you use Paddle instead of Stripe, here's the equivalent webhook handler.

Paddle Webhook Handler
typescript
// Paddle webhook handler
async function handlePaddleWebhook(event: PaddleEvent) {
  switch (event.event_type) {
    case 'transaction.payment_failed':
      const user = await getUserByPaddleId(event.data.customer_id);
      
      await retake.track({
        event: "INTENT",
        type: "payment_failed",
        userId: user.id,
        email: user.email,
        value: parseFloat(event.data.details.totals.total),
        metadata: {
          errorCode: event.data.payments[0]?.error_code,
          subscriptionId: event.data.subscription_id
        }
      });
      break;
      
    case 'transaction.completed':
      // Recovery successful
      await retake.track({
        event: "CONVERSION",
        userId: user.id,
        value: parseFloat(event.data.details.totals.total)
      });
      break;
  }
}

Recommended Email Sequence

1 hour
Immediate Alert
"Your payment didn't go through - here's how to fix it"
1 day
Reminder
"Your subscription is at risk - update payment method"
3 days
Final Warning
"Last chance to keep your account active"

Best Practices

Direct Update Link

Include a one-click link to update payment method. Use Stripe's Customer Portal or a custom billing page.

Show What They'll Lose

"You have 47 projects and 3 team members. Don't lose access." Make the stakes clear.

Be Helpful, Not Threatening

"We noticed an issue with your payment" is better than "Your payment failed!"

Track Retry Success

When Stripe's auto-retry succeeds, send CONVERSION to stop further dunning emails immediately.