Developer Guide
February 27, 2026·15 min read

Building Payment Recovery with Stripe Webhooks: A Complete Guide

At $10K MRR, roughly $900/mo is disappearing because of failed payments. Cards expire, banks block charges, funds run low at the wrong moment. Most of that revenue is recoverable — here's how to build the system.

This guide covers building a complete payment recovery system using Stripe webhooks — from listening to the right events to implementing retry logic and dunning sequences. By the end, you'll have something that recovers 40-60% of failed payments automatically. Using Node.js throughout, but the concepts translate to any backend.

The events that matter

Stripe fires dozens of webhook events. For payment recovery, you need these:

invoice.payment_failed         — subscription invoice failed
invoice.payment_action_required — 3DS authentication required
customer.subscription.deleted  — subscription canceled
customer.subscription.updated  — subscription status changed
charge.failed                  — one-time charge failed
payment_intent.payment_failed  — PaymentIntent failed

The most important one is invoice.payment_failed. This fires every time Stripe tries and fails to charge a subscription invoice. That's your entry point.

Setting up the webhook endpoint

// webhooks/stripe.js
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

router.post('/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  
  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  try {
    await handleStripeEvent(event);
    res.json({ received: true });
  } catch (err) {
    // Return 200 even on app errors — avoid duplicate Stripe retries
    res.json({ received: true, warning: err.message });
  }
});

Two things worth noting: express.raw() middleware is critical — signature verification requires the raw request body, not parsed JSON. And we return 200 even when app logic fails — if you return 500 on a database timeout, Stripe retries the webhook and you might process the same event twice.

The payment failed handler

async function handlePaymentFailed(invoice) {
  const customerId = invoice.customer;
  const subscriptionId = invoice.subscription;
  
  // Get decline code from the payment intent
  const paymentIntent = invoice.payment_intent
    ? await stripe.paymentIntents.retrieve(invoice.payment_intent)
    : null;
    
  const declineCode = paymentIntent?.last_payment_error?.decline_code || 'unknown';
  const attemptCount = invoice.attempt_count || 1;
  
  // Route to the right recovery action
  const action = determineRecoveryAction(declineCode, attemptCount);
  
  switch (action.type) {
    case 'retry_soon':
      await scheduleRetry(invoice, customer, action.delayHours);
      break;
    case 'retry_later':
      await scheduleRetry(invoice, customer, action.delayHours);
      break;
    case 'send_email_only':
      await sendDunningEmail(customer, invoice, declineCode, attemptCount);
      break;
    case 'escalate':
      await sendFinalWarning(customer, invoice);
      break;
  }
}

Decline code routing — the part most people skip

This is where recovery rates diverge. Treating all failures the same means treating "card expired" the same as "do not honor" — which are completely different situations.

function determineRecoveryAction(declineCode, attemptCount) {
  if (attemptCount >= 4) return { type: 'escalate' };
  
  switch (declineCode) {
    // Soft declines — bank may have temporarily blocked
    case 'do_not_honor':
    case 'generic_decline':
    case 'transaction_not_allowed':
      // Retry after 4-6 hours (add jitter to avoid charge bursts)
      return { type: 'retry_soon', delayHours: 4 + Math.random() * 2 };
    
    // Insufficient funds — wait for payday
    case 'insufficient_funds':
    case 'withdrawal_count_limit_exceeded':
      return { 
        type: 'retry_later', 
        delayHours: attemptCount === 1 ? 72 : 168 
      };
    
    // Hard declines — need customer action, don't retry
    case 'expired_card':
    case 'lost_card':
    case 'stolen_card':
    case 'restricted_card':
      return { type: 'send_email_only' };
    
    // Unknown — try once more
    default:
      return { type: 'retry_soon', delayHours: attemptCount === 1 ? 6 : 48 };
  }
}

The jitter on retry timing (Math.random() * 2) is intentional. If you have many customers on the same billing cycle, retrying all at exactly 4 hours creates a charge burst. Spread them out.

Decline Code Reference

expired_cardEmail with update link. Recovery: ~85%
insufficient_fundsRetry day 3 + day 7. Recovery: ~60%
do_not_honorRetry in 4-6h. Recovery: ~45%
lost_card / stolen_cardEmail only. Recovery: ~25%

Scheduling retries with BullMQ

const { Queue, Worker } = require('bullmq');

const retryQueue = new Queue('payment-retries', {
  connection: { host: 'localhost', port: 6379 }
});

