Set Up Stripe Webhooks in Laravel Properly
A production-grade walk-through: install the SDK, register a CSRF-exempt route, verify the Stripe signature, queue the handler for slow work, and test the whole thing locally with the Stripe CLI.
Why webhooks need special handling in Laravel
A Stripe webhook is just an HTTP POST from Stripe to your server, carrying a signed JSON event. Laravel will happily receive it — but three defaults will bite you if you do nothing: CSRF protection will return a 419 on every call, a long-running controller will time Stripe out, and a failing handler will cause Stripe to retry the same event for days. This guide fixes all three.
Step 1: Install the Stripe PHP SDK
composer require stripe/stripe-phpAlso add your secret and webhook signing secret to .env:
STRIPE_SECRET=sk_live_or_test_... STRIPE_WEBHOOK_SECRET=whsec_...
The webhook secret is different from your API secret. You get it in the Stripe dashboard after you register an endpoint, or from the Stripe CLI during local testing (see Step 6).
Step 2: Register a dedicated route
Keep the webhook route out of your web middleware stack. There is no session, no cookie, and no CSRF token here — Stripe is the client.
use App\Http\Controllers\StripeWebhookController;
Route::post('/webhooks/stripe', [StripeWebhookController::class, 'handle'])
->name('webhooks.stripe');
If you prefer routes/web.php, exempt the path from CSRF instead:
protected $except = [
'webhooks/stripe',
];
Step 3: Verify the signature in the controller
Never trust a webhook body that has not been verified. Stripe signs every event with STRIPE_WEBHOOK_SECRET; if the signature does not match, drop the request with a 400.
<?php
namespace App\Http\Controllers;
use App\Jobs\HandleStripeEvent;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
class StripeWebhookController extends Controller
{
public function handle(Request $request): Response
{
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature');
$secret = config('services.stripe.webhook_secret');
try {
$event = Webhook::constructEvent($payload, $signature, $secret);
} catch (SignatureVerificationException $e) {
return response('invalid signature', 400);
} catch (\UnexpectedValueException $e) {
return response('invalid payload', 400);
}
HandleStripeEvent::dispatch($event->toArray());
return response('ok', 200);
}
}
Two things to notice. We pass the raw request body to constructEvent — not the parsed JSON, or the signature will never match. And we return 200 immediately. Everything slow happens in the queued job.
Step 4: Do the work in a queued, idempotent job
Stripe retries delivery for up to three days if your endpoint returns a non-2xx status or times out. That means the same event may be delivered twice. Build your job to be safe to run more than once — look up the event id in a database table and skip if you have already processed it.
<?php
namespace App\Jobs;
use App\Models\ProcessedStripeEvent;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
class HandleStripeEvent implements ShouldQueue
{
use Dispatchable, Queueable;
public function __construct(public array $event) {}
public function handle(): void
{
$eventId = $this->event['id'] ?? null;
if (! $eventId || ProcessedStripeEvent::where('event_id', $eventId)->exists()) {
return;
}
match ($this->event['type']) {
'checkout.session.completed' => $this->completed(),
'invoice.paid' => $this->invoicePaid(),
'customer.subscription.deleted' => $this->subCancelled(),
default => null,
};
ProcessedStripeEvent::create(['event_id' => $eventId]);
}
}
The processed_stripe_events table only needs two columns: a unique string event_id and a timestamp. It is the cheapest idempotency guard you can build.
Step 5: Wire up the config value
'stripe' => [
'secret' => env('STRIPE_SECRET'),
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],
Always read config values through config(), not env() — env() returns null once your config is cached in production.
Step 6: Test locally with the Stripe CLI
The Stripe CLI tunnels real events to your local app and prints the right signing secret for you.
stripe loginstripe listen --forward-to localhost:8000/webhooks/stripestripe trigger checkout.session.completedCopy the whsec_... that stripe listen prints into your local .env. In a second terminal, run php artisan queue:work so your job actually executes.
Common errors and fixes
400 “invalid signature”
You parsed the body before verifying. Always pass $request->getContent() — the raw string — into Webhook::constructEvent. Also check you are using the webhook signing secret, not the API secret.
419 from Laravel
CSRF is blocking the request. Move the route to routes/api.php or add the URI to VerifyCsrfToken::$except.
Stripe shows “endpoint responded with 5xx”
Your controller is crashing. Check storage/logs/laravel.log. Wrap the body decode in try/catch and always return a 400 on a bad payload so Stripe stops retrying.
Events processed twice
Stripe retried. Add idempotency by recording the event id — the job above already does this.
Frequently Asked Questions
What status code should my webhook return?
Return 200 as soon as the event is validated and queued. Everything else can happen asynchronously. Returning 200 stops Stripe retrying.
How many retries will Stripe send?
Stripe retries failing webhooks with exponential backoff for up to three days. If the endpoint still fails, the event is marked as failed in the dashboard.
Do I need HTTPS?
Yes. Stripe will only post to https:// URLs in live mode. Use the Stripe CLI or a tool like ngrok for local testing.
Can I subscribe to only the events I need?
Yes, and you should. In the Stripe dashboard, pick the event types your app actually handles. Fewer events mean less noise in your queue.