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 failedThe 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_failedThe 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.