# Verifying Stripe Webhook Signatures

> "No signatures found matching the expected signature"? Read the raw body in Express, Next.js, Django, Flask or PHP and use the right whsec_.

Source: https://webhooker.eu/blog/verify-stripe-webhook-signatures
Last updated: 2026-09-30

Stripe signs every webhook with a `Stripe-Signature` header that carries a timestamp and an HMAC-SHA256 signature computed over the timestamp and the raw request body, keyed with your endpoint’s [signing secret](https://webhooker.eu/blog/stripe-webhook-signing-secret). To verify it, recompute that HMAC over the exact bytes you received, compare it in constant time, and reject anything outside Stripe’s timestamp tolerance. For the delivery side, which events to handle, how retries and duplicates behave, see the [Stripe webhooks guide](https://webhooker.eu/blog/stripe-webhooks-guide).

## How Stripe signs a webhook: the Stripe-Signature header

Stripe does not sign with a static token you compare against. It signs each request freshly, so the signature is tied to both the exact payload and the moment it was sent. When Stripe delivers an event to your endpoint, it attaches a `Stripe-Signature` header that looks like this:

```text
Stripe-Signature: t=1740000000,v1=5257a869e7...
```

Two parts matter:

- `t=` is the Unix timestamp (in seconds) when Stripe generated the signature.
- `v1=` is a hex-encoded HMAC-SHA256 signature.

Stripe builds the signed payload by joining the timestamp and the raw request body with a period:

```text
signed_payload = t + "." + raw_body
```

It then computes `HMAC-SHA256(signed_payload, signing_secret)` and puts the result in `v1`. The header can carry more than one `v1` value during a secret rotation, so treat the scheme as a comma-separated list rather than a fixed string.

You may also see a `v0` value. The only valid live signature scheme is `v1`; for test mode events, Stripe adds an extra signature with a fake `v0` scheme to help you test parsing. Ignore every scheme that is not `v1`.

## Verifying the signature correctly

The verification itself fits in a few lines of pseudocode. Most failures come from feeding it the wrong inputs, not from the HMAC call.

```text
1. read raw_body  = exact bytes of the request body
2. read header    = value of "Stripe-Signature"
3. parse header into timestamp t and one or more v1 signatures
4. signed_payload = t + "." + raw_body
5. expected       = hex( HMAC_SHA256(signing_secret, signed_payload) )
6. if no received v1 equals expected (constant-time): reject
7. if abs(now - t) > tolerance: reject
8. accept
```

Three inputs decide whether this works:

- **The raw body.** You have to hash the exact bytes Stripe sent. If your framework already parsed the JSON and you re-serialize it, key order and whitespace shift, the bytes change, and the HMAC no longer matches. A single re-ordered key or one extra space breaks the whole comparison. Capture the body before any JSON middleware touches it, usually by registering a raw-body reader on the webhook route only.
- **The endpoint signing secret.** This is the `whsec_...` value shown for that specific endpoint in the Stripe Dashboard, or returned when you create the endpoint through the API. It is not your API key. If you are not sure which value you are holding, [where to find your Stripe webhook signing secret](https://webhooker.eu/blog/stripe-webhook-signing-secret) walks through the exact click path and the keys it gets confused with.
- **A constant-time comparison.** Compare the computed and received signatures with a constant-time function, not `==`. An ordinary string compare can leak timing information about how many leading bytes matched.

Stripe’s official libraries wrap all of this in a single `constructEvent` / `Webhook.construct_event` call, and their [webhook signature documentation](https://docs.stripe.com/webhooks/signature) covers the language-specific calls. If you verify by hand, the steps above are the whole contract.

## The timestamp tolerance: stopping replays

Without the timestamp, anyone who captured one valid request could resend those exact bytes forever and the signature would still validate, because the payload never changed. Checking that `t` is recent turns a captured-and-replayed request into an expired one.

Stripe’s own libraries reject events whose timestamp falls outside a default tolerance of 5 minutes (300 seconds). See Stripe’s [webhook signature documentation](https://docs.stripe.com/webhooks/signature) for the exact behavior and how to configure it. Five minutes is wide enough to absorb normal clock drift and network delay, and narrow enough that a stolen payload goes stale quickly. If you verify manually, apply the same window, and keep your servers on NTP so a drifting clock does not start rejecting legitimate events. Widening the tolerance to paper over skew is the wrong fix; it just hands an attacker a longer replay window. We go deeper on this trade-off in [why a valid signature alone does not stop replays](https://webhooker.eu/blog/webhook-replay-attacks-timestamp-tolerance).

## How to fix “No signatures found matching the expected signature for payload”

The full message from Stripe’s Node library reads:

```text
No signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe?
```

It means the library parsed the `Stripe-Signature` header, computed an HMAC, and none of the `v1` values matched. Only two inputs can be wrong at that point: the body bytes or the signing secret. Start with the body, because that is the cause most of the time.

The same function raises a handful of other messages. They are worth telling apart, because each points at a different fix:

| Message | What it means |
| --- | --- |
| `No signatures found matching the expected signature for payload` | Header parsed fine, HMAC mismatch: wrong bytes or wrong secret |
| `Webhook payload must be provided as a string or a Buffer` | Node only: you passed a parsed object. Recent `stripe-node` versions catch this case before comparing |
| `No stripe-signature header value was provided` | The header never reached your code; a proxy or your framework dropped it |
| `Unable to extract timestamp and signatures from header` | The header is there but mangled, or you passed a different header |
| `No signatures found with expected scheme` | No `v1=` entry in the header |
| `Timestamp outside the tolerance zone` | The signature is fine but older than 300 seconds; see the tolerance section |

That second row matters more than it looks. Pass `req.body` after `express.json()` and a current `stripe-node` says so plainly. So when Node gives you “No signatures found”, you are usually handing it a string that is no longer the original: `JSON.stringify(req.body)`, a body decoded and re-encoded by a proxy, or a secret that doesn’t belong to this endpoint.

### Read the raw body in your framework

Each framework has one correct way to get the bytes before anything parses them:

| Framework | Raw body | Signature header |
| --- | --- | --- |
| Express | `express.raw({ type: "application/json" })` on the webhook route, then `req.body` | `req.headers["stripe-signature"]` |
| Next.js App Router | `await request.text()` in the route handler | `request.headers.get("stripe-signature")` |
| Next.js Pages Router | `export const config = { api: { bodyParser: false } }`, then read the request stream into a Buffer | `req.headers["stripe-signature"]` |
| Django | `request.body` | `request.META["HTTP_STRIPE_SIGNATURE"]` |
| Flask | `request.get_data()` | `request.headers["Stripe-Signature"]` |
| FastAPI | `await request.body()` | `request.headers["stripe-signature"]` |
| PHP | `file_get_contents("php://input")` | `$_SERVER["HTTP_STRIPE_SIGNATURE"]` |
| Laravel | `$request->getContent()` | `$request->header("Stripe-Signature")` |

In Express, register the webhook route before any global `app.use(express.json())`, or the global parser consumes the body first:

```js
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const event = stripe.webhooks.constructEvent(
    req.body, // a Buffer, untouched
    req.headers["stripe-signature"],
    process.env.STRIPE_WEBHOOK_SECRET,
  );
  // ... handle event ...
  res.sendStatus(200);
});
```

In the Next.js App Router, `request.json()` is the trap. Read the text once and parse it yourself after verification:

```ts
export async function POST(request: Request) {
  const rawBody = await request.text();
  const event = stripe.webhooks.constructEvent(
    rawBody,
    request.headers.get("stripe-signature")!,
    process.env.STRIPE_WEBHOOK_SECRET!,
  );
  // ... handle event ...
  return new Response(null, { status: 200 });
}
```

With Django REST Framework, read `request.body` before anything touches `request.data`. Once DRF has parsed the stream, reading `request.body` raises `RawPostDataException`.

### The body is raw and it still fails: check the secret

- The secret has to be the `whsec_...` value of the endpoint that sent this event. Every endpoint has [its own signing secret](https://webhooker.eu/blog/stripe-webhook-signing-secret), so two endpoints pointed at one handler fail about half the traffic.
- [Test mode and live mode never share a secret](https://webhooker.eu/blog/stripe-webhook-signing-secret#why-do-test-mode-and-live-mode-have-different-secrets). A handler that passes every local test and dies on the first live event has the test secret in production.
- `stripe listen` prints a secret of its own for the CLI session. Locally, use that one, not the Dashboard secret. The same applies when the CLI forwards into a Docker container: the container needs the CLI’s secret.
- Look for whitespace. A trailing newline from a `.env` file or a secrets manager changes the key. `stripe-node` adds a note to the error when it spots whitespace in the secret.

Still failing with a raw body and the right secret? Then look at a proxy rewriting the body, clock skew, or a digest compared in the wrong encoding. All of those are in [why webhook signature verification fails](https://webhooker.eu/blog/webhook-signature-verification-failed), which goes through all six in order.

## A debugging checklist

Work through these in order and the mismatch usually surfaces fast.

- **Reproduce with the Stripe CLI.** Run `stripe listen --forward-to localhost:PORT/webhook` and trigger an event. The CLI prints its own signing secret; use that one while testing locally, not your live endpoint secret.
- **Log computed vs received.** Temporarily log the timestamp, the signed payload length, your computed signature, and the received `v1`. If the lengths differ, your body is being mutated; if only the signatures differ, suspect the secret.
- **Check the content type and body capture.** Confirm the request arrives as `application/json` and that you read the raw body before any parsing middleware. In most frameworks that means registering a raw-body handler on the webhook route specifically.
- **Confirm the mode.** Check that the event and the secret are both test or both live.
- **Rule out the proxy.** Compare the byte length your app sees against what Stripe delivered. If a proxy sits in front, make sure it forwards the body untouched.
- **See the raw request first.** If you are not sure what Stripe is actually sending, point the endpoint at a [webhook tester](https://webhooker.eu/webhook-tester) for a minute: it shows the exact `Stripe-Signature` header and the byte-for-byte body, which is what you are supposed to be signing. Paste your `whsec_` secret there and it [checks the Stripe signature in the browser](https://webhooker.eu/webhook-tester?provider=stripe), so you know whether the secret or your code is the problem.

## Offloading verification to a gateway

Signature verification tends to be written once and then copied into every service that receives events. Each copy is another place where raw-body handling can drift after a framework upgrade, and another secret to rotate. The work also multiplies per provider: the same idea arrives as [a `sha256=` hex digest from GitHub](https://webhooker.eu/blog/verify-github-webhook-signature) and [a base64 digest from Shopify](https://webhooker.eu/blog/shopify-webhook-verification-gdpr), each with its own header and encoding. A [webhook gateway](https://webhooker.eu/blog/what-is-a-webhook-gateway) runs the check in one place, in front of your application code.

On [Webhooker](https://webhooker.eu/#features), signature verification is part of every plan, including Free. A source configured with the Stripe preset holds the endpoint’s `whsec_` secret and checks the `Stripe-Signature` header before a payload is accepted. The same check rejects any `t=` more than 300 seconds from the gateway’s clock. A request that fails is rejected at the gateway and never delivered; a request that passes is stored and answered with `200 OK` before delivery starts.

Delivery to your service then runs out of band, at least once, off a durable queue. The [retry schedule and replay](https://docs.webhooker.eu/deliver/retries-and-replay/) page lists the attempts; deliveries that exhaust them land in a dead-letter queue with per-attempt history and can be replayed one at a time or in bulk. Plan limits that apply here: the [Free plan](https://app.webhooker.eu/register) covers 10,000 events a month with 14-day retention, Pro 100,000 events with 30 days, and Team 1,000,000 events with 90 days. Setup is described in [inbound verification per source](https://docs.webhooker.eu/receive/verification/) and the [four-step quick start](https://docs.webhooker.eu/guides/quickstart/).

## Testing the check without Stripe

You do not need the Stripe CLI, or a Stripe account, to exercise this code path. [webhook-mock-sender](https://webhooker.eu/tools/webhook-mock-sender) builds a `payment_intent.succeeded` or `invoice.paid` event, signs it with your `whsec_` secret exactly as Stripe does — `t=<timestamp>,v1=<hex digest>` over `<timestamp>.<raw body>` — and POSTs it to your endpoint. The same command with `--invalid-signature --expect 400` proves the rejection path, and `--timestamp-offset -600 --expect 400` proves the tolerance window is enforced. It is MIT licensed and runs locally, so the signing secret never leaves your machine.

## Frequently asked questions

### Can I skip verification because I am already on HTTPS?

No. HTTPS encrypts the connection, but it does not prove who sent the request. Your endpoint URL can leak, and anyone who learns it can POST forged events that look real. Signature verification is what proves the payload actually came from Stripe and was not altered in transit. Run the check in staging as well as production, so a broken verifier shows up before live events hit it.

### How do I rotate the signing secret without downtime?

Roll the secret with a delayed expiry and make sure your verifier accepts every `v1` value in the header, not just the first one. During the overlap Stripe signs each request with both secrets. The dashboard procedure, the expiry window, and the deploy sequence are covered in [rolling the secret without dropping events](https://webhooker.eu/blog/stripe-webhook-signing-secret#how-do-you-roll-the-secret-without-dropping-events).

### Why does verification work in test mode but fail in live mode?

Almost always a secret mismatch: [test-mode and live-mode endpoints have separate signing secrets](https://webhooker.eu/blog/stripe-webhook-signing-secret#why-do-test-mode-and-live-mode-have-different-secrets), and it is easy to ship the test `whsec_...` to production by accident. Check that the secret in your live environment belongs to the live endpoint, and that you are reading the live-mode signing secret rather than a leftover CLI or test value.
