CanHook
Features Pricing Docs FAQ Blog Log in Get started free

How to Verify Stripe Webhook Signatures the Right Way

By The CanHook Team · September 2, 2026 · 1226 words
In short

Stripe webhook signature verification means recomputing an HMAC-SHA256 hash over the raw, unparsed request body plus a timestamp, then comparing it in constant time to the `v1` value in the Stripe-Signature header. It confirms the event came from Stripe and wasn't altered, and it rejects anything older than the default five-minute tolerance window.

Stripe signs every webhook event it sends you with an HMAC-SHA256 signature in the Stripe-Signature header. If your handler doesn't check it, anyone who finds the endpoint URL can POST a fake checkout.session.completed event and your code will act on it as if a customer actually paid. Verifying that signature is the step most integration guides skim past, and it's the step most likely to fail silently in a way that looks like a wrong secret when the real problem is how you read the body.

Stripe webhook signature verification is the process of recomputing an HMAC over the raw request body and comparing it, in constant time, against the signature Stripe sent, confirming the event both came from Stripe and wasn't altered in transit.

What Does Stripe's Webhook Signature Actually Verify?

It verifies two things: authenticity (the request came from Stripe, not an attacker who guessed your URL) and integrity (the body wasn't changed after Stripe signed it, whether by a proxy, a load balancer, or a bug in your own middleware). The Stripe-Signature header carries a Unix timestamp and one or more v1 hashes, formatted as t=1700000000,v1=5257a869.... Stripe includes that timestamp specifically so a captured request can't be replayed indefinitely: by default it rejects anything more than five minutes old, per Stripe's own signature verification docs.

How Do You Verify a Stripe Signature in Your Handler?

The steps are the same in every language; only the syntax changes:

  1. Read the raw POST body as a string. Never call a JSON-parsing helper on it first.
  2. Split the Stripe-Signature header on commas into t (timestamp) and v1 (signature).
  3. Build the signed payload string as timestamp . '.' . rawBody.
  4. Compute hash_hmac('sha256', signedPayload, secret) using your webhook signing secret.
  5. Compare the result to v1 with a constant-time comparison function, and confirm the timestamp is inside your tolerance window.

In PHP, that looks like this:

<?php
// Stripe requires the RAW request body -- never json_decode() first.
$payload = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$secret = getenv('STRIPE_WEBHOOK_SECRET');

$parts = [];
foreach (explode(',', $sigHeader) as $pair) {
    [$k, $v] = explode('=', $pair, 2);
    $parts[$k] = $v;
}
$timestamp = $parts['t'] ?? null;
$expectedSig = $parts['v1'] ?? null;

$signedPayload = $timestamp . '.' . $payload;
$computedSig = hash_hmac('sha256', $signedPayload, $secret);

$tolerance = 300; // seconds -- matches Stripe's default
$fresh = $timestamp && abs(time() - (int) $timestamp) <= $tolerance;
$valid = $expectedSig && hash_equals($computedSig, $expectedSig) && $fresh;

if (!$valid) {
    http_response_code(400);
    exit('Invalid signature');
}

$event = json_decode($payload, true);

The same trap exists outside PHP. In Express, app.use(express.json()) applied globally consumes and re-encodes the stream before your route runs, so the Stripe webhook route needs its own express.raw({type: 'application/json'}) middleware ahead of the global parser. In Flask, hash whatever bytes your framework reports as the raw payload before you call anything that decodes it — reading the parsed object back into JSON and hashing that reconstruction is the mistake, not the framework itself.

Why Does Verification Fail Even When the Secret Is Correct?

The most common cause is framework middleware that parses the body into an object before your webhook route ever sees it. Once the body has been decoded and re-encoded, even by a formatter that only changes key order or whitespace, the bytes no longer match what Stripe hashed, and the HMAC comparison fails. The fix is to register the webhook route to read the raw stream before any global JSON-parsing middleware touches it.

The second common cause is secret mismatch. Stripe issues a distinct signing secret per webhook endpoint, not per account, so a test-mode endpoint and a live-mode endpoint pointed at the same URL use two different secrets even though they post to identical code. If you've rotated a signing secret recently and only updated one environment, verification will fail on the other until both match.

A third, quieter cause is clock skew. The tolerance check compares the timestamp in the header against your own server's clock, so a server whose time has drifted even a few minutes from real time — a container that never runs NTP, a VM paused and resumed — will reject valid signatures as too old. If verification fails consistently and the secret is confirmed correct on both sides, checking the server's actual time against real time is worth doing before anything else.

How Do You Test Signature Verification Without Faking Payloads?

Hand-building a fixture payload and computing your own signature is fine for a unit test, but it can't catch the bugs that only show up on a request that actually arrived over the network: a proxy that reformats the body, a framework that trims trailing whitespace, a load balancer that decompresses and re-encodes. For those, you need a real inbound request to inspect.

A CanHook catch URL gives you a plain HTTPS endpoint that accepts the real POST from Stripe, stores the exact headers and raw body it received, and lets you read the untouched Stripe-Signature value before your own code ever touches it. Point a Stripe test-mode webhook at it, trigger one event, and you can copy the exact raw body and header your handler needs to verify. See how request capture works for the endpoint setup.

Local Tunnel vs Stripe CLI vs a Hosted Catch URL

Local tunnelStripe CLI listenHosted catch URL
Needs a process running on your machineYesYesNo
Captured request survives after you close itNoNoYes, for your plan's retention window
A teammate can see the same raw requestNoNoYes
Works without exposing your dev laptopNoNoYes

Isn't the Stripe CLI Enough for This?

For iterating on your own machine, yes: stripe listen forwards real signed events and is the fastest loop for a single developer. The gap is what happens after. The forwarding session is tied to your terminal, and once you close it, the request is gone; there's nothing left to hand a teammate debugging the same signature failure a day later, and nothing to compare against if the bug only reproduces in a shared staging environment. A hosted catch URL keeps the raw request around so the second person to look at it doesn't have to reproduce it from scratch.

Test Your Handler Against a Real Stripe Event

Create a free CanHook endpoint, add it as a webhook destination on a Stripe test-mode account, and fire one event at it. It takes about a minute, and the free plan's two endpoints are enough to run this alongside your normal dev capture. Sign up free and you'll have a real, correctly-formatted Stripe-Signature header to build your handler against instead of a fixture you assembled by hand. For signature handling on other providers, see verifying GitHub webhook signatures.

What Should You Log When Verification Fails?

Log the timestamp, whether the check passed, and the reason if it didn't, but never log the raw payload or the secret itself in application logs that other services can read; Stripe events routinely carry customer emails and billing metadata. If you need the actual failing body to debug a one-off case, a capture history with its own access control handles that, rather than your general-purpose log aggregator. Keep the failure count visible somewhere you'll notice it: a spike in signature failures right after a deploy almost always means the raw-body path broke, not that Stripe changed anything on their end.

Frequently asked questions

How do I verify a Stripe webhook signature in PHP?

Read the raw POST body with file_get_contents('php://input'), split the Stripe-Signature header into its timestamp and v1 value, build timestamp.'.'.rawBody, compute hash_hmac('sha256', ...) with your signing secret, and compare with hash_equals. Never json_decode the body before hashing it.

Why does Stripe signature verification fail even with the correct secret?

The most common cause is middleware that parses the JSON body before your webhook route reads it; re-encoding changes the bytes so the HMAC no longer matches. The second common cause is using a test-mode secret against a live-mode endpoint, or the reverse, since Stripe issues a separate secret per webhook endpoint.

What is the default tolerance window for a Stripe webhook timestamp?

Five minutes (300 seconds) by default. Stripe includes a Unix timestamp in the Stripe-Signature header specifically so a captured request can't be replayed against your endpoint indefinitely, and verification should reject anything outside that window.

Can I test Stripe webhook signature verification without a live Stripe account?

Yes. A Stripe test-mode account and the Stripe CLI's trigger command fire real, correctly signed test events. Point them at a capture endpoint first so you can inspect the exact raw body and header before wiring up your production handler.

Does Stripe retry a webhook if my endpoint times out or returns an error?

Yes. If your endpoint doesn't return a 2xx status, Stripe retries the event automatically with backoff, for up to three days in live mode according to Stripe's own documentation, so a slow or failing handler doesn't silently lose events.

Should I verify the signature before or after parsing the JSON body?

Before. Verification has to run against the exact raw bytes Stripe hashed. Parsing the body into an object and re-serializing it, even if the data looks identical, can change whitespace or key order and break the HMAC comparison.