Handling Failed Payments
The essential playbook for implementing handling failed payments in your SaaS.
Failed recurring payments are normal in subscription SaaS. The goal is not to prevent every failure, but to recover revenue safely without granting access indefinitely or cutting users off too early.
This page shows a minimal production-ready Stripe failed-payment flow: webhook handling, local subscription state sync, retry-aware access control, customer notifications, and production debugging.
Quick Fix / Quick Setup
# Minimal Stripe failed-payment handler flow
# 1) Listen for these webhooks:
# invoice.payment_failed
# invoice.paid
# customer.subscription.updated
# customer.subscription.deleted
#
# 2) Persist Stripe IDs on your side:
# user_id, stripe_customer_id, stripe_subscription_id,
# plan, subscription_status, current_period_end,
# cancel_at_period_end, last_payment_failed_at
#
# 3) On invoice.payment_failed:
# - verify webhook signature
# - mark account as 'past_due' in DB
# - store failed invoice/payment_intent IDs
# - email user to update payment method
# - keep access or restrict based on your policy
#
# 4) On invoice.paid:
# - mark account as 'active'
# - clear dunning flags
# - restore access if previously restricted
#
# 5) On customer.subscription.deleted or unpaid/canceled terminal state:
# - revoke premium entitlements
#
# Example Flask/FastAPI pseudo-handler:
def handle_stripe_event(event):
t = event['type']
obj = event['data']['object']
if t == 'invoice.payment_failed':
sub_id = obj.get('subscription')
update_subscription(sub_id, {
'subscription_status': 'past_due',
'last_payment_failed_at': now_iso(),
'latest_invoice_id': obj.get('id')
})
queue_email('payment_failed', customer_id=obj.get('customer'))
elif t == 'invoice.paid':
sub_id = obj.get('subscription')
update_subscription(sub_id, {
'subscription_status': 'active',
'last_payment_failed_at': None,
'latest_invoice_id': obj.get('id')
})
restore_entitlements(sub_id)
elif t == 'customer.subscription.updated':
sync_subscription_from_stripe(obj)
elif t == 'customer.subscription.deleted':
update_subscription(obj['id'], {'subscription_status': 'canceled'})
revoke_entitlements(obj['id'])Use Stripe as the billing source of truth, but keep your database synced from webhooks. Do not rely only on the frontend success page or a single payment event.
What’s happening
A subscription renewal can fail because:
- the card is expired
- the bank declines the charge
- 3DS authentication is required
- the payment method was removed
- Stripe retries are exhausted
Stripe may move a subscription through states such as:
activepast_dueunpaidcanceled
Your app must decide:
- when to keep access
- when to warn users
- when to restrict premium features
- when to fully revoke entitlements
Safe production pattern:
- Process Stripe webhooks.
- Persist billing state locally.
- Notify the customer.
- Gate entitlements from your database, not from frontend assumptions.
Process Flow
Step-by-step implementation
1. Store billing state locally
Create a subscription table with enough fields to drive auth and entitlement checks.
CREATE TABLE subscriptions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL UNIQUE,
stripe_customer_id TEXT NOT NULL UNIQUE,
stripe_subscription_id TEXT UNIQUE,
plan TEXT NOT NULL,
subscription_status TEXT NOT NULL,
current_period_end TIMESTAMPTZ,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
latest_invoice_id TEXT,
last_payment_failed_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_subscriptions_stripe_subscription_id
ON subscriptions (stripe_subscription_id);
CREATE INDEX idx_subscriptions_status
ON subscriptions (subscription_status);Minimum statuses to support:
trialingactivepast_dueunpaidcanceled
2. Configure Stripe webhooks
Enable these events:
invoice.payment_failedinvoice.paidcustomer.subscription.updatedcustomer.subscription.deleted
Optional but useful:
payment_intent.payment_failedcheckout.session.completedcustomer.subscription.created
Environment variables:
STRIPE_SECRET_KEY=sk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
STRIPE_PRICE_ID=price_xxx
APP_URL=https://yourapp.comLocal webhook forwarding:
stripe listen --forward-to localhost:8000/webhooks/stripe3. Verify webhook signatures
Never trust raw POST data without Stripe signature verification.
Example in Python:
import os
import stripe
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
webhook_secret = os.environ["STRIPE_WEBHOOK_SECRET"]
@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=payload,
sig_header=sig_header,
secret=webhook_secret,
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid payload")
except stripe.error.SignatureVerificationError:
raise HTTPException(status_code=400, detail="Invalid signature")
process_event(event)
return {"received": True}4. Make processing idempotent
Stripe can retry delivery. Your handler must tolerate duplicates.
Create an event log table:
CREATE TABLE stripe_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ
);Pseudo-code:
def process_event(event):
event_id = event["id"]
event_type = event["type"]
if stripe_event_already_processed(event_id):
return
mark_event_received(event_id, event_type)
try:
dispatch_event(event)
mark_event_processed(event_id)
except Exception:
# do not mark processed on failure
raiseThis prevents:
- duplicate emails
- duplicate access revocations
- duplicate state transitions
5. Handle invoice.payment_failed
This event should start your recovery flow.
Example:
def on_invoice_payment_failed(invoice: dict):
sub_id = invoice.get("subscription")
customer_id = invoice.get("customer")
invoice_id = invoice.get("id")
payment_intent_id = invoice.get("payment_intent")
update_subscription_by_stripe_id(sub_id, {
"subscription_status": "past_due",
"last_payment_failed_at": now_utc(),
"latest_invoice_id": invoice_id,
})
save_failed_payment_metadata(
stripe_subscription_id=sub_id,
stripe_invoice_id=invoice_id,
stripe_payment_intent_id=payment_intent_id,
)
enqueue_email_once(
template="payment_failed",
unique_key=f"payment_failed:{invoice_id}",
customer_id=customer_id,
)Recommended behavior:
- mark local status
past_due - keep access during retry window if using a grace policy
- show in-app warning
- send user to billing portal or payment method update flow
Do not revoke premium access immediately unless that is an explicit policy.
6. Sync from customer.subscription.updated
Do not infer full subscription state from one invoice event. Stripe subscription updates can happen because:
- retries succeeded
- plan changed
- cancellation scheduled
- cancellation reversed
- payment method fixed
Example sync:
def sync_subscription_from_stripe(sub: dict):
update_subscription_by_stripe_id(sub["id"], {
"subscription_status": sub["status"],
"plan": extract_plan(sub),
"current_period_end": to_datetime(sub["current_period_end"]),
"cancel_at_period_end": sub.get("cancel_at_period_end", False),
"ended_at": to_datetime(sub["ended_at"]) if sub.get("ended_at") else None,
})If your local record is missing fields or looks stale, retrieve the latest object from Stripe before applying destructive changes.
7. Handle invoice.paid
This is your recovery event.
def on_invoice_paid(invoice: dict):
sub_id = invoice.get("subscription")
invoice_id = invoice.get("id")
update_subscription_by_stripe_id(sub_id, {
"subscription_status": "active",
"last_payment_failed_at": None,
"latest_invoice_id": invoice_id,
})
clear_billing_flags(sub_id)
restore_entitlements(sub_id)On successful recovery:
- clear payment warning banners
- restore restricted premium access
- stop any queued dunning sequence for that invoice window
8. Handle terminal states
Terminal non-paying states should revoke premium access.
Cases to handle:
customer.subscription.deletedcustomer.subscription.updatedwith statusunpaidcustomer.subscription.updatedwith statuscanceled
Example:
TERMINAL_STATUSES = {"unpaid", "canceled"}
def maybe_revoke_for_terminal_status(sub: dict):
if sub["status"] in TERMINAL_STATUSES:
update_subscription_by_stripe_id(sub["id"], {
"subscription_status": sub["status"],
"ended_at": now_utc(),
})
revoke_entitlements(sub["id"])Do not delete customer data. Restrict features instead.
9. Add payment method recovery
Fastest path: Stripe Billing Portal.
Example session creation:
import stripe
def create_billing_portal_session(customer_id: str, return_url: str):
session = stripe.billing_portal.Session.create(
customer=customer_id,
return_url=return_url,
)
return session.urlIf you need custom flows, use SetupIntents. For most MVPs, Billing Portal is enough.
10. Define access control clearly
Recommended minimal SaaS policy:
activeortrialing: full premium accesspast_due: keep access during retry window, show warningsunpaidorcanceled: revoke premium accesscancel_at_period_end=true: keep access until period end
Example entitlement check:
def has_premium_access(subscription) -> bool:
if not subscription:
return False
if subscription.subscription_status in ("active", "trialing"):
return True
if subscription.subscription_status == "past_due":
return within_grace_period(subscription)
return FalseKeep these checks local. Do not call Stripe on every request.
11. Add user-facing billing state in the app
Show a visible billing status page or account banner.
Minimum states to expose:
- Active
- Payment issue
- Trialing
- Scheduled cancellation
- Canceled
Users should always have a path to fix billing without contacting support.
12. Add monitoring and alerts
Track at least:
- webhook delivery failures
- spike in
invoice.payment_failed - mismatch between Stripe status and local DB
- number of accounts in
past_due - stuck event processing jobs
Example query:
SELECT subscription_status, COUNT(*)
FROM subscriptions
GROUP BY subscription_status
ORDER BY COUNT(*) DESC;event sequence diagram for checkout completed -> renewal invoice -> payment_failed -> update payment method -> invoice.paid.
Common causes
- No webhook handler for
invoice.payment_failedorinvoice.paid - Webhook signature verification failing because the wrong
whsecsecret is configured - Duplicate or non-idempotent webhook processing
- Local subscription status not synced from
customer.subscription.updated - No default payment method attached to customer or subscription
- Card declined, expired, insufficient funds, or authentication required
- App revokes access too early on first failure
- Billing portal or update-payment-method flow missing
- Using frontend success/cancel pages as billing truth
- Wrong live/test mode API keys or webhook endpoint configuration
Debugging tips
Check Stripe and your app in parallel.
Stripe CLI and API commands
stripe listen --forward-to localhost:8000/webhooks/stripe
stripe trigger invoice.payment_failed
stripe trigger invoice.paid
stripe events list --limit 20
stripe subscriptions retrieve sub_xxx
stripe invoices retrieve in_xxx
stripe payment_intents retrieve pi_xxxManual webhook replay test
curl -X POST https://yourapp.com/webhooks/stripe \
-H 'Content-Type: application/json' \
-d @event.jsonApp and database inspection
grep -i 'stripe\|webhook\|invoice.payment_failed\|invoice.paid' /var/log/app.logpsql "$DATABASE_URL" -c "
SELECT
user_id,
stripe_subscription_id,
subscription_status,
last_payment_failed_at,
latest_invoice_id
FROM subscriptions
ORDER BY updated_at DESC
LIMIT 20;
"What to verify
- Compare your local
subscription_statuswith the current Stripe subscription object. - Check if webhook deliveries are failing, delayed, or retried.
- Inspect the latest invoice, payment intent, and decline code in Stripe Dashboard.
- Confirm a valid default payment method exists.
- Verify your app is not revoking access on
invoice.payment_failedwhile Stripe is still retrying. - Confirm production is using live keys and the correct webhook secret.
If event order is inconsistent, retrieve the latest subscription object from Stripe and resync.
Checklist
- ✓ Webhook signature verification implemented
- ✓ Processed-event idempotency implemented
- ✓
invoice.payment_failedupdates local DB - ✓
invoice.paidupdates local DB - ✓
customer.subscription.updatedsyncs state - ✓
customer.subscription.deletedrevokes access - ✓ Customer can update payment method without support
- ✓ Billing warning UI added in app
- ✓ Premium entitlement rules documented
- ✓ Terminal status revokes access
- ✓ Alerts for webhook failures configured
- ✓ Recovery tested with Stripe CLI and test events
Related guides
- Stripe Subscription Setup (Step-by-Step)
- Handling Webhooks Correctly
- Managing Subscription States
- Payment Webhooks Failing
- Payment System Checklist
FAQ
Should failed payment handling depend only on invoice.payment_failed?
No. Use invoice.payment_failed as a trigger, but keep subscription state synchronized with customer.subscription.updated and terminal deletion or cancellation events.
When should access be removed?
Usually when the subscription reaches a terminal non-paying state such as unpaid or canceled, or after your defined grace period ends.
Can I rely on Stripe Smart Retries alone?
No. Smart Retries help recover revenue, but your app still needs local state sync, customer notifications, and entitlement enforcement.
What is the easiest way for users to fix payment issues?
Stripe Billing Portal is the fastest production-ready option for updating cards and managing subscriptions.
How do I avoid duplicate recovery emails?
Store processed Stripe event IDs and make email jobs idempotent per invoice or failure window.
Final takeaway
A good failed-payment flow is mostly state synchronization and entitlement discipline.
Use Stripe webhooks, keep a local billing record, notify the customer, and only revoke access based on a defined policy.
If you cannot explain exactly when access is restricted and restored, your billing system is not production-ready.