Blog / 5 min read

Stripe failed subscription payments and webhooks

Stripe failed subscription payments need more than a working checkout. Here are the webhooks, statuses and retry settings to handle before you launch.

Stripe failed subscription payments are handled in two places: your Stripe Dashboard settings decide how often Stripe retries a card, and your webhook endpoint decides what your app does while that happens. If the endpoint only listens for the first successful payment, a customer whose card expires will keep full access forever, or lose it with no warning. Both are bugs you only meet after launch.

Most of the SaaS products we're asked to look at have a checkout that works and nothing behind it. This is the list of what sits behind it.

What happens when a subscription payment fails

When a renewal is due, Stripe creates an invoice and tries to charge the card. If the charge fails, Stripe sends an invoice.payment_failed event and the subscription usually moves to past_due. A card can fail for boring reasons: it expired, the bank flagged the charge, or the customer was just short that day. Some of those clear up on a retry.

Stripe keeps creating invoices while a subscription is past_due. What happens after the retries run out is your choice in the Dashboard's subscription settings: the subscription can move to canceled, move to unpaid, or stay past_due. Pick one on purpose. The default you never looked at is the one your customers will meet.

For access, Stripe's own guidance is simple. Revoke access when the status becomes canceled or unpaid. When it's past_due, tell the customer and ask them to update their card, but don't lock them out on the first failure. Most failed payments are an expired card, and the customer would pay if they knew.

Which webhooks a subscription product needs

Skip the idea of listening to everything. Stripe recommends subscribing only to the events you use. For a normal subscription product these are the ones we wire up first:

  • invoice.paid, to extend access after a successful renewal
  • invoice.payment_failed, to start your "please update your card" emails
  • invoice.payment_action_required, for cards that need extra authentication from the customer
  • customer.subscription.updated, which fires on renewals, plan changes and status changes
  • customer.subscription.deleted, for when the subscription actually ends
  • customer.subscription.trial_will_end, sent three days before a trial ends, so you can check there's a card on file

Add charge.refunded and charge.dispute.created too if you take real money. Otherwise you'll only learn about a dispute when you happen to open the Dashboard.

A paid invoice isn't always an active subscription

This one catches people. After invoice.paid, Stripe's docs say to retrieve the subscription and confirm its status is active before you extend the customer's access. A paid invoice doesn't always move the subscription to active, for example when your settings need every outstanding invoice settled. If you grant access straight from invoice.paid, you can give it away early.

Keep one source of truth for access

We'd store a single field on your side, something like access_until or a plain status, and update it only in the webhook handler. The checkout success page shouldn't write it. Customers close tabs, lose connection and hit back buttons. The webhook is the one thing Stripe will keep retrying.

How to write the webhook handler so it doesn't lose events

Webhooks are where the quiet bugs live. Four rules cover most of them.

  1. Verify the signature on every request, using the raw request body and the whsec_ secret for that endpoint. If your framework parses and re-serialises the body first, verification fails. In Django, FastAPI and Express this is a common first-day problem.
  2. Return a 2xx quickly and do the real work afterwards. Put the event on a queue or a background job. A slow handler times out, and Stripe treats a timeout as a failure.
  3. Expect duplicates. Store the IDs of events you've processed and skip repeats.
  4. Don't rely on order. Stripe doesn't guarantee that events arrive in the order they happened. If invoice.paid arrives before customer.subscription.created, fetch the objects from the API rather than assuming your database already knows about them.

If your endpoint is down, Stripe retries live-mode events for up to three days with exponential back-off. That's a decent safety net. It's also why a broken handler can look fine for a day and then dump a pile of old events on you when you fix it.

There's one more setting that matters more than people expect. If Stripe doesn't get a successful response to invoice.created, it can delay finalising invoices that use automatic collection for up to 72 hours. A webhook endpoint that returns errors can hold up your own billing. The details are in Stripe's subscription webhooks documentation and the general webhook guide.

How do I test failed payments before launch?

Run the Stripe CLI against your local server with stripe listen --forward-to and your webhook route, then trigger events and watch what your app does. Better still, create a real test subscription in a sandbox using Stripe's test cards that decline, and walk it through the whole path: first payment, failed renewal, card update, recovery, cancellation.

We'd check these in order:

  • The customer gets an email when a payment fails, and it links to a page where they can update their card
  • Access stays on during past_due and goes off at unpaid or canceled
  • Sending the same event twice changes nothing
  • Stopping your server for an hour and then restarting it ends with the right state in your database
  • Cancelling in the Stripe customer portal updates your app without anyone touching it

If you can't tick every line, you aren't ready to charge real cards. The same goes for apps put together quickly with an AI builder: the checkout usually works and the handler behind it is a stub. We list this and eleven other things in our 12-point launch checklist for AI-built apps.

Where we'd start if the billing code already exists

Don't rewrite it. Read the webhook handler first, find which events it actually handles, and check where access_until gets set. Nine times out of ten the fix is a handful of events, a queue and a duplicate check, not a new billing system.

If you'd like a second pair of eyes, our free launch check covers billing among other things, and our SaaS development service includes subscriptions, webhooks and the emails around them. If it's an AI-built prototype with a billing problem, prototype rescue starts with a written health check that says what to keep, fix or rebuild.

Have a project in mind?

A few lines about what you need is enough to start. We work European hours from Lahore and reply to every message within 24 hours. If it helps, we'll sign an NDA before the first call.