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

python
# 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:

  • active
  • past_due
  • unpaid
  • canceled

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:

  1. Process Stripe webhooks.
  2. Persist billing state locally.
  3. Notify the customer.
  4. Gate entitlements from your database, not from frontend assumptions.
active
payment_failed
retry window
recovered or unpaid/canceled

Process Flow

Step-by-step implementation

1. Store billing state locally

Create a subscription table with enough fields to drive auth and entitlement checks.

sql
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:

  • trialing
  • active
  • past_due
  • unpaid
  • canceled

2. Configure Stripe webhooks

Enable these events:

  • invoice.payment_failed
  • invoice.paid
  • customer.subscription.updated
  • customer.subscription.deleted

Optional but useful:

  • payment_intent.payment_failed
  • checkout.session.completed
  • customer.subscription.created

Environment variables:

bash
STRIPE_SECRET_KEY=sk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
STRIPE_PRICE_ID=price_xxx
APP_URL=https://yourapp.com

Local webhook forwarding:

bash
stripe listen --forward-to localhost:8000/webhooks/stripe

3. Verify webhook signatures

Never trust raw POST data without Stripe signature verification.

Example in Python:

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:

sql
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:

python
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
        raise

This prevents:

  • duplicate emails
  • duplicate access revocations
  • duplicate state transitions

5. Handle invoice.payment_failed

This event should start your recovery flow.

Example:

python
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:

python
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.

python
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.deleted
  • customer.subscription.updated with status unpaid
  • customer.subscription.updated with status canceled

Example:

python
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:

python
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.url

If you need custom flows, use SetupIntents. For most MVPs, Billing Portal is enough.

10. Define access control clearly

Recommended minimal SaaS policy:

  • active or trialing: full premium access
  • past_due: keep access during retry window, show warnings
  • unpaid or canceled: revoke premium access
  • cancel_at_period_end=true: keep access until period end

Example entitlement check:

python
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 False

Keep 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:

sql
SELECT subscription_status, COUNT(*)
FROM subscriptions
GROUP BY subscription_status
ORDER BY COUNT(*) DESC;
checkout completed
renewal invoice
payment_failed
update payment method
invoice.paid

event sequence diagram for checkout completed -> renewal invoice -> payment_failed -> update payment method -> invoice.paid.

Common causes

  • No webhook handler for invoice.payment_failed or invoice.paid
  • Webhook signature verification failing because the wrong whsec secret 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

bash
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_xxx

Manual webhook replay test

bash
curl -X POST https://yourapp.com/webhooks/stripe \
  -H 'Content-Type: application/json' \
  -d @event.json

App and database inspection

bash
grep -i 'stripe\|webhook\|invoice.payment_failed\|invoice.paid' /var/log/app.log
bash
psql "$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_status with 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_failed while 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_failed updates local DB
  • invoice.paid updates local DB
  • customer.subscription.updated syncs state
  • customer.subscription.deleted revokes 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

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.