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. 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.
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:
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:
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 still see references to v0 in older material. In current Stripe usage, v0 relates only to the signing secret produced by the Stripe CLI when you forward events locally. It is not a field you verify in production. Verify v1.
Verifying the signature correctly
We have built this check more than once, and every time the crypto was the easy part. The verification itself is a few lines of pseudocode. The hard part is feeding it the right inputs.
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 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 covers the language-specific calls. If you verify by hand, the steps above are the whole contract.
The timestamp tolerance: stopping replays
The timestamp is not decoration. Without it, 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 roughly five minutes. See Stripe’s webhook signature documentation 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.
When the check fails
Four inputs decide the outcome — the signing secret, the raw body bytes, the hash algorithm, and the encoding of the digest — and a body that was parsed before hashing accounts for most Stripe mismatches. We keep the full diagnosis in one place rather than repeat it here: why webhook signature verification fails covers the six causes, what each provider’s error wording narrows the search to, and the order to work through them.
Two Stripe-specific traps are worth naming even so. Every endpoint carries its own signing secret, so pointing two endpoints at a single handler fails roughly half your traffic while the other half keeps working. And test mode and live mode never share a secret, which is why a handler that passes every local test dies on the first live event.
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/webhookand 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/jsonand 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.
Offloading verification to a gateway
Signature verification is easy to get wrong once and then copy into every service that receives events. Every copy is one more place the raw-body handling can quietly drift, and one more secret you have to remember to rotate. It also multiplies per provider: the same idea arrives as a sha256= hex digest from GitHub and a base64 digest from Shopify, each with its own quirks. Moving the check to a webhook gateway keeps the crypto in one place and out of your application code.
That is how Webhooker handles it: per-source signature verification runs before a payload is accepted. You register a Stripe source, set the scheme to HMAC-SHA256, and supply the signing secret, and every inbound request is checked at the gateway. The timestamp tolerance guards against replays at the same layer. A request that fails verification never reaches your application; a request that passes arrives as an already-trusted event.
Because verification happens before ingest, everything downstream inherits it. The ingest URL answers 200 OK in a few milliseconds and delivery happens out of band, so a slow or forged request never blocks Stripe. Delivery is at-least-once off a Postgres queue, failed deliveries land in a dead-letter queue with full per-attempt history, and you can replay them one at a time or in bulk, all of it operating on events that already cleared the signature check. The docs walk through how to set up a Stripe source and verification scheme. If you would rather verify Stripe signatures without writing the crypto yourself, that is the shortest path.
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. Treat it as mandatory on every webhook endpoint, in every environment, no exceptions.
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.
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, 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.