Webhooks

Everything the app knows about a subscription arrives by webhook. There's no endpoint for finishing a signup, no callback URL the client posts back to, and no trust placed in a client saying its payment went through. Stripe says so or it didn't happen.

That's a bigger deal than it sounds, because a subscription changes state in plenty of places the API never sees. A customer swaps plans in the billing portal, a renewal charge fails and dunning starts, a card expires and the subscription eventually gives up and cancels itself. None of that runs through our controllers. If the webhook isn't the commit path then half the lifecycle simply never lands.

What we listen to

The route is POST /stripe/webhook and it points straight at Cashier's own controller. Cashier verifies the signature against STRIPE_WEBHOOK_SECRET, writes the subscription row, then fires a WebhookHandled event with the raw payload. Our listeners hang off that event rather than off a custom controller, so Cashier keeps doing the parts it's good at and we only handle what it doesn't.

Two listeners live in app/Listeners/Stripe.

CommitPlan is the one that matters. It watches customer.subscription.created, updated, and deleted, and its job is to recompute the plan the user is entitled to. It resolves that off the live subscription rather than the price in the payload, which is the detail that holds a cancelled user on their plan until the grace period actually runs out. It also decides which lifecycle notification to send, if any.

CommitPaymentMethod promotes the card that paid the subscription up to the customer default. Stripe is told to save the payment method on the subscription, so it never reaches the customer on its own, and Cashier reads the brand and last four off the customer default. Skip this and a user who just paid successfully has a null card on their profile.

Why previous_attributes does the deciding

Stripe sends customer.subscription.updated for almost everything. Renewals, status flips, plan moves, card changes, cancellations, resumptions. The event type alone tells you nothing about which of those happened.

What does tell you is previous_attributes, which carries only the fields that actually changed and their old values. So a cancellation is an update where cancel_at_period_end appears in the previous set, a plan change is one where items does, and a payment clearing is one where status crossed from something dead into active or trialing.

That last one is why the subscribed notification fires on an update rather than a create. Stripe creates the subscription the moment the card is submitted, before anything has cleared, so announcing every create would congratulate users whose payment failed. The activating update is the honest signal.

When a webhook doesn't land

It happens. Rarely, but it happens, and a missed webhook means a user who paid is sitting on the wrong plan, which is the worst kind of bug to find out about from a support email.

There are a few layers under it.

Stripe retries on its own schedule for up to three days, with backoff, and that alone covers the overwhelming majority of failures. A deploy blip or a brief outage resolves itself without anyone noticing.

Every write in the listeners is idempotent, which is what makes those retries safe. Recomputing the plan from live state produces the same answer the second time. Promoting a payment method that's already the default is a no op. The only thing that can't be idempotent by accident is a notification, and that's handled by gating on previous_attributes rather than on current status, since a replayed event carries the same previous set and a genuinely unchanged one carries nothing.

Underneath that, users.stripe_id means a user's entire billing state can be rebuilt from the provider at any point. Nothing about the subscription is local-only, so there's no state to lose, just state to go and fetch.

The failover sync

SubscriptionProvider::sync() is the contract entry point for that rebuild. It pulls the live subscription from Stripe and commits the result locally, which is the same thing a webhook would have done, just triggered by us instead of by them.

There's a narrow version of it running already. When a client hits POST /subscription/intent for a second time, maybe after a refresh or a back button, the local status is only as fresh as the last webhook, so the service syncs an incomplete subscription against Stripe before deciding anything. Without that a subscription paid three seconds ago still reads incomplete and gets torn down.

The general case is the same call applied more broadly, reconciling a user whose local state has drifted from the provider's. Worth knowing it's there before you go looking for it in the webhook path, because it isn't in the webhook path at all. It's the thing you reach for when the webhook path didn't run.

Setup

Point a Stripe dashboard webhook endpoint at /stripe/webhook and put the signing secret in STRIPE_WEBHOOK_SECRET. Locally the Stripe CLI container prints a secret to its logs on startup, and the repo's own Stripe setup notes cover the exact commands.

One thing worth doing before you go live is confirming the events are actually subscribed in the dashboard. The listeners silently ignore anything they don't care about, which is correct behaviour, but it means an endpoint configured with the wrong event set fails completely quietly.

See also Plans & Subscriptions and Billing Providers.

© Websanova 2026 About Privacy