What the error means
Stripe signs the exact webhook body it sends. PHP code then verifies that body with the `Stripe-Signature` header and the endpoint signing secret. If any of those three values are missing, changed, or from a different endpoint, verification fails.
Common causes
1. The body was parsed before verification
This is the most common PHP mistake. If you call `json_decode()` first and pass the decoded array or re-encoded JSON into Stripe's verifier, the bytes no longer match the signed payload.
// Bad: verifying a decoded or re-encoded body
$data = json_decode(file_get_contents('php://input'), true);
$event = \Stripe\Webhook::constructEvent(
json_encode($data),
$_SERVER['HTTP_STRIPE_SIGNATURE'],
$endpointSecret
);
2. The wrong endpoint secret is used
Stripe Dashboard endpoints and Stripe CLI forwarding sessions use different `whsec_` secrets. A webhook from the CLI will not verify with the Dashboard endpoint secret, and a Dashboard delivery will not verify with the CLI secret.
3. The signature header is missing or incomplete
The header should look like `t=timestamp,v1=signature`. In plain PHP this is commonly read from `$_SERVER['HTTP_STRIPE_SIGNATURE']`.
4. The event is old or replayed from logs
Stripe signatures include a timestamp. Replaying an old saved request can fail tolerance checks even when the body and secret were originally correct.
Correct PHP pattern
Lab check: an equivalent `constructEvent()` flow passed with PHP 8.1.9 and stripe-php 20.2.1, including rejection of a tampered body, old timestamp, and wrong secret. Verify this pattern in your PHP 8.2/8.3 environment before production.
<?php
require __DIR__ . '/vendor/autoload.php';
$endpointSecret = 'whsec_your_endpoint_secret';
$payload = @file_get_contents('php://input');
$signature = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
try {
$event = \Stripe\Webhook::constructEvent(
$payload,
$signature,
$endpointSecret
);
} catch (\UnexpectedValueException $exception) {
http_response_code(400);
exit('Invalid payload');
} catch (\Stripe\Exception\SignatureVerificationException $exception) {
http_response_code(400);
exit('Invalid signature');
}
if ($event->type === 'checkout.session.completed') {
$session = $event->data->object;
// Fulfill the order here.
}
http_response_code(200);
echo 'ok';
How to test the fix
Send a fresh webhook from Stripe or the Stripe CLI, log only whether the raw body, signature header, and endpoint secret are present, then verify with `constructEvent()` before decoding or handling the event.