Handling Failed Subscription Payments: A Complete Dunning Strategy
When a customer's credit card expires or gets declined, you have roughly 7-10 days to recover that payment before they start considering alternatives. Yet most...
Osmoto Team
Senior Software Engineer

When a customer's credit card expires or gets declined, you have roughly 7-10 days to recover that payment before they start considering alternatives. Yet most SaaS businesses lose 30-40% of customers to involuntary churn simply because they don't have a systematic approach to handling failed payments. The difference between businesses that recover 70% of failed payments and those that recover 20% isn't luck—it's having a well-designed dunning strategy.
Dunning (from the German word "mahnen," meaning to remind or urge) is the systematic process of communicating with customers about overdue payments. In subscription billing, this becomes critical because failed payments are inevitable—cards expire, banks flag transactions, and customers change payment methods without updating their subscriptions. Without proper dunning logic, these temporary payment failures become permanent customer losses.
This guide covers how to build a comprehensive dunning strategy that combines Stripe's built-in retry logic with custom business rules, automated email sequences, and proactive customer communication to maximize payment recovery while maintaining positive customer relationships.
Understanding Payment Failure Types and Retry Logic
Before building your dunning strategy, you need to understand why payments fail and how Stripe handles retries automatically. Payment failures fall into three categories, each requiring different approaches:
Temporary Failures (Should Retry)
- Insufficient funds: Customer temporarily doesn't have enough money
- Card issuer declined: Bank's fraud detection or temporary security hold
- Processing errors: Network timeouts or temporary gateway issues
- Authentication required: 3D Secure challenges that weren't completed
Permanent Failures (Should Not Retry)
- Card expired: Customer needs to update payment method
- Card lost/stolen: Payment method is permanently invalid
- Account closed: Bank account or card has been closed
- Invalid card details: Wrong card number, CVV, or billing address
Soft Declines vs Hard Declines
Stripe categorizes failures as "soft" (temporary, worth retrying) or "hard" (permanent, don't retry). However, this classification isn't perfect—some "soft" declines like insufficient funds might persist for weeks, while some "hard" declines like expired cards might work if the customer has automatic card updating enabled.
// Stripe webhook handler for failed payments export async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) { const subscription = await stripe.subscriptions.retrieve(invoice.subscription as string); const customer = await stripe.customers.retrieve(subscription.customer as string); // Get the payment intent to understand failure reason const paymentIntent = await stripe.paymentIntents.retrieve( invoice.payment_intent as string ); const failureCode = paymentIntent.last_payment_error?.decline_code; const failureType = categorizeFailure(failureCode); // Start appropriate dunning sequence based on failure type await startDunningSequence(customer, subscription, failureType, failureCode); } function categorizeFailure(declineCode: string | undefined): 'soft' | 'hard' | 'auth_required' { const softDeclines = ['insufficient_funds', 'generic_decline', 'try_again_later']; const hardDeclines = ['expired_card', 'lost_card', 'stolen_card', 'pickup_card']; const authRequired = ['authentication_required', 'three_d_secure_required']; if (!declineCode) return 'soft'; if (hardDeclines.includes(declineCode)) return 'hard'; if (authRequired.includes(declineCode)) return 'auth_required'; return 'soft'; }
Configuring Stripe's Smart Retries
Stripe automatically retries failed payments using machine learning to optimize timing, but you should configure this behavior rather than relying on defaults. Smart Retries analyze factors like customer payment history, failure reason, and time of day to determine optimal retry timing.
Setting Up Smart Retries
// Configure subscription with optimized retry settings const subscription = await stripe.subscriptions.create({ customer: customerId, items: [{ price: priceId }], payment_behavior: 'default_incomplete', payment_settings: { payment_method_types: ['card'], save_default_payment_method: 'on_subscription', }, // Enable smart retries with custom settings automatic_tax: { enabled: false }, collection_method: 'charge_automatically', // This enables the default smart retry behavior }); // For more control, configure at the invoice level const invoice = await stripe.invoices.create({ customer: customerId, auto_advance: true, collection_method: 'charge_automatically', // Custom retry settings payment_settings: { payment_method_types: ['card'], default_mandate: null, }, });
Customizing Retry Behavior
While Smart Retries work well for most cases, you might want custom behavior for specific customer segments:
// Custom retry logic for high-value customers async function configureCustomRetries(subscription: Stripe.Subscription) { const customer = await stripe.customers.retrieve(subscription.customer as string); const customerValue = await getCustomerLifetimeValue(customer.id); if (customerValue > 10000) { // High-value customers get more aggressive retries await stripe.subscriptions.update(subscription.id, { metadata: { custom_retry_count: '7', retry_interval_days: '1,3,7,14,21,28,35', dunning_priority: 'high' } }); } else if (customerValue < 100) { // Low-value customers get fewer retries to reduce processing costs await stripe.subscriptions.update(subscription.id, { metadata: { custom_retry_count: '3', retry_interval_days: '3,7,14', dunning_priority: 'low' } }); } }
Building Multi-Channel Communication Sequences
Effective dunning isn't just about payment retries—it's about communicating with customers through multiple channels at the right times. Your communication sequence should escalate gradually while providing clear paths to resolution.
Email Sequence Design
Design your email sequence to balance urgency with customer experience:
Day 0 (Immediate): Soft notification
- Subject: "We couldn't process your payment for [Product]"
- Tone: Helpful, assumes it's a simple issue
- CTA: Update payment method
- Include: One-click payment update link
Day 3: First reminder
- Subject: "Action needed: Update your payment method"
- Tone: Still friendly but more direct
- Include: Account benefits at risk, easy resolution steps
- CTA: Multiple options (update card, contact support, pause account)
Day 7: Urgency increase
- Subject: "Your [Product] account will be suspended soon"
- Tone: More urgent, clear consequences
- Include: Specific suspension date, what they'll lose access to
- CTA: Update payment method or contact support immediately
Day 14: Final notice
- Subject: "Final notice: Your account will be cancelled"
- Tone: Professional but firm
- Include: Cancellation date, data retention policy, reactivation process
- CTA: Last chance to update payment method
// Email sequence configuration interface DunningEmailConfig { dayOffset: number; template: string; urgencyLevel: 'low' | 'medium' | 'high' | 'critical'; channels: ('email' | 'sms' | 'in_app')[]; actions: string[]; } const dunningSequence: DunningEmailConfig[] = [ { dayOffset: 0, template: 'payment_failed_soft', urgencyLevel: 'low', channels: ['email', 'in_app'], actions: ['update_payment_method', 'retry_payment'] }, { dayOffset: 3, template: 'payment_failed_reminder', urgencyLevel: 'medium', channels: ['email', 'sms'], actions: ['update_payment_method', 'contact_support', 'pause_account'] }, { dayOffset: 7, template: 'account_suspension_warning', urgencyLevel: 'high', channels: ['email', 'sms', 'in_app'], actions: ['update_payment_method', 'contact_support'] }, { dayOffset: 14, template: 'final_cancellation_notice', urgencyLevel: 'critical', channels: ['email', 'sms'], actions: ['update_payment_method', 'contact_support'] } ];
In-App Messaging Strategy
Don't rely solely on email—many users don't check email regularly or your messages might end up in spam. In-app messages have higher visibility and can provide immediate resolution paths:
// In-app notification system interface InAppNotification { type: 'banner' | 'modal' | 'sidebar'; urgency: 'info' | 'warning' | 'error'; dismissible: boolean; actions: NotificationAction[]; } async function showPaymentFailureNotification(userId: string, failureType: string) { const notification: InAppNotification = { type: failureType === 'expired_card' ? 'modal' : 'banner', urgency: 'warning', dismissible: false, actions: [ { label: 'Update Payment Method', action: 'open_billing_portal', style: 'primary' }, { label: 'Contact Support', action: 'open_support_chat', style: 'secondary' } ] }; await notificationService.create(userId, notification); }
SMS Integration for Critical Notifications
For high-value customers or critical payment failures, SMS can be more effective than email:
// SMS dunning for high-priority cases async function sendSMSNotification(customer: Stripe.Customer, urgencyLevel: string) { // Only send SMS for medium+ urgency and customers who opted in if (urgencyLevel === 'low' || !customer.metadata?.sms_notifications) { return; } const phone = customer.phone; if (!phone) return; const message = urgencyLevel === 'critical' ? `URGENT: Your ${process.env.PRODUCT_NAME} account will be cancelled tomorrow. Update your payment method: ${process.env.BILLING_PORTAL_URL}` : `Your ${process.env.PRODUCT_NAME} payment failed. Please update your payment method: ${process.env.BILLING_PORTAL_URL}`; await smsService.send(phone, message); }
Implementing Graduated Response Strategies
Not all customers should be treated the same during dunning. Your response should be proportional to customer value, payment history, and failure type.
Customer Segmentation for Dunning
interface CustomerDunningProfile { segment: 'high_value' | 'standard' | 'at_risk' | 'new'; retryCount: number; communicationFrequency: 'aggressive' | 'standard' | 'minimal'; escalationPath: 'account_manager' | 'support' | 'automated'; gracePeriod: number; // days before service suspension } async function determineCustomerDunningProfile(customerId: string): Promise<CustomerDunningProfile> { const customer = await stripe.customers.retrieve(customerId); const subscriptions = await stripe.subscriptions.list({ customer: customerId }); const invoices = await stripe.invoices.list({ customer: customerId, limit: 12 }); // Calculate customer metrics const lifetimeValue = calculateLifetimeValue(invoices.data); const paymentHistory = analyzePaymentHistory(invoices.data); const subscriptionAge = getSubscriptionAge(subscriptions.data[0]); // Segment based on multiple factors if (lifetimeValue > 5000 && paymentHistory.successRate > 0.9) { return { segment: 'high_value', retryCount: 7, communicationFrequency: 'standard', escalationPath: 'account_manager', gracePeriod: 14 }; } else if (subscriptionAge < 30) { return { segment: 'new', retryCount: 5, communicationFrequency: 'aggressive', escalationPath: 'support', gracePeriod: 7 }; } else if (paymentHistory.failureCount > 3) { return { segment: 'at_risk', retryCount: 3, communicationFrequency: 'minimal', escalationPath: 'automated', gracePeriod: 5 }; } return { segment: 'standard', retryCount: 5, communicationFrequency: 'standard', escalationPath: 'support', gracePeriod: 10 }; }
Service Degradation vs Suspension
Rather than immediately suspending accounts, consider graduated service restrictions:
// Graduated service restrictions enum ServiceLevel { FULL = 'full', LIMITED = 'limited', READ_ONLY = 'read_only', SUSPENDED = 'suspended' } async function applyServiceRestrictions(customerId: string, daysPastDue: number) { const profile = await determineCustomerDunningProfile(customerId); let serviceLevel: ServiceLevel; if (daysPastDue <= 3) { serviceLevel = ServiceLevel.FULL; } else if (daysPastDue <= 7) { serviceLevel = ServiceLevel.LIMITED; // Reduce API limits, disable new features } else if (daysPastDue <= profile.gracePeriod) { serviceLevel = ServiceLevel.READ_ONLY; // View data only, no modifications } else { serviceLevel = ServiceLevel.SUSPENDED; // No access } await updateCustomerServiceLevel(customerId, serviceLevel); await notifyCustomerOfRestrictions(customerId, serviceLevel, daysPastDue); }
Proactive Payment Method Management
The best dunning strategy prevents failures before they happen. Implement proactive monitoring and customer communication about upcoming payment method issues.
Card Expiration Monitoring
// Monitor for cards expiring soon async function monitorCardExpirations() { const nextMonth = new Date(); nextMonth.setMonth(nextMonth.getMonth() + 1); const expiringCards = await stripe.paymentMethods.list({ customer: customerId, type: 'card', // Note: Stripe doesn't support filtering by expiration date directly // You'll need to check this client-side or store expiration data }); for (const card of expiringCards.data) { const expMonth = card.card?.exp_month; const expYear = card.card?.exp_year; if (expMonth === nextMonth.getMonth() + 1 && expYear === nextMonth.getFullYear()) { await sendCardExpirationWarning(card.customer as string, card.id); } } } async function sendCardExpirationWarning(customerId: string, paymentMethodId: string) { const updateUrl = `${process.env.BILLING_PORTAL_URL}?update_payment_method=${paymentMethodId}`; await emailService.send({ to: customerId, template: 'card_expiring_soon', data: { updateUrl, expirationDate: 'next month' // Format appropriately } }); }
Automatic Card Updating
Enable Stripe's automatic card updating to reduce expiration-related failures:
// Enable automatic card updating when creating payment methods const paymentMethod = await stripe.paymentMethods.create({ type: 'card', card: { token: cardToken }, // This enables automatic updates from card networks metadata: { automatic_updates: 'enabled' } }); // Attach to customer with automatic updating await stripe.paymentMethods.attach(paymentMethod.id, { customer: customerId, });
Payment Method Health Scoring
Track payment method reliability to proactively identify problematic cards:
interface PaymentMethodHealth { id: string; successRate: number; recentFailures: number; lastSuccessfulPayment: Date; riskScore: 'low' | 'medium' | 'high'; } async function calculatePaymentMethodHealth(paymentMethodId: string): Promise<PaymentMethodHealth> { const recent = await stripe.charges.list({ payment_method: paymentMethodId, created: { gte: Math.floor(Date.now() / 1000) - (90 * 24 * 60 * 60) }, // Last 90 days limit: 100 }); const successful = recent.data.filter(charge => charge.status === 'succeeded'); const failed = recent.data.filter(charge => charge.status === 'failed'); const successRate = successful.length / (successful.length + failed.length); const recentFailures = failed.filter(charge => charge.created > Math.floor(Date.now() / 1000) - (7 * 24 * 60 * 60) ).length; let riskScore: 'low' | 'medium' | 'high' = 'low'; if (successRate < 0.7 || recentFailures > 2) riskScore = 'high'; else if (successRate < 0.9 || recentFailures > 0) riskScore = 'medium'; return { id: paymentMethodId, successRate, recentFailures, lastSuccessfulPayment: new Date(successful[0]?.created * 1000), riskScore }; }
Advanced Recovery Techniques
Beyond standard retry logic, implement advanced techniques to maximize payment recovery rates.
Alternative Payment Methods
When card payments fail repeatedly, offer alternative payment methods:
// Offer alternative payment methods for failed cards async function offerAlternativePaymentMethods(customerId: string, failedAmount: number) { const customer = await stripe.customers.retrieve(customerId); const country = customer.address?.country || 'US'; // Determine available payment methods by country const availableMethods = getAvailablePaymentMethods(country, failedAmount); // Create setup intent for alternative payment method const setupIntent = await stripe.setupIntents.create({ customer: customerId, payment_method_types: availableMethods, usage: 'off_session', metadata: { reason: 'card_payment_failed', original_payment_method: 'card' } }); // Send email with alternative payment options await emailService.send({ to: customer.email, template: 'alternative_payment_methods', data: { setupIntentClientSecret: setupIntent.client_secret, availableMethods, amount: failedAmount } }); } function getAvailablePaymentMethods(country: string, amount: number): string[] { const methods = ['card']; // Always include card as backup switch (country) { case 'US': methods.push('us_bank_account'); if (amount >= 1) methods.push('ach_debit'); break; case 'DE': methods.push('sepa_debit'); break; case 'GB': methods.push('bacs_debit'); break; // Add more countries as needed } return methods; }
Payment Plan Options
For customers experiencing financial difficulties, offer payment plans:
// Create payment plan for failed subscription payment async function createPaymentPlan(subscriptionId: string, missedPayments: number) { const subscription = await stripe.subscriptions.retrieve(subscriptionId); const totalOwed = missedPayments * (subscription.items.data[0].price?.unit_amount || 0); // Create payment plan with 3 installments const installmentAmount = Math.ceil(totalOwed / 3); const paymentPlan = await stripe.subscriptions.create({ customer: subscription.customer, items: [{ price_data: { currency: subscription.currency, product: subscription.items.data[0].price?.product as string, unit_amount: installmentAmount, recurring: { interval: 'month', interval_count: 1 } } }], metadata: { type: 'payment_plan', original_subscription: subscriptionId, installment_count: '3', total_owed: totalOwed.toString() }, // End after 3 payments cancel_at: Math.floor(Date.now() / 1000) + (90 * 24 * 60 * 60) }); // Pause original subscription during payment plan await stripe.subscriptions.update(subscriptionId, { pause_collection: { behavior: 'void', resumes_at: Math.floor(Date.now() / 1000) + (90 * 24 * 60 * 60) } }); return paymentPlan; }
Personalized Recovery Offers
Use customer data to create targeted recovery offers:
// Generate personalized recovery offers async function generateRecoveryOffer(customerId: string, failureReason: string) { const customer = await stripe.customers.retrieve(customerId); const usage = await getCustomerUsageMetrics(customerId); const paymentHistory = await getPaymentHistory(customerId); let offer; if (failureReason === 'insufficient_funds' && usage.engagement > 0.8) { // High-engagement customer with money issues - offer discount offer = { type: 'discount', percentage: 25, duration: 3, // months message: 'We value your loyalty. Here\'s 25% off for the next 3 months.' }; } else if (paymentHistory.lifetimeValue > 1000 && failureReason === 'expired_card') { // High-value customer with expired card - offer extended trial offer = { type: 'extended_trial', days: 14, message: 'Take 2 weeks to update your payment method - on us.' }; } else if (usage.engagement < 0.3) { // Low engagement - offer feature highlight offer = { type: 'feature_highlight', features: ['premium_feature_1', 'premium_feature_2'], message: 'Don\'t miss out on these features you haven\'t tried yet.' }; } return offer; }
Common Pitfalls in Dunning Implementation
Over-Communication Fatigue
One of the biggest mistakes is bombarding customers with too many notifications. This creates negative brand association and can push customers away permanently:
// Implement communication frequency limits interface CommunicationLimits { maxEmailsPerWeek: number; maxSMSPerMonth: number; cooldownBetweenChannels: number; // hours } const communicationLimits: Record<string, CommunicationLimits> = { high_value: { maxEmailsPerWeek: 3, maxSMSPerMonth: 4, cooldownBetweenChannels: 24 }, standard: { maxEmailsPerWeek: 2, maxSMSPerMonth: 2, cooldownBetweenChannels: 48 }, at_risk: { maxEmailsPerWeek: 1, maxSMSPerMonth: 1, cooldownBetweenChannels: 72 } }; async function checkCommunicationLimits(customerId: string, channel: string): Promise<boolean> { const profile = await determineCustomerDunningProfile(customerId); const limits = communicationLimits[profile.segment]; const recentCommunications = await getCommunicationHistory(customerId, 7); // Last 7 days if (channel === 'email' && recentCommunications.emails >= limits.maxEmailsPerWeek) { return false; } if (channel === 'sms' && recentCommunications.sms >= limits.maxSMSPerMonth) { return false; } // Check cooldown between different channels const lastCommunication = recentCommunications.latest; if (lastCommunication && (Date.now() - lastCommunication.timestamp) < (limits.cooldownBetweenChannels * 60 * 60 * 1000)) { return false; } return true; }
Ignoring Payment Method Preferences
Not all customers want to receive SMS notifications or phone calls. Respect their communication preferences:
// Respect customer communication preferences interface CustomerPreferences { email: boolean; sms: boolean; phone: boolean; inApp: boolean; urgencyThreshold: 'low' | 'medium' | 'high'; // When to escalate channels } async function getCustomerPreferences(customerId: string): Promise<CustomerPreferences> { const customer = await stripe.customers.retrieve(customerId); return { email: customer.metadata?.email_notifications !== 'false', sms: customer.metadata?.sms_notifications === 'true', phone: customer.metadata?.phone_notifications === 'true', inApp: true, // Always enabled for critical notifications urgencyThreshold: (customer.metadata?.urgency_threshold as any) || 'medium' }; } async function sendRespectfulNotification(customerId: string, urgency: string, message: string) { const preferences = await getCustomerPreferences(customerId); const channels: string[] = []; // Always use in-app for critical messages if (urgency === 'critical') { channels.push('in_app'); } // Respect preferences for other channels if (preferences.email) channels.push('email'); if (preferences.sms && (urgency === 'critical' || (urgency === 'high' && preferences.urgencyThreshold !== 'high'))) { channels.push('sms'); } // Send through approved channels only for (const channel of channels) { if (await checkCommunicationLimits(customerId, channel)) { await sendNotification(customerId, channel, message); } } }
Poor Webhook Reliability
Dunning systems depend heavily on webhooks, but webhook failures can break your entire recovery process:
// Robust webhook handling with retry logic async function handleWebhookWithRetry(event: Stripe.Event, maxRetries = 3) { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await processWebhookEvent(event); return; // Success, exit retry loop } catch (error) { console.error(`Webhook processing failed (attempt ${attempt}):`, error); if (attempt === maxRetries) { // Final attempt failed - queue for manual review await queueFailedWebhook(event, error); throw error; } // Exponential backoff await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000)); } } } async function queueFailedWebhook(event: Stripe.Event, error: any) { // Store failed webhooks for manual processing await database.failedWebhooks.create({ eventId: event.id, eventType: event.type, error: error.message, data: event.data, createdAt: new Date() }); // Alert engineering team await alerting.send({ level: 'error', message: `Webhook ${event.type} failed after all retries`, context: { eventId: event.id, error: error.message } }); }
Best Practices Summary
Implementing an effective dunning strategy requires balancing automation with personalization, urgency with customer experience. Here are the key principles:
Communication Strategy:
- Segment customers based on value, payment history, and engagement
- Use graduated messaging that increases urgency over time
- Respect customer communication preferences and implement frequency limits
- Provide multiple resolution paths (update payment, contact support, payment plans)
Technical Implementation:
- Configure Stripe Smart Retries but don't rely solely on defaults
- Implement robust webhook handling with retry logic and failure queues
- Monitor payment method health proactively
- Use multiple communication channels (email, SMS, in-app) strategically
Business Logic:
- Apply graduated service restrictions rather than immediate suspension
- Offer alternative payment methods and payment plans for different failure types
- Create personalized recovery offers based on customer data
- Track and optimize recovery metrics continuously
Customer Experience:
- Make payment updates as frictionless as possible with direct links to billing portal
- Provide clear explanations of what happens next and when
- Offer human support escalation paths for high-value customers
- Maintain professional, helpful tone even in final notices
The most successful dunning strategies recover 60-80% of failed payments while maintaining positive customer relationships. This requires treating dunning not as collections, but as customer success—helping customers resolve payment issues so they can continue using your product.
For businesses looking to implement comprehensive subscription billing with sophisticated dunning logic, our Stripe Subscriptions service includes complete dunning strategy implementation, from webhook handling to multi-channel communication sequences. We've helped SaaS companies improve payment recovery rates by 40-60% through properly configured dunning systems that balance automation with customer experience.
Related Articles