async function scheduleRetry(invoice, customer, delayHours) {
  const delayMs = delayHours * 60 * 60 * 1000;
  
  await retryQueue.add('retry-payment', {
    invoiceId: invoice.id,
    customerId: customer.id,
    subscriptionId: invoice.subscription,
    attemptNumber: (invoice.attempt_count || 1) + 1,
  }, { delay: delayMs });
}

// Worker that processes retries
const retryWorker = new Worker('payment-retries', async (job) => {
  const { invoiceId } = job.data;
  
  try {
    const invoice = await stripe.invoices.pay(invoiceId, {
      forgive: false,
    });
    console.log(`Payment succeeded on retry: ${invoiceId}`);
  } catch (err) {
    // Stripe will fire invoice.payment_failed again — handled by webhook
    console.log(`Retry failed: ${invoiceId} — ${err.message}`);
  }
}, { connection: { host: 'localhost', port: 6379 } });

Frictionless card update links

The biggest killer of recovery rates isn't the email — it's the update flow. If customers click your link and hit a login screen, 80%+ abandon. Stripe Checkout in setup mode generates a direct, secure card update URL with no login required:

async function generateUpdateLink(customer) {
  const session = await stripe.checkout.sessions.create({
    payment_method_types: ['card'],
    mode: 'setup',
    customer: customer.stripeId,
    success_url: `${process.env.APP_URL}/billing/updated?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/billing`,
  });
  
  return session.url; // Direct link — no login required
}

Dunning email sequence

async function sendDunningEmail(customer, invoice, declineCode, attemptCount) {
  const updateLink = await generateUpdateLink(customer);
  
  // Choose template based on decline reason and attempt count
  const template = selectTemplate(declineCode, attemptCount);
  
  await email.send({
    to: customer.email,
    from: 'support@yourapp.com', // Real address — enable replies
    subject: template.subject,
    html: template.render({
      firstName: customer.firstName,
      productName: 'Your App',
      updateLink,
      lastFour: customer.cardLast4,
    }),
  });
}

function selectTemplate(declineCode, attemptCount) {
  if (declineCode === 'expired_card') return templates.expiredCard;
  if (declineCode === 'insufficient_funds') return templates.insufficientFunds;
  if (attemptCount >= 3) return templates.finalWarning;
  if (attemptCount === 1) return templates.softAlert;
  return templates.followUp;
}

Handling subscription recovery after payment

async function handlePaymentSucceeded(invoice) {
  const customerId = invoice.customer;
  const subscriptionId = invoice.subscription;
  
  // Update database
  await db.failedPayments.update({
    stripeInvoiceId: invoice.id,
    status: 'recovered',
    recoveredAt: new Date(),
  });
  
  // Restore access if suspended
  await db.subscriptions.update({
    stripeId: subscriptionId,
    status: 'active',
    accessSuspended: false,
  });
  
  // Cancel any pending retry jobs
  await cancelPendingRetries(invoice.id);
  
  // Optional: send a "your account is back" email
  const customer = await db.customers.findByStripeId(customerId);
  if (customer) {
    await email.send({
      to: customer.email,
      subject: 'Payment sorted — your account is fully active',
      template: 'payment-recovered',
    });
  }
}

Testing your webhook locally

Use the Stripe CLI to forward events to your local server:

# Install and login
stripe login

# Forward events to local server
stripe listen --forward-to localhost:3000/webhooks/stripe

# Trigger a test event
stripe trigger invoice.payment_failed

The CLI outputs a webhook signing secret (whsec_...) — use that as STRIPE_WEBHOOK_SECRET in your local .env.

What to monitor in production

  • Webhook delivery rate — check Stripe dashboard for failed deliveries
  • Recovery rate by decline code — expired_card should be 70%+; do_not_honor 40-50%
  • Retry queue depth — large queue means retries are backing up
  • Time to recovery — median should be under 5 days
  • Dunning email open rates — first email should be 65%+; below that means delivery issues

Recovery Rate Targets

With this system fully implemented: expired_card 75-85%, insufficient_funds 50-60%, generic/soft declines 40-50%, overall 55-65%.

Without dedicated recovery: 25-35% overall. The delta compounds fast at higher MRR.

Rather not build this yourself?

Revive handles all of this — webhook processing, decline routing, retry scheduling, dunning sequences, and frictionless update flows — for $49/mo. Connect Stripe in 3 minutes, no code required.

Try Revive instead →

Related reading: Stripe Payment Failure Codes Explained · 7 Dunning Email Templates · SaaS Churn Metrics That Actually Matter

About Revive: Payment recovery automation for SaaS. Smart retries, dunning emails, and win-back campaigns. $49/mo flat, no revenue share.