Webhooks

The client reports a finished signup by calling the sync, and the API reads the state back from Stripe rather than trusting the client's word that a payment went through. That's the fast path, but it isn't the only thing changing a subscription and it isn't always around to report. Everything else arrives by webhook.

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 there 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 an event with the raw payload. Our listeners hang off those events rather than off a custom controller, so Cashier keeps doing the parts it's good at and we only handle what it doesn't.

Four listeners live in app/Listeners/Stripe. Two ride Cashier's WebhookHandled event for the types Cashier handles itself. The other two ride WebhookReceived, because Cashier has no handler for checkout sessions or setup intents and never dispatches WebhookHandled for them.

CommitPlan is the one that matters most. 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.

CommitSubscriptionPaymentMethod promotes the card that pays a subscription to the customer default, but only where that's actually needed. Checkout sets the subscription's own default and the sync makes the same card the customer default, so nothing this API creates depends on it. What's left is a subscription started from the Stripe dashboard, which arrives carrying a card that never reached the customer. Cashier reads the brand and last four off the customer default, so without this those users keep a null card on their profile.

CommitSession listens for checkout.session.completed on subscription sessions and runs the same sync the client calls. It's the backstop for a browser that died after the confirm, a bank challenge that cleared in a tab the user had already closed, or a sync call that errored on work Stripe had already settled.

CommitSetupPaymentMethod listens for setup_intent.succeeded and makes a card the user added on its own, outside any subscription flow, the one on file. Confirming a setup intent only attaches the card to the customer and sets no default anywhere, so nothing would bill it until this runs.

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 can fire on an update as well as a create. A create only announces when the subscription is already live, because Stripe can create one before anything has cleared and announcing every create would congratulate users whose payment failed. A signup that starts out incomplete is picked up by the update that flips it live instead.

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, and the sync is a set of updateOrCreate calls against ids Stripe has already settled. 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.

One write, two triggers

The signup sync is a single write path with two triggers rather than two paths to keep in agreement. The client calls it after the confirm and CommitSession calls it when checkout.session.completed arrives. Both hand it a session id, both read the completed session from Stripe, and whichever lands second finds the same rows already written and changes nothing.

Worth knowing before you go looking for it in the webhook path. The client call isn't a webhook feature at all, it's just the same function reached from the other side.

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. That includes checkout.session.completed and setup_intent.succeeded, which are easy to leave out because Cashier doesn't need them. 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, Subscribing, and Billing Providers.

© Websanova 2026 About Privacy