log in
consulting hosting industries the daily tools about contact

Stripe Billing: Don't Downgrade Users on the First Failed Payment

Stripe's retry logic is smarter than most people use it. Here's how I wire it up so customers don't lose access the moment a card declines.

The first time I accidentally downgraded a paying customer mid-cycle because their card issuer did a routine fraud hold, I got an earful. Rightfully so. The charge failed, my webhook fired, I marked the subscription inactive, and the user lost access to a service they'd been paying for without interruption for eight months. Card got sorted in two hours. The damage to trust took longer.

Stripe actually has all the machinery you need to avoid this. Most developers just don't wire it up correctly — or they wire it up and then fight against it with their own webhook logic.

What the Problem Actually Is

When a subscription renewal charge fails, Stripe doesn't immediately cancel the subscription. It puts it into a state called past_due and starts a retry schedule — called Smart Retries or, if you configure it manually, a dunning schedule. The subscription stays past_due for however long your retry window is, then moves to canceled or unpaid depending on your configuration.

The footgun: if you're listening to invoice.payment_failed and immediately downgrading or deactivating the user, you're doing Stripe's job for it, but worse. You're making a business decision (cut off access) on the very first failure, before Stripe has had a chance to retry, before the customer has even seen an email, before their bank has had time to sort out whatever transient thing triggered the decline.

The right model is: a failed payment is a warning, not a verdict. The subscription status is the verdict.

The Stripe States You Actually Need to Track

Stripe subscriptions move through these states during a failed payment cycle:

  • active — everything is fine
  • past_due — a payment failed, retries are in progress
  • unpaid — retries exhausted, but you've configured Stripe to leave it open
  • canceled — it's over

You configure what happens at the end of the retry window in your Stripe Dashboard under Billing → Settings → Subscriptions and emails → Manage failed payments. I set the final action to cancel for most clients, but for B2B SaaS where losing a seat mid-contract is a relationship problem, I sometimes use unpaid so I can handle it manually.

Smart Retries will attempt the charge 3–4 times over a window you define (up to 28 days). I usually set it to around 14 days with retries at roughly day 3, 5, and 10 after the initial failure. That's plenty of time for a card to be replaced or a bank to sort a fraud hold.

How I Wire the Webhooks

Here's the actual logic I use. The key is: only change access based on subscription status, not individual invoice events.

// app/Http/Controllers/StripeWebhookController.php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use App\Models\User;
use App\Jobs\NotifyPaymentFailed;
use App\Jobs\NotifySubscriptionCanceled;

class StripeWebhookController extends Controller
{
    public function handle(Request $request): \Illuminate\Http\Response
    {
        $payload = $request->getContent();
        $sig = $request->header('Stripe-Signature');

        try {
            $event = \Stripe\Webhook::constructEvent(
                $payload,
                $sig,
                config('services.stripe.webhook_secret')
            );
        } catch (\Stripe\Exception\SignatureVerificationException $e) {
            return response('Invalid signature', 400);
        }

        match ($event->type) {
            'invoice.payment_failed'        => $this->onPaymentFailed($event),
            'invoice.payment_succeeded'     => $this->onPaymentSucceeded($event),
            'customer.subscription.updated' => $this->onSubscriptionUpdated($event),
            'customer.subscription.deleted' => $this->onSubscriptionDeleted($event),
            default                         => null,
        };

        return response('OK', 200);
    }

    private function onPaymentFailed(\Stripe\Event $event): void
    {
        $invoice = $event->data->object;

        // Do NOT touch access here. Just notify the customer.
        // Stripe will retry. We wait for subscription status to change.
        $user = User::where('stripe_customer_id', $invoice->customer)->first();

        if (!$user) {
            return;
        }

        $attemptCount = $invoice->attempt_count;

        // Send progressively more urgent emails based on attempt count
        NotifyPaymentFailed::dispatch($user, $attemptCount);

        Log::info('Payment failed for user', [
            'user_id'       => $user->id,
            'attempt_count' => $attemptCount,
            'invoice_id'    => $invoice->id,
        ]);
    }

    private function onPaymentSucceeded(\Illuminate\Http\Request|\Stripe\Event $event): void
    {
        $invoice = $event->data->object;
        $user = User::where('stripe_customer_id', $invoice->customer)->first();

        if (!$user) {
            return;
        }

        // Restore access if we had soft-restricted them at past_due
        if ($user->subscription_status !== 'active') {
            $user->update(['subscription_status' => 'active']);
        }
    }

