Skip to content
APIs & Integrations•7 min read•Published

Stripe Webhooks in Laravel: Verify, Dedupe and Retry Safely

How to handle Stripe webhooks in Laravel safely: verify signatures, respond fast, ignore duplicates, handle out-of-order events and replay failures without double-charging.

Aqib Javaid
Aqib Javaid
Senior Full-Stack Engineer
Glass Stripe card sending a signature-verified envelope to a Laravel queued job

If you take payments with Stripe, the money moving is only half the story. The other half arrives later as Stripe webhooks: messages that tell your app a payment succeeded, failed, was refunded or disputed. Handle them badly and you get customers who paid but have no access, double-processed orders, or a billing status that quietly drifts away from reality.

I build these integrations as part of API development and integrations work. This guide covers how to receive Stripe webhooks safely in Laravel: verifying them, ignoring duplicates, responding fast and recovering when something fails.

Why webhooks, not just the redirect after checkout#

When a customer pays, Stripe sends them back to your site. It is tempting to mark the order paid at that moment. Don't rely on it. The customer can close the tab, lose connection or never reach your success page, and payments can also change state later (failed renewals, refunds, disputes).

Webhooks are the reliable channel. Your app should treat the webhook as the source of truth for payment state and the redirect only as a friendly "thanks" page.

The six rules for a safe webhook endpoint#

1. Verify the signature#

Anyone can send a request to your public URL. Stripe signs every event with a Stripe-Signature header, and you must verify it against your endpoint's signing secret before trusting the payload. Stripe's webhook documentation explains the scheme. Verification needs the raw request body, so read it before any middleware changes it.

php
use Stripe\Webhook;
use Stripe\Exception\SignatureVerificationException;

public function handle(Request $request)
{
    $payload = $request->getContent();           // raw body
    $signature = $request->header('Stripe-Signature');

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

    // ...store and queue the event
    return response('OK', 200);
}

Keep the signing secret in configuration, never in code, and use a separate secret per environment.

2. Exempt the route from CSRF, and only that route#

Stripe is not a browser, so it cannot send a CSRF token. Exclude the webhook route from CSRF protection (in Laravel 11 and later this is done in bootstrap/app.php with validateCsrfTokens(except: [...])). Signature verification replaces CSRF protection for this one endpoint.

3. Respond fast, process later#

Stripe expects a quick success response. If your handler does slow work, such as sending email, calling other APIs or updating several tables, the request may time out and Stripe will treat it as failed and retry. Do the minimum inside the request: verify, store, queue, return 200. Do the real work in a queued job, as described in my guide to Laravel queue architecture.

4. Deduplicate by event ID#

Webhooks are delivered at least once, which means duplicates will happen. Every Stripe event has a unique id. Store it in a table with a unique index. If an insert fails because the ID already exists, you have already seen the event, so return 200 and do nothing. The same thinking applies to your own API endpoints, covered in API idempotency keys in Laravel.

5. Don't assume order#

Events can arrive out of order. A "payment failed" event might land after a later "payment succeeded" event for the same invoice. Your handler should look at the current state in your database, and where the order matters, fetch the latest object from Stripe's API instead of trusting the sequence of events.

6. Make handlers safe to run twice#

Even with deduplication, jobs can be retried. Write each handler so that running it twice leaves the same result: update by a stable key, use updateOrCreate, and check state before sending an email or granting access.

A reliable architecture#

This is the shape I use:

  1. Endpoint: verify the signature, insert the event into a stripe_events table (ID unique, payload, status), dispatch a job, return 200.
  2. Job: load the stored event, run the handler for its type, and mark it processed. On failure, let the queue retry with backoff.
  3. Handlers: one small class per event type you care about, such as invoice.paid, invoice.payment_failed, charge.refunded and checkout.session.completed.
  4. Dead-letter view: a simple admin list of events that failed repeatedly, so a human can inspect and replay them.

Storing the raw event gives you an audit trail, and it lets you replay an event after fixing a bug instead of asking Stripe to resend it.

Which events to handle#

Don't subscribe to everything. Start with the events that change what the customer can do or what you owe:

  • A checkout or payment completing successfully
  • A payment failing, so you can notify the customer and limit access if appropriate
  • Subscription updates and cancellations
  • Refunds and disputes

Fewer subscriptions mean less code to get wrong. Add more only when a feature needs them.

When Stripe can't reach you#

If your endpoint returns an error or times out, Stripe retries the delivery over a period of time, with increasing gaps between attempts. You can also resend events from the Stripe dashboard. That safety net only helps if your endpoint is idempotent and your stored events show what happened. Add monitoring so you hear about a failing endpoint before customers do. My notes on observability for Laravel SaaS cover the alerts I set up.

Testing webhooks locally#

Use the Stripe CLI to forward real test events to your local app and to trigger specific events on demand. Test these cases on purpose:

  • A valid event, an event with a bad signature and an event with a tampered body
  • The same event delivered twice
  • Events arriving out of order
  • A handler that throws an error halfway through
  • Your queue worker being stopped while events arrive

If all of these behave well in test mode, production will rarely surprise you.

Should you use Laravel Cashier?#

If you are building standard subscriptions, Laravel Cashier already ships a webhook controller that verifies signatures and handles common subscription events. It is a good starting point, and you can extend it for your own events. For more custom flows, such as saving a card now and charging later, a hand-built handler gives you more control. My article on subscription billing architecture covers when each fits.

A real-world example#

On the Stripe lead-billing project, I built a payment workflow that lets lead-generation clients save a payment method without an immediate charge, then automatically charges it when qualified leads are delivered. The status of successful and failed payments was kept in sync through webhook-driven updates. That is exactly the situation where trusting the webhook, not the browser, matters. Read the full Stripe lead billing case study.

Common mistakes to avoid#

  • Parsing the JSON body before verifying the signature
  • Doing heavy work inside the webhook request
  • Treating the checkout redirect as proof of payment
  • Using one signing secret for test and live modes
  • Returning 500 for events you simply don't handle (return 200 and ignore them)
  • Having no way to see or replay failed events

Key takeaways#

  • Webhooks, not redirects, are the source of truth for payment state.
  • Verify the signature, respond fast, then process in a queue.
  • Deduplicate on the event ID and make every handler safe to run twice.
  • Store raw events so you can audit and replay.
  • Monitor the endpoint and test failure cases before launch.

If you want a payment integration that stays correct when things go wrong, book a call and we can review your flow together.

Share this technical insight with your network

Share to LinkedIn or Facebook with key takeaways, featured media, and direct links.

Aqib Javaid

About the author

Aqib Javaid

Senior Full-Stack Engineer

8+ years building Laravel, React/Next.js and AI-powered products for startups and growing businesses - SaaS platforms, APIs, integrations and the performance work that keeps them fast. Read the case studies to see how that plays out on real projects.

4.9 from 76 client reviews

📁 Production Case Study

Case Study: Stripe Lead Billing & Payment Automation

Built a secure Stripe payment workflow that allows lead-generation clients to save payment methods without an immediate charge, then automatically charge those methods when qualified leads are delivered. Integrated the payment lifecycle with GoHighLevel and designed webhook-driven status synchronization for successful and failed payments.

Related Technical Articles

View all articles →
✦ Let's Build Together

Have a complex technical project in mind?

Available for full-stack engineering, performance audits, cloud deployments, and high-concurrency systems architecture.