If your Laravel application suddenly throws "Stripe webhook signature verification failed" — the Stripe PHP SDK's SignatureVerificationException, usually worded as "No signatures found matching the expected signature for payload" — every payment event your app depends on is silently dying. checkout.session.completed never fires, subscriptions never activate, and customers pay for something your system never delivers. I have debugged this exact failure in production Stripe integrations more than once, and the cause is almost never what developers suspect first. This guide walks through the five real causes, in order of likelihood, with the exact fix for each.
What "signature verification failed" actually means
Every webhook Stripe sends is signed with HMAC-SHA256 using your endpoint's signing secret (the whsec_... value). The signature arrives in the Stripe-Signature header as a timestamp plus one or more v1= hashes, for example: t=1728991234,v1=5257a869e7ecebeda32affa62cdca3eeba9f567d...
When you call Stripe\Webhook::constructEvent(), the SDK recomputes the HMAC over the payload and compares it to the header using a timing-safe comparison. If the bytes differ by even a single character — one extra space, one reordered key — verification fails. The SDK also enforces a timestamp tolerance (default 300 seconds) so old events cannot be replayed.
Understanding this is the key to everything below: Stripe signs bytes, not data. Your job is to hand the SDK the exact bytes Stripe sent, nothing more, nothing less.
Fix #1: Verify the raw request body, never a re-encoded one
This is the single most common cause I see. The controller decodes the JSON into an array and then re-encodes it before verifying:
// BROKEN — do not do this
$payload = json_encode($request->all()); // or $request->input()
$event = \Stripe\Webhook::constructEvent(
$payload,
$request->header('Stripe-Signature'),
config('services.stripe.webhook_secret')
);
A JSON round-trip through PHP's encoder is not guaranteed to reproduce Stripe's original bytes. Whitespace, key order, and number formatting can all change — and any change breaks the HMAC. The fix is one line:
// CORRECT — verify the exact bytes Stripe sent
$payload = $request->getContent(); // raw, unmanipulated body
$sigHeader = $request->header('Stripe-Signature');
$secret = config('services.stripe.webhook_secret');
$event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $secret);
Verify before you decode anything, and decode only after verification succeeds. The safe sequence is: raw body → signature verification → JSON decoding → event processing.
Why it works: HMAC signs bytes, not data
HMAC-SHA256 takes a key and a message and produces a fixed-size tag. The "message" here is the literal HTTP body. Stripe computes the tag on its side; the SDK recomputes it on yours. If your framework decoded the JSON into a PHP array and re-encoded it, you are computing the tag over a different message — similar data, different bytes. Two JSON strings can represent identical data while being different byte sequences, and HMAC will produce completely different tags for each. That is why "but the data is the same!" is never a valid defense against this error.
Fix #2: Keep Laravel middleware away from the webhook body
Even with $request->getContent(), anything upstream that mutates the request can change what the body looks like by the time your controller runs. Two classic traps:
Trap A: route placed in routes/web.php. The web group applies CSRF protection, and Stripe does not send your CSRF token — so Laravel returns 419 Page Expired before your controller ever executes. Your webhook "fails" and you never even reach signature verification. Put the route in routes/api.php, where CSRF does not apply:
// routes/api.php
use App\Http\Controllers\StripeWebhookController;
Route::post('/webhooks/stripe', [StripeWebhookController::class, 'handle']);
If the route must live behind the web group, exempt it explicitly. In Laravel 11+, configure this in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'webhooks/stripe',
]);
})
Trap B: global middleware that alters the body. TrimStrings and ConvertEmptyStringsToNull mutate string values in the request. If any custom middleware transforms the payload before your controller reads the raw content, the bytes no longer match Stripe's signature. Keep the webhook route clear of body-altering middleware — check what applies with php artisan route:list -v and look at the middleware column for your webhook route.
Fix #3: Use the right webhook secret (the Stripe CLI trap)
This one burns developers during local testing. When you run:
stripe listen --forward-to localhost:8000/api/webhooks/stripe
the CLI prints an ephemeral signing secret, something like whsec_3a8f.... That secret is different from the one shown in your Stripe Dashboard for the production endpoint. If your .env holds the dashboard secret while you test through the CLI, every local event fails signature verification — and everything looks correct because the secret is correct, just for the wrong endpoint.
Fixes:
Copy the whsec_... from the stripe listen output into your local .env as STRIPE_WEBHOOK_SECRET while testing.
Remember that each endpoint has its own secret. Rotating or re-creating the endpoint in the Dashboard invalidates the old secret.
After changing the secret, clear the config cache — a stale cached secret is a maddening variant of this bug:
php artisan config:clear
php artisan config:cache
If you cache config in production (and you should), a secret rotated in .env but not re-cached will keep failing until the cache is rebuilt.
Fix #4: Fail closed when the secret is missing
This is not a verification failure — it is worse. If STRIPE_WEBHOOK_SECRET is empty and your code skips verification as a "fallback," your endpoint accepts unsigned payloads. Anyone can POST a fake invoice.paid event and grant themselves a subscription. The failure mode is invisible: everything works in staging, someone forgets the env var in production, and your billing endpoint is open to the internet.
Make a missing secret loud and fatal:
$secret = config('services.stripe.webhook_secret');
if (empty($secret)) {
Log::critical('STRIPE_WEBHOOK_SECRET is not set — rejecting all webhook events.');
abort(500, 'Webhook secret not configured.');
}
Never do this:
// NEVER — turns a config mistake into a security hole
if (empty($secret)) {
$event = json_decode($request->getContent()); // skipping verification
}
Fix #5: Build the production-ready handler (verify, queue, respond fast)
Stripe expects a 2xx response quickly. If your controller generates an invoice PDF or updates five tables before responding, Stripe assumes a timeout and retries the event — and retries of a slow handler can double-charge or double-provision. The correct pattern: verify, dispatch to a queue, respond 200 immediately.
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessStripeWebhookJob;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
class StripeWebhookController extends Controller
{
public function handle(Request $request)
{
$secret = config('services.stripe.webhook_secret');
if (empty($secret)) {
Log::critical('STRIPE_WEBHOOK_SECRET is not set — rejecting webhook.');
abort(500, 'Webhook secret not configured.');
}
try {
$event = Webhook::constructEvent(
$request->getContent(), // raw bytes, untouched
$request->header('Stripe-Signature'),
$secret
);
} catch (\UnexpectedValueException $e) {
// Payload is not valid JSON at all
Log::warning('Invalid Stripe webhook payload.');
return response()->json(['error' => 'Invalid payload'], 400);
} catch (SignatureVerificationException $e) {
// Bytes do not match the signature
Log::error('Stripe webhook signature verification failed.', [
'message' => $e->getMessage(),
]);
return response()->json(['error' => 'Invalid signature'], 401);
}
// Heavy lifting happens in the background; Stripe gets its 200 now
ProcessStripeWebhookJob::dispatch($event->toArray());
return response()->json(['status' => 'received'], 200);
}
}
Two details worth noting. First, catch UnexpectedValueException separately: it means the body was not even valid JSON (a proxy mangled it, or someone is probing the endpoint), which is a different problem from a bad signature. Second, make the job idempotent — check the event ID against a processed_webhook_events table before acting, because Stripe will legitimately re-deliver events and your queue worker may process a job more than once.
How to diagnose it in five minutes
When the error appears, work through this checklist instead of guessing:
Reproduce locally. Run stripe listen --forward-to with the CLI's own whsec_... in your .env, then stripe trigger checkout.session.completed. If local works but production fails, the problem is the production secret or middleware, not your code.
Log what you actually received. Temporarily log the Stripe-Signature header and a hash of $request->getContent() before verification. Compare against the event in the Dashboard — if the body hash differs from what Stripe sent, something upstream mutated it.
Check the route's middleware. php artisan route:list --path=webhooks shows every middleware on the route. Anything that touches the body does not belong there.
Confirm which secret the endpoint uses. In the Dashboard, open Developers → Webhooks → your endpoint → "Signing secret". That exact value (or the CLI's, when testing locally) must be the one in your environment.
Rule out replays. Re-sending an old event from Postman or the Dashboard will fail the timestamp tolerance check even with a valid signature. Always test with fresh events.
Common pitfalls that still catch experienced developers
Decoding before verifying. $request->json()->all() followed by json_encode() is the re-encoding bug from Fix #1 wearing a different hat.
Testing with a stale config cache. You fixed the secret in .env, but config:cache still serves the old one. Rebuild it.
One secret for multiple endpoints. Each endpoint gets its own whsec_.... Copying the wrong one into the wrong environment fails silently until the first real event.
Assuming the 419 is a Stripe problem. A 419 means Laravel rejected the request before your code ran — it is a routing/middleware issue, not a signature issue.
Slow handlers causing retries. A controller that takes 25 seconds looks like a failure to Stripe, which retries — and the retry storm looks like duplicate charges. Queue everything.
Wrapping up
Nine times out of ten, "Stripe webhook signature verification failed" in Laravel comes down to one of three things: you verified a re-encoded payload instead of the raw body, the route sits behind CSRF or body-mutating middleware, or you are using the wrong endpoint's secret. Work the five-minute checklist above, apply the fix that matches, and your checkout.session.completed events will start flowing again.
If you would rather not debug payment plumbing at 2 a.m., this is exactly the kind of integration work I do — Stripe integrations and custom REST APIs built right the first time. If you are evaluating who should build yours, my guide to hiring a Laravel developer and my breakdown of custom REST API development costs are good places to start.