    private function onSubscriptionUpdated(\Stripe\Event $event): void
    {
        $subscription = $event->data->object;
        $user = User::where('stripe_customer_id', $subscription->customer)->first();

        if (!$user) {
            return;
        }

        $previousStatus = $event->data->previous_attributes->status ?? null;
        $newStatus = $subscription->status;

        $user->update(['subscription_status' => $newStatus]);

        // Only restrict access when the subscription is genuinely terminal
        if (in_array($newStatus, ['canceled', 'unpaid']) &&
            !in_array($previousStatus, ['canceled', 'unpaid'])) {
            $user->update(['has_access' => false]);
            NotifySubscriptionCanceled::dispatch($user);
        }

        // Optionally: soft-restrict at past_due (read the section below)
        if ($newStatus === 'past_due' && $previousStatus === 'active') {
            // Log it, maybe flag for customer success follow-up
            // But don't hard-cut access yet
            Log::warning('Subscription moved to past_due', ['user_id' => $user->id]);
        }
    }

    private function onSubscriptionDeleted(\Stripe\Event $event): void
    {
        $subscription = $event->data->object;
        $user = User::where('stripe_customer_id', $subscription->customer)->first();

        if (!$user) {
            return;
        }

        $user->update([
            'subscription_status' => 'canceled',
            'has_access'          => false,
        ]);

        NotifySubscriptionCanceled::dispatch($user);
    }
}

The intent in onPaymentFailed is deliberately minimal. Email the customer. That's it. Don't touch has_access. Don't downgrade the plan. Let Stripe run its retry schedule.

The Gotchas That Will Bite You

Idempotency. Stripe can and does send the same webhook more than once. Your handlers need to be idempotent. I store processed event IDs in a stripe_webhook_events table and bail early on duplicates. Cashier does some of this for you if you're using it, but if you're rolling your own (which I do for complex billing setups), you need to handle it yourself.

customer.subscription.updated fires a lot. Any metadata change, any trial extension, any proration — it all fires this event. That previous_attributes check matters. Don't assume a status field will always be in previous_attributes; it's only there if the status actually changed. Null-safe access on that field is not optional.

The past_due gray zone. There's a legitimate product question here: do you soft-restrict access at past_due? I've gone both ways. For a consumer SaaS, I usually don't restrict at all until canceled — the UX hit isn't worth it during what's often a transient card issue. For a higher-stakes B2B product where you're worried about someone running up usage they can't pay for, I'll add a soft restriction (read-only mode, no new resource creation) at past_due after the second failed attempt. That's a business call, not a technical one.

Cashier vs. raw Stripe. Laravel Cashier abstracts a lot of this, and it's great for straightforward setups. But I've hit projects where Cashier's opinionated handling of past_due fought with what the client actually wanted. Cashier by default considers a past_due subscription as inactive — which is exactly the behavior this post is arguing against. You can override it by extending Billable and overriding subscribed(), but at that point you might just be better off handling the webhooks yourself.

Test with Stripe's test clock. Stripe introduced test clocks a couple of years back and they're genuinely great for this kind of scenario. You can simulate the full dunning cycle — initial failure, retries, eventual cancellation — without waiting 14 real days. I use them in a dedicated test Stripe account to walk through the entire failure-to-cancel flow before shipping any billing changes.

When This Approach Matters Most

Every subscription product should do this, but it's especially important when:

  • Your customers are on annual plans. A declined renewal on a $1,200/year contract deserves more than a single retry before you cut access.
  • You're serving businesses (not consumers). Their AP departments update cards on cycles you don't control. Be patient.
  • Your churn math is tight. Failed payment recovery is recoverable churn. Don't manufacture it with aggressive cutoff logic.

I'd be more aggressive about immediate restriction only if I were running a service with meaningful cost-of-goods per active user — like, if every active session is burning cloud spend. Even then, I'd at least wait for the second failed attempt.

When I'd Skip the Custom Logic

If you're on a simple Cashier setup with a single subscription tier and you don't need nuanced access logic, Cashier's defaults plus Stripe's built-in dunning emails will get you 80% of the way there with almost no code. Don't over-engineer it for a product with 30 customers.

But once you have multiple tiers, B2B accounts, or meaningful ARR at risk, the defaults aren't enough. At that point the custom webhook handling pays for itself the first time it saves an annual contract from churning over a bank fraud hold.

Failed payments are a normal part of running a subscription business. Your code should treat them that way — as something to recover from, not something to punish your customer for before you've even tried.

Related

Need help shipping something like this? Get in touch.