Fixing Stripe Cashier Subscription and Webhook Failures
Subscriptions that say active in Stripe but cancelled in your database, webhooks returning 500s, and customers billed for plans they downgraded — the causes and the repair sequence.
Billing bugs are the worst class of Laravel bug: they are silent, they compound daily, and every one of them is a customer conversation. Most Cashier problems come down to a single root cause — your database and Stripe disagree, and nothing is reconciling them.
Start with the webhook endpoint
Stripe is the source of truth; your subscriptions table is a cache of it. That cache is only correct if webhooks are being delivered, accepted and processed.
Check, in this order:
- Delivery. In Stripe's dashboard, look at recent webhook attempts for your endpoint. Persistent 4xx or 5xx responses mean nothing downstream is trustworthy.
- The route. Cashier's webhook route must be excluded from CSRF verification. In Laravel 11 that is
$middleware->validateCsrfTokens(except: ['stripe/*'])inbootstrap/app.php. - Signature verification.
STRIPE_WEBHOOK_SECRETmust match the endpoint you are receiving from. Test-mode and live-mode secrets differ, and so does the CLI's local secret. - Raw body. Signature verification runs on the unmodified request body. Any middleware that rewrites the payload breaks it.
- Event selection. At minimum subscribe to
customer.subscription.created,.updated,.deleted,invoice.payment_succeededandinvoice.payment_failed.
The classic failure patterns
Fast redirect, slow webhook. Checkout succeeds, the customer lands on your success page, and your app shows no subscription because the webhook has not arrived yet. Fix the UX, not the timing: treat the success page as pending, and let the webhook flip state.
Non-idempotent handlers. Stripe retries. If your handler creates an invoice row or sends an email without an idempotency check, retries duplicate them. Key everything on the Stripe event id and store processed ids.
Queued handlers on a dead worker. If webhook processing is dispatched to a queue and the queue is broken, Stripe sees 200 responses while nothing happens. See recovering broken Laravel queues.
Proration and plan-swap drift. swap() versus swapAndInvoice() versus noProrate() produce materially different invoices. Choose deliberately and test both directions of every plan change.
Trials handled in two places. A trial defined on the Stripe price and also on your model produces a state neither system agrees on. Pick one owner.
Deleted customers. A customer removed in the Stripe dashboard leaves an orphaned stripe_id. Handle customer.deleted instead of letting every later call throw.
The repair sequence
- Stop the bleeding. Fix delivery and signature verification so new events land correctly.
- Replay history. Stripe lets you resend past events. Replay in chronological order against an idempotent handler.
- Reconcile. Walk every local subscription and compare it to the Stripe API: status, price id, current period end, cancel-at-period-end. Write a report before writing any fix.
- Correct with intent. Decide per discrepancy whether Stripe or your database is right. Usually Stripe wins — but refunds and credit notes are a commercial decision, not a code one.
- Add a reconciliation job. A nightly command that compares both sides and alerts on drift turns a future outage into a Slack message.
- Test it. Use the Stripe CLI (
stripe listen --forward-to) plus feature tests that assert your handler is idempotent under duplicate events.
What good looks like
- Every webhook handler is idempotent and keyed on the event id.
- The webhook endpoint responds in milliseconds and queues real work.
- A nightly reconciliation job reports drift and finds zero rows most days.
- Plan changes, cancellations and failed payments each have a feature test.
- Failed payments have a dunning flow, not silent access revocation.
If billing has already drifted and you need someone to write the reconciliation report before touching data, that is a scoped rescue sprint from $1,000. If you are not sure what shape the problem is yet, the $299 Laravel Triage Audit covers the billing surface as part of the risk register.