Subscription revenue doesn't automatically disappear when a payment fails—but without the right strategy, it will. Dunning and failed payments: what developers should know becomes critical once you're managing recurring billing at scale, because a significant chunk of failed renewals can be recovered with smart retry logic, customer communication, and grace periods. We'll walk through the mechanics that keep revenue flowing even when cards decline.
Why failed payments matter more than you think
Payment failures happen constantly. A card expires, a customer's bank flags a charge as suspicious, or there's a temporary network issue. In subscription models, a single failed payment can cascade: the customer loses access, support tickets pile up, and that MRR evaporates. Industry data suggests that 12–15% of failed payments can be recovered if you handle them intelligently. That's real money left on the table.
The reason most teams fail here is simple: they treat payment failures as terminal events. A charge fails, they log it, maybe send one email, then move on. But payment recovery is a process—not an event. It requires orchestration across retry timing, customer communication, access control, and final state transitions.
Smart retries—Stripe retries at good times automatically
Stripe's automatic retry logic is one of the easiest wins in dunning management. When a subscription payment fails, Stripe automatically attempts to charge the card again at optimized intervals, increasing the chance of success without you having to build retry infrastructure.
Stripe's retry schedule typically works like this:
- Day 1: Immediate retry (often within minutes)
- Day 3: Second attempt
- Day 5: Third attempt
These intervals are strategically timed. The first retry catches transient network issues. Subsequent retries give customers time to resolve issues on their end—topping up a prepaid card, fixing a fraud block, or updating their payment method manually.
To enable and configure Stripe's automatic retry behavior, check your billing settings in the Stripe Dashboard or use the API:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
// Retrieve current billing portal configuration
const configuration = await stripe.billingPortal.configurations.retrieve(
'bpc_example123'
);
console.log('Automatic retry enabled:', configuration.features.subscription_update.enabled);
You can also customize the retry behavior via webhooks. When a invoice.payment_failed event fires, you'll have visibility into the attempt count and can make intelligent decisions about when to escalate to customer communication:
app.post('/webhooks', 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.WEBHOOK_SECRET);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
if (event.type === 'invoice.payment_failed') {
const invoice = event.data.object;
const attemptCount = invoice.attempt_count;
// After 2nd or 3rd failed attempt, notify customer
if (attemptCount >= 2) {
await notifyCustomerAboutFailedPayment(invoice.customer, invoice);
}
}
res.json({ received: true });
});
The key insight: let Stripe do what it does well. Don't override the retry schedule unless you have a specific reason. The timing is optimized across millions of transactions, and your custom logic is unlikely to beat it.
Customer emails—Ask customers to update cards
Automated retries work, but they're not magic. Many failures require customer action. A card might be permanently expired, or a fraud block might stay in place until the customer contacts their bank. That's where proactive communication comes in.
A well-timed, clear email asking the customer to update their payment method can recover 30–40% of failed payments that the automatic retries couldn't fix. The email should:
- Be sent soon after the first failure (not days later)
- Include a clear, one-click link to update the card (not a generic login page)
- Explain the business impact: "Your service will pause on [date] if we can't charge the updated card"
- Avoid guilt-tripping or aggressive language; keep it professional and factual
You can send these emails either manually (triggered by invoice webhooks) or via a dunning provider like Stripe's billing portal or a third-party tool. Here's a practical approach using webhook events:
const nodemailer = require('nodemailer');
async function handlePaymentFailed(invoice) {
const customer = await stripe.customers.retrieve(invoice.customer);
// Only email if this is the 1st or 2nd attempt and email hasn't been sent recently
if (invoice.attempt_count <= 2 && !invoice.next_payment_attempt) {
const updatePaymentUrl = `https://yourapp.com/billing/update-payment?invoice_id=${invoice.id}`;
const emailBody = `
Hi ${customer.name},
We tried to charge your card ending in ${customer.invoice_settings.custom_fields?.[0]?.value || 'xxxx'}
on ${new Date(invoice.created * 1000).toDateString()}, but it was declined.
To keep your service active, please update your payment method:
${updatePaymentUrl}
We'll retry the payment in a few days, but updating now will ensure no interruption to your service.
Best,
The Billing Team
`;
await sendEmail(customer.email, 'Update your payment method', emailBody);
// Log that we sent the email so we don't spam
await logDunningEmail(invoice.id, customer.id);
}
}
The "update payment" link should take the customer directly to a page where they can add a new card without friction. Use Stripe's SetupIntents API to securely collect a new card and update the customer's default payment method:
app.post('/api/update-payment-method', async (req, res) => {
const { customerId } = req.body;
const setupIntent = await stripe.setupIntents.create({
customer: customerId,
payment_method_types: ['card'],
});
res.json({ clientSecret: setupIntent.client_secret });
});
// On the frontend (React example):
// Use Stripe Elements + SetupIntent to collect card and confirm
// Then retry the failed invoice
A solid dunning email strategy typically includes 2–3 emails spaced over the retry window. The tone evolves: first is friendly, second is slightly more urgent, third (if needed) flags that access will be suspended.
Grace periods—Keep access for a few days
Here's a nuance that many teams miss: immediately canceling a subscription when a payment fails is often the wrong call. A brief grace period keeps the customer in your product, reduces support friction, and gives both Stripe's retries and your dunning emails time to work.
A grace period is a window (typically 3–7 days) during which a subscription is technically unpaid but the customer retains access. This achieves two things:
- The customer can resolve the issue without losing continuity
- If the retry succeeds, everything is seamless—no manual re-activation required
Implement grace periods by tracking a subscription's state independently of Stripe's invoice status. When a payment fails, mark the subscription as grace_period rather than canceled:
async function handleInvoicePaymentFailed(invoice) {
const subscription = await stripe.subscriptions.retrieve(invoice.subscription);
// Instead of canceling, enter grace period
await db.subscriptions.update(
{ stripe_subscription_id: subscription.id },
{
status: 'grace_period',
grace_period_ends_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
failed_invoice_id: invoice.id,
}
);
// Log the event for monitoring
console.log(`Subscription ${subscription.id} entered grace period`);
}
In your app's authorization logic, still allow access if the subscription is in grace_period:
async function checkSubscriptionAccess(userId) {
const subscription = await db.subscriptions.findOne({ user_id: userId });
if (subscription.status === 'active' || subscription.status === 'grace_period') {
return { hasAccess: true, message: null };
}
if (subscription.status === 'grace_period' && new Date() > subscription.grace_period_ends_at) {
// Grace period expired, revoke access
return { hasAccess: false, message: 'Your subscription has been suspended.' };
}
return { hasAccess: false, message: 'Subscription not active.' };
}
When the retry eventually succeeds, update the subscription status back to active and clear the grace period flag. This transition should happen automatically when you receive an invoice.payment_succeeded webhook:
if (event.type === 'invoice.payment_succeeded') {
const invoice = event.data.object;
await db.subscriptions.update(
{ stripe_subscription_id: invoice.subscription },
{
status: 'active',
grace_period_ends_at: null,
}
);
}
Final state—Cancel or mark unpaid after retries end
Retries have limits. After Stripe exhausts its retry schedule (typically around day 5) and your dunning emails haven't prompted a card update, the subscription must reach a final state. At this point, you have two choices: cancel the subscription or mark it as unpaid and suspended.
Most teams choose cancellation because it's clean: no lingering unpaid invoices, no ambiguous states. Stripe will automatically cancel a subscription after all retries fail if you've configured that behavior. However, some businesses prefer to suspend the subscription (keeping it in a paused state) so the customer can reactivate by updating their payment method without signing up again.
To manually cancel after grace period expiration:
async function expireGracePeriods() {
const now = new Date();
const expiredSubscriptions = await db.subscriptions.find({
status: 'grace_period',
grace_period_ends_at: { $lt: now },
});
for (const sub of expiredSubscriptions) {
await stripe.subscriptions.del(sub.stripe_subscription_id);
await db.subscriptions.update(
{ stripe_subscription_id: sub.stripe_subscription_id },
{ status: 'canceled', canceled_at: now }
);
// Notify customer one final time
await sendCancellationNotification(sub.user_id);
}
}
// Run this daily via a scheduled task
If you prefer to offer a "suspended" state that allows reactivation, keep the subscription in Stripe but pause it in your database:
await db.subscriptions.update(
{ stripe_subscription_id: sub.stripe_subscription_id },
{ status: 'suspended', suspended_at: now }
);
// Later, if customer updates payment method:
await stripe.subscriptions.update(sub.stripe_subscription_id, {
collection_method: 'charge_automatically',
});
await db.subscriptions.update(
{ stripe_subscription_id: sub.stripe_subscription_id },
{ status: 'active', suspended_at: null }
);
Record the reason for cancellation in your database so you can analyze why revenue was lost. Was it a permanent card issue? Customer non-response? A one-time payment that shouldn't have been a subscription? These patterns inform future decisions.
Common mistakes
- Overriding Stripe's retry schedule: Attempting custom retry logic that doesn't align with Stripe's optimized timing wastes engineering effort and typically recovers less revenue. Trust the platform.
- Sending dunning emails too late or not at all: If the customer doesn't know there's a problem until 5 days in, it's often too late. Send the first email within hours of the first failure.
- Canceling immediately on first failure: A single failed charge doesn't mean the customer wants to leave. Grace periods and retries recover significant revenue that immediate cancellation throws away.
- No visibility into failed payments: If you're not monitoring
invoice.payment_failedwebhooks or tracking subscription state transitions, you're flying blind. Instrument this from day one. - Ignoring the customer's reasons for non-payment: Sometimes payment fails because of a genuine dispute or a fraud block. A thoughtful email and an easy way to update the card often solves it.
Key takeaways
- Stripe's automatic retry schedule is optimized and should be your foundation; don't override it without good reason.
- Send a timely, clear email asking the customer to update their payment method within hours of the first failure.
- Implement a grace period (3–7 days) to keep the customer in your product while retries and dunning emails work.
- After the retry window closes and the customer hasn't updated their card, reach a final state: cancel the subscription or suspend it, and log the reason.
- Monitor
invoice.payment_failedandinvoice.payment_succeededwebhooks to orchestrate the entire flow; this is mission-critical instrumentation. - Recovery rates vary, but 12–15% of failed payments can typically be recovered with a proper dunning strategy.






Comments
Loading comments…