Stripe Failed Payment Recovery: How to Fix It
Updated October 2026
Failed payments are one of the biggest silent revenue killers for subscription businesses. Some subscription churn is involuntary: customers who wanted to continue but whose payment failed. Many of these payments can be recovered. This tutorial walks you through measuring your failed payment recovery rate in Stripe and acting on what you find. It uses Python and the Stripe API with a secret key that has read access; there is no spreadsheet route in the steps below.
What is Failed Payment Recovery Analysis?
Failed payment recovery analysis examines the lifecycle of payment failures in your Stripe account, from the initial failure to eventual recovery or permanent loss. By understanding what percentage of failed payments you're successfully recovering and how long recovery takes, you can optimize your retry logic, customer communication, and payment update workflows to maximize revenue retention.
This analysis answers critical questions such as:
- What percentage of failed payments are we ultimately recovering?
- How long does it typically take to recover a failed payment?
- Which failure reasons have the highest recovery rates?
- Are certain customer segments more likely to resolve payment issues?
- Is our retry schedule optimized for maximum recovery?
Prerequisites and Data Requirements
Before beginning your failed payment recovery analysis, ensure you have the following:
Required Access and Permissions
- Stripe Account Access: Admin or developer access to your Stripe account
- API Keys: Secret API key with read permissions for charges, payment intents, and invoices
- Webhook Configuration (optional): To receive payment events as they happen, as in the Step 6 example
Data Requirements
- Data Volume: About four months of payment history: a 90-day analysis period plus a 30-day follow-up so each failure has had time to recover. There is no magic number of failed payments. The recovery rate is a proportion, and the code in Step 2 prints a 95% interval around it. With 100 failed payments and a rate near 40% that interval runs from about 31% to 50%; with 400 it narrows to about 35% to 45%
- Payment Method Data: Access to customer payment method information
- Invoice History: Complete invoice and payment attempt records
- Customer Metadata (optional): Subscription tier, customer lifetime value, or other segmentation data
Technical Setup
You'll need one of the following to extract and analyze your data:
- Python 3 with a current version of the Stripe library installed (
pip install stripe) - Alternative: Any programming language with Stripe SDK support (Node.js, Ruby, PHP, etc.)
Step 1: Extract Failed Payment Data from Stripe
The first step is to pull the failed payment attempts from your Stripe account. We'll focus on charges with a failed status and their associated payment intents. The analysis period ends 30 days ago, so every failure in it has had at least 30 days to recover by the time you run the script.
Using the Stripe API
import stripe
from datetime import datetime, timedelta, timezone
stripe.api_key = 'sk_test_your_secret_key_here'
# A payment that failed yesterday has had no time to recover, so leave a follow-up window
# between the end of the analysis period and today.
FOLLOW_UP_DAYS = 30
end_date = datetime.now(timezone.utc) - timedelta(days=FOLLOW_UP_DAYS)
start_date = end_date - timedelta(days=90) # analysis period: 90 days of failures
# Retrieve every charge created in the period. auto_paging_iter() requests
# the next page (100 charges each) until the list ends.
charges = stripe.Charge.list(
limit=100,
created={'gte': int(start_date.timestamp()), 'lte': int(end_date.timestamp())},
)
failed_charges = []
for charge in charges.auto_paging_iter():
if charge.status == 'failed':
failed_charges.append({
'id': charge.id,
'amount': charge.amount,
'currency': charge.currency,
'customer': charge.customer,
'payment_intent': charge.payment_intent,
'failure_code': charge.failure_code,
'failure_message': charge.failure_message,
'created': charge.created
})
print(f"Found {len(failed_charges)} failed charges from {start_date:%Y-%m-%d} to {end_date:%Y-%m-%d}")
# Each retry is a new charge on the same PaymentIntent, so count payments, not attempts:
# keep the first failed charge of each PaymentIntent and count its attempts.
failed_payments = {}
without_intent = 0
for charge in sorted(failed_charges, key=lambda c: c['created']):
pi_id = charge['payment_intent']
if pi_id is None:
without_intent += 1
continue
if pi_id not in failed_payments:
failed_payments[pi_id] = {**charge, 'attempts': 0}
failed_payments[pi_id]['attempts'] += 1
print(f"Failed charges without a payment intent (not analyzed): {without_intent}")
print(f"Failed payments (distinct payment intents): {len(failed_payments)}")
Expected Output
Found 461 failed charges from 2024-09-12 to 2024-12-11
Failed charges without a payment intent (not analyzed): 33
Failed payments (distinct payment intents): 289
The figures in this and the following Expected Output blocks come from a synthetic Stripe account generated for this tutorial, not from a real business, so your numbers will differ.
What this code does:
- Connects to the Stripe API using your secret key
- Retrieves every charge in the analysis period, 100 per request, using the library's auto-pagination
- Keeps charges with
status == 'failed'and their failure code and payment intent ID - Groups the failed charges by payment intent, because each retry is a new charge on the same payment intent. The unit of analysis is the failed payment, not the attempt. Failed charges with no payment intent are counted and reported but not analyzed
Important note: If you're using Stripe's newer Payment Intents API exclusively, you may need to query stripe.PaymentIntent.list() instead and filter for status == 'requires_payment_method' or check the last_payment_error field.
Step 2: Track Recovery Attempts and Outcomes
Now that you have your failed payments, the next step is determining which ones were eventually recovered. A payment is considered "recovered" when a later attempt on the same payment intent succeeds. Each failed payment ends up in one of three groups: recovered, not recovered, or unknown when the lookup itself failed. Unknown payments stay in the count and are reported separately, so a network error cannot shrink the denominator without you seeing it.
Matching Failed Payments to Recoveries
import time
from collections import defaultdict
from math import sqrt
def check_payment_recovery(payment_intent_id, failed_date):
"""
Did this payment intent end up succeeding after its first failure?
Returns outcome 'recovered', 'not_recovered' or 'unknown' (the lookup failed).
"""
try:
pi = stripe.PaymentIntent.retrieve(payment_intent_id, expand=['latest_charge'])
except stripe.StripeError:
# Keep the payment in the analysis as unknown instead of dropping it
return {'outcome': 'unknown'}
# For a succeeded payment intent, latest_charge is the charge that succeeded
if pi.status == 'succeeded' and pi.latest_charge and pi.latest_charge.created > failed_date:
return {
'outcome': 'recovered',
'recovery_date': pi.latest_charge.created,
'days_to_recovery': (pi.latest_charge.created - failed_date) / 86400,
'recovery_charge_id': pi.latest_charge.id
}
return {'outcome': 'not_recovered'}
def wilson_interval(successes, n, z=1.96):
"""95% Wilson score interval for a proportion, in percent."""
if n == 0:
return (0.0, 0.0)
p = successes / n
centre = (p + z**2 / (2 * n)) / (1 + z**2 / n)
half = z * sqrt(p * (1 - p) / n + z**2 / (4 * n**2)) / (1 + z**2 / n)
return (max(centre - half, 0) * 100, min(centre + half, 1) * 100)
# Analyze recovery for every failed payment
recovery_results = []
for idx, payment in enumerate(failed_payments.values()):
if idx % 100 == 0:
print(f"Processing payment {idx + 1} of {len(failed_payments)}...")
time.sleep(1) # Rate limiting
recovery_results.append({
**payment,
**check_payment_recovery(payment['payment_intent'], payment['created'])
})
# Calculate recovery statistics
recovered = [r for r in recovery_results if r['outcome'] == 'recovered']
not_recovered = [r for r in recovery_results if r['outcome'] == 'not_recovered']
unknown = [r for r in recovery_results if r['outcome'] == 'unknown']
known = len(recovered) + len(not_recovered)
recovery_rate = len(recovered) / known * 100 if known else 0
low, high = wilson_interval(len(recovered), known)
print(f"\nRecovery Analysis Results:")
print(f"Failed payments: {len(recovery_results)}")
print(f"Recovered: {len(recovered)}")
print(f"Not recovered after at least {FOLLOW_UP_DAYS} days: {len(not_recovered)}")
print(f"Outcome unknown (lookup failed): {len(unknown)}")
print(f"Recovery rate (known outcomes): {recovery_rate:.1f}% (95% interval {low:.1f}% to {high:.1f}%)")
# Value, by currency (amounts are in the smallest unit; this assumes two-decimal currencies)
failed_value, recovered_value = defaultdict(float), defaultdict(float)
for r in recovery_results:
if r['outcome'] == 'unknown':
continue
failed_value[r['currency']] += r['amount'] / 100
if r['outcome'] == 'recovered':
recovered_value[r['currency']] += r['amount'] / 100
print()
for cur in sorted(failed_value):
print(f"{cur.upper()}: recovered {recovered_value[cur]:,.2f} of {failed_value[cur]:,.2f} "
f"({recovered_value[cur] / failed_value[cur] * 100:.1f}% of value)")
Expected Output
Processing payment 1 of 289...
Processing payment 101 of 289...
Processing payment 201 of 289...
Recovery Analysis Results:
Failed payments: 289
Recovered: 99
Not recovered after at least 30 days: 182
Outcome unknown (lookup failed): 8
Recovery rate (known outcomes): 35.2% (95% interval 29.9% to 41.0%)
EUR: recovered 387.46 of 1,347.15 (28.8% of value)
USD: recovered 4,948.50 of 14,054.98 (35.2% of value)
This step gives you a baseline. In this example, 99 of the 281 failed payments with a known outcome (35.2%, 95% interval 29.9% to 41.0%) had succeeded by the time the script ran, at least 30 days after the failure. The other 182 had not. That is "not recovered by the cutoff", not proof of permanent loss: a payment can still recover later, which is why the analysis leaves out the most recent 30 days of failures. The 8 payments whose lookup failed are listed on their own line; run the script again to resolve them. By value, 35.2% of the USD amount and 28.8% of the EUR amount recovered. Counting value separately matters because recovery can differ between large and small payments.
Step 3: Calculate Time-to-Recovery Metrics
Understanding how quickly you recover failed payments is just as important as knowing your overall recovery rate. Some payments recover within hours, while others may take weeks. These numbers describe only the payments that did recover, so read them next to the recovery rate: a fast median can sit beside a poor rate.
import statistics
recovery_times = [r['days_to_recovery'] for r in recovered]
if recovery_times:
print(f"\nTime-to-Recovery Analysis (recovered payments only):")
print(f"Median recovery time: {statistics.median(recovery_times):.1f} days")
print(f"Mean recovery time: {statistics.mean(recovery_times):.1f} days")
print(f"Min recovery time: {min(recovery_times):.1f} days")
print(f"Max recovery time: {max(recovery_times):.1f} days")
# Share of ALL failed payments with a known outcome that recovered within each window.
# Every payment here has had at least FOLLOW_UP_DAYS to recover, so these are comparable.
same_day = sum(1 for t in recovery_times if t < 1)
within_week = sum(1 for t in recovery_times if t < 7)
within_month = sum(1 for t in recovery_times if t < 30)
print(f"\nRecovered within each window (share of {known} failed payments):")
print(f"Same day: {same_day} ({same_day/known*100:.1f}%)")
print(f"Within 1 week: {within_week} ({within_week/known*100:.1f}%)")
print(f"Within 30 days: {within_month} ({within_month/known*100:.1f}%)")
Expected Output
Time-to-Recovery Analysis (recovered payments only):
Median recovery time: 5.1 days
Mean recovery time: 6.0 days
Min recovery time: 0.1 days
Max recovery time: 23.7 days
Recovered within each window (share of 281 failed payments):
Same day: 14 (5.0%)
Within 1 week: 65 (23.1%)
Within 30 days: 99 (35.2%)
In this example, 65 of the 99 recoveries (65.7%) came within a week, which is 23.1% of all 281 failed payments. The windows in the second block use all failed payments with a known outcome as the denominator, so they can be compared between cohorts that had the same follow-up. The 30-day share equals the overall rate here because the longest recovery took 23.7 days. If most recoveries happen in the first week, early retries are doing the work; if recoveries are spread over weeks, compare the timing with your retry schedule and your customer messages.
Step 4: Segment by Failure Reason
Not all payment failures are created equal. Failure codes can differ in how often the payment is eventually recovered, so check rather than assume. The code groups by the first failed charge's failure_code (see Stripe's error codes) and prints a 95% interval for each row, because rows with few payments have wide intervals.
# Group by failure code (a missing code becomes 'unknown'); unknown outcomes are left out
failure_segments = defaultdict(lambda: {'total': 0, 'recovered': 0, 'recovery_times': []})
for result in recovery_results:
if result['outcome'] == 'unknown':
continue
failure_code = result['failure_code'] or 'unknown'
failure_segments[failure_code]['total'] += 1
if result['outcome'] == 'recovered':
failure_segments[failure_code]['recovered'] += 1
failure_segments[failure_code]['recovery_times'].append(result['days_to_recovery'])
# Display results sorted by volume
print("\nRecovery Rate by Failure Reason:")
print(f"{'Failure Code':<26} {'Count':<7} {'Recovered':<10} {'Rate':<8} {'95% interval':<15} {'Avg Days'}")
print("-" * 80)
sorted_failures = sorted(failure_segments.items(), key=lambda x: x[1]['total'], reverse=True)
for failure_code, stats in sorted_failures:
rate = f"{stats['recovered'] / stats['total'] * 100:.1f}%"
low, high = wilson_interval(stats['recovered'], stats['total'])
interval = f"{low:.0f}% to {high:.0f}%"
avg_days = f"{statistics.mean(stats['recovery_times']):.1f}" if stats['recovery_times'] else 'n/a'
print(f"{failure_code:<26} {stats['total']:<7} {stats['recovered']:<10} {rate:<8} {interval:<15} {avg_days}")
Expected Output
Recovery Rate by Failure Reason:
Failure Code Count Recovered Rate 95% interval Avg Days
--------------------------------------------------------------------------------
insufficient_funds 105 53 50.5% 41% to 60% 5.6
card_declined 77 19 24.7% 16% to 35% 7.9
expired_card 45 13 28.9% 18% to 43% 9.4
authentication_required 20 2 10.0% 3% to 30% 5.3
processing_error 19 12 63.2% 41% to 81% 1.1
incorrect_cvc 15 0 0.0% 0% to 20% n/a
In this example, insufficient_funds recovered at 50.5% (95% interval 41% to 60%) and card_declined at 24.7% (16% to 35%), a gap the intervals do not overlap on. Smaller rows are less certain: processing_error recovered 12 of 19 payments, but its interval runs from 41% to 81%, and incorrect_cvc recovered none of 15 (interval 0% to 20%). Differences between small rows are not established by 15 or 20 payments. Segments like these suggest what to test:
- A different retry schedule for the failure types that recover best, then compare the next cohort
- A message asking customers to update an expired card, then compare the
expired_cardrow before and after - A separate approach per failure type, judged by its interval, not by the point estimate alone
Step 5: Interpreting Your Results
Now that you have comprehensive recovery data, it's time to translate these insights into action. Here's how to interpret your results and what to compare them against.
Comparing Your Results
This tutorial does not quote industry benchmark ranges. Recovery rates depend on your customer mix, price, payment methods, retry settings and the follow-up window used, and published figures rarely define those. Compare your own cohorts instead: the same unit (the payment intent), the same follow-up window, and the 95% interval to judge whether a change is larger than noise.
Key Performance Indicators to Track
| Metric | What It Tells You | How to Read It |
|---|---|---|
| Overall Recovery Rate | Effectiveness of your entire recovery system | Compare with your earlier cohorts, with the 95% interval |
| 7-Day Recovery Rate | How well your immediate retry logic works | Share of all failed payments, not of recovered ones |
| Median Time to Recovery | Speed of recovery among payments that did recover | Read it beside the recovery rate |
| Recovery Rate by Failure Type | Which failure types need targeted strategies | Small counts give wide intervals |
Actionable Insights
Based on your analysis results, consider these optimization strategies:
If Your Recovery Rate Is Lower Than Your Earlier Cohorts:
- Implement or optimize Stripe's Smart Retries feature
- Add email campaigns to notify customers of payment failures
- Enable in-app notifications for payment issues
- Review your retry schedule. You may be giving up too early
If Recovery Takes Longer Than It Used To:
- Increase retry frequency in the first 72 hours
- Implement earlier customer outreach
- Add SMS notifications for high-value customers
- Make payment method updates easier in your UI
If Certain Failure Codes Have Low Recovery:
- expired_card: Send proactive renewal reminders 30 days before expiration
- insufficient_funds: Time retries for typical paydays (1st and 15th of month)
- authentication_required: Guide customers through 3D Secure verification
- card_declined: Prompt for alternative payment method immediately
Step 6: Implement Recovery Improvements in Stripe
Armed with data-driven insights, you can now optimize your Stripe configuration for better recovery rates. Here are the most impactful changes you can make.
Enable Smart Retries
Stripe's Smart Retries uses AI to choose when to retry failed subscription payments; the settings are described in Stripe's documentation on automatic retries. In the Dashboard, go to Billing, then Revenue recovery, then Retries. Stripe recommends Smart Retries, with a default of 8 tries within 2 weeks, and also offers a custom schedule of up to three retries. Stripe does not retry some declines (for example when the card issuer returns a hard decline code), so those payments need a new payment method from the customer.
Customize Email Notifications
Configure Stripe to send automatic emails when payments fail:
In your Stripe Dashboard's billing email settings (Stripe's documentation has the current location), turn on the customer emails for failed payments and for payments that need action. No API call is needed for this: changing a subscription's payment_behavior does not control these emails.
Implement Dunning Management
Create a multi-channel dunning campaign based on your recovery time analysis. As an example sequence to adapt (not a tested one), if your data shows most recoveries happen within 7 days:
- Day 0: Immediate email notification of failed payment
- Day 1: In-app banner prompting payment method update
- Day 3: Follow-up email with clear call-to-action
- Day 7: Final warning email before service suspension
- Day 10: SMS notification for high-value customers (optional)
Update Retry Logic Based on Failure Type
Use webhooks to implement custom retry logic based on failure codes:
import stripe
@app.route('/webhook', methods=['POST'])
def webhook_handler():
payload = request.get_data()
sig_header = request.headers.get('Stripe-Signature')
try:
event = stripe.Webhook.construct_event(
payload, sig_header, webhook_secret
)
except ValueError:
return 'Invalid payload', 400
if event['type'] == 'charge.failed':
charge = event['data']['object']
failure_code = charge.get('failure_code')
# Custom retry logic based on failure type
if failure_code == 'insufficient_funds':
# Retry more aggressively - customers often get paid soon
schedule_retry(charge['payment_intent'], delay_days=2)
elif failure_code == 'expired_card':
# Send immediate email asking for card update
send_card_update_email(charge['customer'])
schedule_retry(charge['payment_intent'], delay_days=7)
elif failure_code == 'authentication_required':
# Customer needs to complete 3DS - send reminder
send_authentication_reminder(charge['customer'])
return 'Success', 200
Common Issues and Troubleshooting
Issue 1: Recovery Rate Seems Too High or Too Low
Symptom: Your calculated recovery rate is 80%+ or below 10%
Diagnosis:
- Check your date range: very recent failures won't have had time to recover
- Verify you're correctly matching payment intents to charges
- Check that multiple failures of the same payment intent are counted once, as in Step 1
Solution: Keep the follow-up window (FOLLOW_UP_DAYS in Step 1) so recent failures are excluded, and deduplicate by payment intent ID rather than charge ID. Both are already in the code above; if your rate still looks off, report unknown lookups separately and check the interval.
Issue 2: API Rate Limiting Errors
Symptom: stripe.RateLimitError: Too many requests
Solution: Add rate limiting to your code:
import time
for idx, payment in enumerate(failed_payments.values()):
if idx % 25 == 0 and idx > 0:
time.sleep(1) # Pause for 1 second every 25 requests
# Process payment...
Issue 3: Missing Payment Intent IDs
Symptom: Many charges have payment_intent: null
Diagnosis: You may have older charges created before Payment Intents API, or charges created through Checkout sessions.
Solution: The code in Step 1 counts these charges and leaves them out of the recovery rate, so report the count next to the rate. Matching them to invoices depends on your API version, so check the Invoice object reference for how a payment is linked to an invoice in your version before writing that join.
Issue 4: Inconsistent Failure Codes
Symptom: Many failures show failure_code: null or "generic_decline"
Diagnosis: Card networks don't always provide specific decline reasons for security purposes.
Solution: Group these together and use failure_message for additional context. Consider them as lower-recovery-potential failures in your strategy.
Issue 5: Data Not Showing Expected Improvements
Symptom: You've implemented recovery strategies but recovery rate hasn't improved
Diagnosis: Changes take time to show impact, or you need more targeted interventions.
Solution:
- Wait until the new cohort has had the same follow-up window as the old one before comparing
- Compare cohorts: failures before vs. after your changes
- Segment by customer value: focus on high-LTV customer recovery first
For more advanced troubleshooting and data quality insights, explore our guide on AI-First Data Analysis Pipelines, which covers data validation and quality assurance techniques applicable to payment analytics.
Run It on Your Own Stripe Export
Export your payments from Stripe as a CSV and upload it at MCP Analytics. The analysis runs a payment recovery analysis on your file and shows the result with the method used. You choose the columns; nothing connects to your Stripe account.
Next Steps and Advanced Topics
Once you've mastered basic failed payment recovery analysis, consider exploring these advanced topics:
1. Predictive Recovery Scoring
Build machine learning models to predict recovery likelihood based on customer behavior, failure type, and payment history. This allows you to prioritize outreach to customers most likely to convert. Consider techniques like AdaBoost for classification problems.
2. Survival Analysis for Payment Retention
Apply survival analysis techniques to model "time until recovery" or "time until permanent loss." This helps optimize retry timing. The Accelerated Failure Time (AFT) model is particularly useful for this application.
3. Multi-Touch Attribution for Recovery
If you use multiple recovery channels (email, SMS, in-app, phone calls), implement attribution modeling to understand which touchpoints drive recovery. This optimizes your communication spend.
4. Customer Lifetime Value Integration
Weight your recovery efforts by customer LTV. High-value customers may warrant manual outreach or account manager intervention, while low-value customers rely on automated recovery.
5. Payment Method Diversification
Analyze whether offering alternative payment methods (ACH, SEPA, digital wallets) reduces initial failure rates or improves recovery rates compared to credit card-only approaches.
6. Geographic and Seasonal Patterns
Examine whether recovery rates vary by customer location or time of year.
Conclusion
Failed payment recovery is one of the highest-ROI optimizations you can make to your subscription business. By analyzing your current recovery rate, understanding time-to-recovery patterns, and segmenting by failure type, you can implement targeted strategies that significantly reduce involuntary churn.
Remember these key takeaways:
- Track recovery rate as a core business metric alongside MRR and churn
- Segment by failure type, different failure codes require different strategies
- Optimize retry timing based on your actual recovery curves, not generic advice
- Implement multi-channel dunning for higher-value customers
- Continuously test and iterate on your recovery workflows
Start with the analysis in this tutorial and rerun it each month with the same follow-up window, so each cohort is measured the same way. To run it on your own export, see Run It on Your Own Stripe Export above.
Explore more: Stripe Analytics: all tools, tutorials, and guides →
Not sure which plan? Compare plans →