ds@mcpanalytics.aiEmail your data and your question and get an answer about your own data, with the method shown.Email your data and question. Get an answer.

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:

Prerequisites and Data Requirements

Before beginning your failed payment recovery analysis, ensure you have the following:

Required Access and Permissions

Data Requirements

Technical Setup

You'll need one of the following to extract and analyze your data:

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:

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:

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:

If Recovery Takes Longer Than It Used To:

If Certain Failure Codes Have Low Recovery:

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:

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:

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:

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.

Try it on your data

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:

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 →

Your turn

Bring your own data and the question you actually need answered.

MCP AnalyticsData science by email Send us your data and your question, and we will reply. ds@mcpanalytics.ai