Stripe webhook PHP fix

Fix Stripe webhook signature verification failed in PHP.

This error usually means the request body, `Stripe-Signature` header, or `whsec_` endpoint secret passed to `constructEvent()` does not match the original Stripe delivery.

Fast checklist

  • Read the raw body from `php://input` before `json_decode()`.
  • Pass the full `Stripe-Signature` header to `constructEvent()`.
  • Use the exact `whsec_` secret for the endpoint that sent the event.
  • Do not mix Stripe CLI secrets with Dashboard endpoint secrets.
  • Test with a fresh event when timestamp tolerance is involved.

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.