Stripe Webhooks in Laravel: How to Make Subscription Billing Reliable

Practical patterns for handling Stripe webhooks in Laravel SaaS apps: signature checks, idempotency, queues, event ordering and testing, from building multi-tenant billing.

Person paying online with a card and laptop
Photo by rupixen on Unsplash
Short answerVerify the signature, store every event, process it in a queue, make every handler idempotent, and treat Stripe as the source of truth for subscription state. Test with the Stripe CLI and replay events on purpose.

Billing is the one part of a SaaS product where small bugs turn directly into lost money or angry customers. Charge someone twice, or keep a cancelled account active for months, and it hurts. I've built Stripe billing into several Laravel SaaS products, including multi-tenant platforms like TrackReward, and these are the patterns I now use every time.

Treat webhooks as the source of truth

It's tempting to update a customer's subscription right after checkout succeeds in the browser. But the browser isn't reliable: people close tabs, lose connection, use a back button. Stripe's webhooks are the reliable signal. Your app should listen for events like:

  • checkout.session.completed
  • invoice.paid
  • invoice.payment_failed
  • customer.subscription.updated
  • customer.subscription.deleted

and update its own records from them.

1. Verify the signature

Every webhook request must be checked against your endpoint's signing secret before you trust it. Laravel Cashier does this for you. If you handle webhooks yourself, use Stripe's library to verify. Never skip this.

2. Store the event, then respond quickly

Stripe expects a fast 200 response. I save the raw event in a stripe_events table, dispatch a queued job, and return immediately. The heavy work happens in the queue. If something fails, the event is still stored and can be retried.

3. Make every handler idempotent

Stripe can deliver the same event more than once. That's documented, normal behaviour. If handling an event twice would send two welcome emails or add credits twice, you have a bug waiting to happen.

The fix is simple: use the event ID as a unique key. Before processing, check whether you've already handled it. Wrap the processing in a database transaction.

4. Don't rely on event order

Events can arrive out of order. When it matters, I re-fetch the latest object from the Stripe API inside the job instead of trusting the event payload. That way the app always ends up in the correct state, whatever order things arrived in.

5. Make it tenant-aware

In multi-tenant apps, each event must map to the right tenant, usually through the Stripe customer ID stored on the tenant record. The queued job then initializes the correct tenant before touching any data. Getting this wrong is how one customer ends up seeing another's plan.

6. Handle failures like a human would

invoice.payment_failed shouldn't instantly lock someone out. I give a grace period, send a friendly email with a link to update the card, and only restrict the account after Stripe's retries are exhausted.

7. Test it properly

  • Use the Stripe CLI to forward events to your local app.
  • Trigger the main events on purpose: success, failure, cancellation, plan change.
  • Replay the same event twice and confirm nothing doubles.
  • Write automated tests with saved event payloads.

The payoff

When billing is built this way, it becomes the quiet part of your product. Subscriptions match reality, support tickets about charges drop, and you can trust your revenue numbers.

Billing is a core part of my SaaS and web app development service. If your billing is causing support tickets, let's look at it together.