← All articles

Verifying Stripe Webhook Signatures

Webhooker Team 8 min read
Flat illustration of Stripe webhook signature verification: a payment event split into a timestamp and a digest, hashed by a blue node carrying the Webhooker pulse and matched against the received digest with a checkmark.

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:

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:

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.

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.