Implementing Billing Retry Logic for Failed Subscription Payments
A subscription payment failed with insufficient_funds or card_declined. What next? Block the user immediately and they'll leave. Retry endlessly without logic and the bank will flag the card as compromised. Smart retry strategy balances refund recovery with user retention.
Smart Exponential Backoff with Jitter
The de facto standard for retry — exponential backoff with jitter:
import random
from datetime import datetime, timedelta
RETRY_SCHEDULE = [
timedelta(hours=1), # Attempt 2: 1 hour later
timedelta(hours=24), # Attempt 3: 1 day later
timedelta(days=3), # Attempt 4: 3 days later
timedelta(days=7), # Attempt 5: 7 days later
]
def schedule_next_retry(subscription_id: str, attempt: int) -> datetime | None:
if attempt >= len(RETRY_SCHEDULE):
# Exhausted all attempts — transition to grace period or cancel
return None
base_delay = RETRY_SCHEDULE[attempt]
jitter = timedelta(minutes=random.randint(-30, 30))
next_attempt_at = datetime.utcnow() + base_delay + jitter
db.update_subscription_retry(
subscription_id=subscription_id,
next_retry_at=next_attempt_at,
attempt_number=attempt + 1
)
return next_attempt_at
Jitter is important: without it, all subscriptions that failed simultaneously (e.g., processor outage) will retry synchronously and create peak load.
Error Code Classification: What to Retry, What Not To
Not all Stripe error codes are equally worthwhile for retry:
| Error Code | Retry | Reason |
|---|---|---|
insufficient_funds |
Yes | Funds will appear |
card_declined (generic) |
Yes | Temporary bank decline |
do_not_honor |
Yes, with delay | Temporary block |
stolen_card |
No | Permanently blocked |
card_velocity_exceeded |
Yes, 24h later | Operation limit |
expired_card |
No | New card needed |
incorrect_cvc |
No | User entered wrong CVC |
NON_RETRYABLE_CODES = {
'card_declined': ['stolen_card', 'lost_card', 'fraudulent'],
'incorrect_cvc': None,
'expired_card': None,
'invalid_account': None,
}
def should_retry(stripe_error: dict) -> bool:
code = stripe_error.get('code', '')
decline_code = stripe_error.get('decline_code', '')
if code in NON_RETRYABLE_CODES:
blocked = NON_RETRYABLE_CODES[code]
if blocked is None or decline_code in blocked:
return False
return True
Grace Period: User Doesn't Lose Access Immediately
After first failed payment — don't block, give grace period (usually 3–7 days):
def handle_payment_failure(subscription_id: str, error: dict):
subscription = db.get_subscription(subscription_id)
if not should_retry(error):
# Unrecoverable error — request payment method update
notify_update_payment_method(subscription.user_id)
db.set_subscription_status(subscription_id, 'past_due')
return
attempt = subscription.retry_attempt or 0
next_retry = schedule_next_retry(subscription_id, attempt)
if next_retry is None:
# Retries exhausted — transition to grace period or cancel
grace_end = datetime.utcnow() + timedelta(days=3)
db.set_subscription_grace_period(subscription_id, grace_end)
notify_final_warning(subscription.user_id, grace_end)
else:
db.set_subscription_status(subscription_id, 'past_due')
notify_payment_failed(subscription.user_id, next_retry, attempt + 1)
Stripe: Built-in Smart Retries
Stripe provides a built-in mechanism — Smart Retries — using ML to choose optimal retry timing based on successful payment patterns. Enable in Dashboard → Billing → Subscriptions → Smart Retries.
But Smart Retries don't replace your business logic: Stripe doesn't know how many grace period days you allow or what notifications to send.
If using Stripe Billing, subscribe to webhook events:
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, WEBHOOK_SECRET
)
except stripe.error.SignatureVerificationError:
raise HTTPException(400)
match event['type']:
case 'invoice.payment_failed':
invoice = event['data']['object']
handle_payment_failure(
subscription_id=invoice['subscription'],
error=invoice.get('last_payment_error', {})
)
case 'invoice.payment_succeeded':
# Payment succeeded after retry — restore access
restore_subscription_access(invoice['subscription'])
case 'customer.subscription.deleted':
# Subscription cancelled after all retries
handle_subscription_cancelled(invoice['subscription'])
Notifying the User
A series of notifications is critical: 42% of users update payment info after the first reminder. Push via FCM/APNs + email is mandatory.
def notify_payment_failed(user_id: str, next_retry: datetime, attempt: int):
messages = {
1: "Payment failed. We'll retry {date}.",
2: "Second attempt failed. Update your card or we'll retry {date}.",
3: "Final attempt — {date}. After that, access will be limited."
}
template = messages.get(attempt, messages[3])
send_push(user_id, template.format(date=next_retry.strftime("%d.%m at %H:%M")))
send_email(user_id, subject="Subscription Payment Issue", body=template)
Timeline
2–3 days. Retry logic with error classification + grace period + webhook handling — 2 days. Notification series + scenario testing — 0.5–1 day. Pricing is calculated individually.







