Stripe signs every webhook with an HMAC-SHA256 digest of the raw request body. Verifying that signature is the only thing standing between your fulfilment logic and a stranger POSTing a fake payment_intent.succeeded at your endpoint to ship themselves a product they never paid for. (For the full Stripe-side reference, see our Stripe provider page.)
I've been on both sides of this. I've watched constructEvent throw at 2am with a deadline two hours away and no idea which of seven things broke. I've also shipped HMAC signing on the sender side of WebhookWhisper, which means I had to think very carefully about all the ways receivers get this wrong. This post is the version of the guide I wish I'd had during both.
I'll cover what the signature actually is, the canonical Stripe SDK call in five languages, the seven verification failures I see most often (with the actual fix for each), how to roll your own verifier if you have to, and how to test the whole thing end-to-end without burning a real Stripe charge.
Why signature verification is non-negotiable
Without verification, your webhook endpoint is a public POST endpoint that triggers business logic. Anyone who knows the URL — and webhook URLs leak constantly: from server logs, from .env files committed to public GitHub repos, from screenshots in Slack — can craft a payload that looks identical to the real thing and trigger your fulfilment.
The threat is concrete:
- Order fulfilment forgery. POST
checkout.session.completedwith a real-looking session object → your code ships a product, sends a license key, or grants account access for a payment that never happened. - Subscription state manipulation. POST
customer.subscription.createdwith the attacker's email → they get whatever access tier your code grants on subscription start. - Refund triggering. POST
charge.refundedfor a real charge ID → some implementations issue a credit on their own ledger before checking with Stripe's API. - Replay attacks. Even legitimate Stripe events can be captured (e.g. via a logging service) and re-sent. Without timestamp tolerance, the same successful charge fulfils five times.
Stripe's signature scheme defends against all of these. It's HMAC-SHA256 over a string that includes the timestamp, signed with a per-endpoint secret you configure in the dashboard. If the math checks out and the timestamp is recent, you know two things at once: the request came from Stripe, and it isn't a replay.
Verification is also part of basic webhook security hygiene — every provider that signs (Stripe, GitHub, Shopify, Twilio, Slack) does it for the same reason, and the verification recipe shape is the same across all of them. Get it right once and the next provider is a 30-minute integration.
How the Stripe-Signature header works
Every Stripe webhook arrives with a Stripe-Signature header. The format looks like this:
Stripe-Signature: t=1712000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd,v0=legacy_value_ignore_me
Three comma-separated fields:
t— Unix timestamp (seconds) when Stripe sent the event. Used for replay protection.v1— Hex-encoded HMAC-SHA256 of the signed payload. This is the field you actually verify.v0— Legacy scheme. Ignore. Stripe still sends it for backward compatibility but no current SDK uses it.
The "signed payload" Stripe computes the HMAC over is constructed by concatenating the timestamp, a literal period, and the raw request body bytes:
signed_payload = "{t}.{raw_request_body}"
expected_v1 = HMAC-SHA256(signing_secret, signed_payload).hex()
So the verification flow your code (or the SDK) runs is:
- Parse the
Stripe-Signatureheader intotandv1. - Recompute
expected_v1 = HMAC-SHA256(secret, "{t}.{raw_body}"). - Compare
expected_v1tov1using a constant-time comparison (not==— see "Roll your own" below for why). - Check that
now() - t < tolerance. Stripe's SDKs default to 300 seconds. - If both pass, the request is authentic and recent. Otherwise, reject with HTTP 400.
If you want to play with this interactively — paste a body and a secret, watch the signature get computed in your browser — we built a free HMAC signature playground that does exactly this for Stripe (and four other providers). The math runs entirely client-side, so your secret never reaches a server.
The single most important rule: raw body, not parsed JSON
Before any code, the one thing that causes more Stripe signature failures than every other cause combined: your verification call must be passed the exact bytes Stripe sent, not a re-serialized JSON object.
HMAC is a checksum over bytes. If your HTTP framework parses the JSON body and your handler then receives a JavaScript object, the bytes are gone. When the SDK helper re-stringifies that object to verify, the result will not match Stripe's signature — keys may be in a different order, whitespace will be normalized away, Unicode escapes may be expanded.
Every framework has a different escape hatch for this:
- Express:
express.raw({ type: 'application/json' })on the webhook route, beforeexpress.json(). - Fastify: register a content-type parser that returns the raw buffer for that route.
- Next.js (Pages Router):
export const config = { api: { bodyParser: false } }and read withmicroor buffer manually. - Next.js (App Router):
await req.text()in the route handler — Next gives you the raw text by default. - Django:
request.bodyreturns raw bytes. Just don't accessrequest.POSTorjson.loadsfirst. - Flask:
request.get_data()for raw bytes. Don't callrequest.get_json()before verifying. - Go (net/http):
io.ReadAll(r.Body)— straightforward. - Rails:
request.body.readin the controller action. Skip CSRF for this route. - PHP:
file_get_contents('php://input').
I've seen this bug ship to production at three startups I've consulted with, and I made the same mistake myself the first time I integrated Stripe in 2019. It is universal. If your verification is failing and you can't see why, this is the first thing to check. The error-page reference is stripe-signature-mismatch (and the cross-provider version is signature-mismatch).
Implementation — Node.js (recommended)
The Stripe Node SDK does the entire flow in one call. The trick, as above, is the raw body middleware:
import Stripe from 'stripe'
import express from 'express'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET // whsec_...
const app = express()
// ⚠️ Route-specific raw body — must come BEFORE express.json()
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature']
let event
try {
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret)
} catch (err) {
console.error('Signature verification failed:', err.message)
return res.status(400).send(`Webhook Error: ${err.message}`)
}
// Signature verified — now it's safe to act on the event
switch (event.type) {
case 'payment_intent.succeeded':
await fulfillOrder(event.data.object)
break
case 'customer.subscription.deleted':
await revokeAccess(event.data.object)
break
default:
console.log('Unhandled event type:', event.type)
}
// Acknowledge fast — Stripe times out at 30s
res.json({ received: true })
}
)
// Global JSON middleware for everything else
app.use(express.json())
What constructEvent does for you: parses the Stripe-Signature header, recomputes the HMAC, does a constant-time comparison, and enforces the 300-second timestamp tolerance. If anything fails, it throws a StripeSignatureVerificationError with a message that tells you which check failed. Always log err.message — it's specific and useful.
Implementation — Python (Django and Flask)
import stripe
from django.conf import settings
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
stripe.api_key = settings.STRIPE_SECRET_KEY
webhook_secret = settings.STRIPE_WEBHOOK_SECRET
@csrf_exempt
def stripe_webhook(request):
payload = request.body # raw bytes — Django keeps these
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE')
try:
event = stripe.Webhook.construct_event(
payload, sig_header, webhook_secret
)
except ValueError:
# Invalid payload (not JSON, etc.)
return HttpResponseBadRequest('Invalid payload')
except stripe.error.SignatureVerificationError as e:
return HttpResponseBadRequest(f'Signature verification failed: {e}')
if event['type'] == 'payment_intent.succeeded':
fulfill_order(event['data']['object'])
return HttpResponse(status=200)
For Flask, the shape is identical — read request.get_data() and pass it to the same stripe.Webhook.construct_event call. @csrf_exempt in Django (or skipping CSRF in Flask) is essential — Stripe doesn't have a CSRF token and never will.
Implementation — Go
package main
import (
"io"
"log"
"net/http"
"os"
"github.com/stripe/stripe-go/v76/webhook"
)
func handleStripeWebhook(w http.ResponseWriter, r *http.Request) {
const maxBodyBytes = int64(65536)
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
payload, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
return
}
event, err := webhook.ConstructEvent(
payload,
r.Header.Get("Stripe-Signature"),
os.Getenv("STRIPE_WEBHOOK_SECRET"),
)
if err != nil {
log.Printf("Signature verification failed: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
switch event.Type {
case "payment_intent.succeeded":
// handle payment success
case "customer.subscription.deleted":
// handle cancellation
}
w.WriteHeader(http.StatusOK)
}
The http.MaxBytesReader wrap is a small but important hardening — Stripe payloads are typically under 16 KB, but without a cap any client (legitimate or malicious) can stream gigabytes at your handler before you discover the body isn't really a webhook.
Implementation — Ruby (Rails)
require 'stripe'
class StripeWebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def create
payload = request.body.read
sig_header = request.env['HTTP_STRIPE_SIGNATURE']
secret = ENV['STRIPE_WEBHOOK_SECRET']
begin
event = Stripe::Webhook.construct_event(payload, sig_header, secret)
rescue JSON::ParserError, Stripe::SignatureVerificationError => e
return head :bad_request
end
case event.type
when 'payment_intent.succeeded'
FulfillmentJob.perform_later(event.data.object.id)
end
head :ok
end
end
skip_before_action :verify_authenticity_token is the Rails-specific equivalent of Django's @csrf_exempt. Without it, Rails will reject the POST as a CSRF attempt before your handler runs.
Implementation — PHP
<?php
require_once 'vendor/autoload.php';
\Stripe\Stripe::setApiKey(getenv('STRIPE_SECRET_KEY'));
$endpoint_secret = getenv('STRIPE_WEBHOOK_SECRET');
$payload = file_get_contents('php://input');
$sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
try {
$event = \Stripe\Webhook::constructEvent(
$payload, $sig_header, $endpoint_secret
);
} catch (\UnexpectedValueException $e) {
http_response_code(400); exit('Invalid payload');
} catch (\Stripe\Exception\SignatureVerificationException $e) {
http_response_code(400); exit('Invalid signature');
}
if ($event->type === 'payment_intent.succeeded') {
fulfill_order($event->data->object);
}
http_response_code(200);
file_get_contents('php://input') reads the raw request body. It's the only safe way — $_POST is parsed and useless for HMAC verification.
Rolling your own verifier (when you have to)
Most of the time you should use the SDK. But there are real situations where you can't — minimal serverless functions where the SDK is too heavy, an Edge Worker with no Node compatibility, a language Stripe doesn't ship a library for. Here's the canonical implementation, in Node, with the gotchas called out:
import crypto from 'crypto'
function verifyStripeSignature(rawBody, sigHeader, secret, toleranceSeconds = 300) {
// 1. Parse the Stripe-Signature header
const parts = Object.fromEntries(
sigHeader.split(',').map(kv => kv.split('='))
)
const timestamp = parts.t
const v1 = parts.v1
if (!timestamp || !v1) throw new Error('Invalid signature header')
// 2. Recompute the HMAC
const signedPayload = `${timestamp}.${rawBody}`
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex')
// 3. Constant-time comparison — NOT == or ===
const expectedBuf = Buffer.from(expected, 'hex')
const receivedBuf = Buffer.from(v1, 'hex')
if (expectedBuf.length !== receivedBuf.length) throw new Error('Signature mismatch')
if (!crypto.timingSafeEqual(expectedBuf, receivedBuf)) {
throw new Error('Signature mismatch')
}
// 4. Replay-protection check
const now = Math.floor(Date.now() / 1000)
if (now - parseInt(timestamp, 10) > toleranceSeconds) {
throw new Error('Timestamp outside tolerance zone')
}
return true
}
Three things that look fine but aren't, and that I see in DIY implementations all the time:
===instead of constant-time compare. A naive string equality check returns false on the first differing character, and the time it takes to do that is observable on the wire. A determined attacker can fish out the secret byte-by-byte over millions of requests. Always usecrypto.timingSafeEqualin Node,hmac.compare_digestin Python,hmac.Equalin Go.- JSON-stringifying the body before signing. If you pass
JSON.stringify(req.body)instead of the raw bytes, your re-stringified JSON's whitespace and key order will not match what Stripe signed. Always use the raw buffer. - Skipping the timestamp check. Without it, a logged or replayed request stays valid forever. The signature is correct; the event already happened. The 5-minute tolerance is a feature, not a bug.
The seven verification failures, ranked by frequency
This is what actually causes signature verification to fail in the wild. The order is mine, based on watching this bug report from teams using Stripe over the years and from supporting WebhookWhisper users.
| Symptom | Likely cause | Fix |
|---|---|---|
| "No signatures found matching the expected signature" | Body parsed before verification (the raw-body bug) | Use express.raw() on the webhook route, before express.json(). In Next.js App Router, use await req.text() not await req.json(). |
| "Timestamp outside the tolerance zone" | Replaying an old event manually, or your server clock has drifted | In dev: pass a longer tolerance to constructEvent. In prod: install chrony or ntpd — server clocks drift surprisingly fast on cheap VPSes. |
| Verification works in test mode, fails in live | You went to production with the test-mode whsec_ |
Stripe issues separate signing secrets per endpoint per mode. Re-register the endpoint in live mode and use the live signing secret. |
Verification fails for events fired by stripe trigger but works in dashboard |
Using the Stripe CLI's signing secret with a Dashboard endpoint, or vice versa | The CLI prints its own whsec_ when you run stripe listen — that secret only verifies CLI-forwarded events. Dashboard endpoints have their own secret. |
| Works locally, fails behind a proxy / CDN / WAF | Cloudflare, an API gateway, or NGINX is altering the body (common with auto-decompression, JSON pretty-printing, or removing trailing whitespace) | Disable any body transformation on the webhook route. In Cloudflare, turn off "Auto Minify" and "Rocket Loader" for the webhook path. In API Gateway, use Lambda proxy integration so the raw body passes through unmodified. |
| Works for some events, fails for others on the same endpoint | Some events are larger than your body-size middleware allows. The body gets truncated and the HMAC fails. | Increase your body parser limit. Stripe's largest events (invoice.payment_succeeded with many line items) can exceed 64 KB. express.raw({ limit: '1mb' }). |
| Verification randomly fails for ~1% of events | Multi-region deployment with sticky-session middleware re-encoding the body, or a load balancer adding/stripping headers | Verify on the edge (closest to ingress) or as the first thing your handler does — don't let any other middleware touch the body first. |
Whichever symptom you have, the debugging move is the same: log the raw bytes you received, log the timestamp from the header, log the secret prefix (first 8 chars only — never the full secret), and recompute the HMAC by hand using the playground or a one-liner. If your manual computation matches Stripe's v1 but the SDK call fails, your raw body isn't actually raw. If your manual computation doesn't match, your secret is wrong or your timestamp parsing is.
A short note from the other side of the signature
When we shipped HMAC signing on the WebhookWhisper sender side this year (the receiver-facing equivalent of what Stripe does — we sign every event we forward to your endpoint so you can verify it came from us), the first failure mode I hit was, embarrassingly, the same raw-body bug I'm warning you about above. Our test harness was JSON-stringifying the body before signing, then the receiver was JSON-stringifying it again before verifying, and the two stringifications were not identical. Different Node versions, different key ordering. The signatures matched on my laptop and failed on the deployed staging server.
The fix on our side was to sign over the exact bytes that go on the wire, computed once, and to document it loudly in our signing docs: pass the raw body bytes, in that order, no transformation. Stripe documents the same constraint in their docs and almost everyone misses it on first read. I missed it on first implementation, with the docs open in another tab.
The second thing we hit was constant-time comparison. The first version of our verifier helper used ===. The second version, after I read it back two days later, used crypto.timingSafeEqual. The math was right both times; one version was a textbook timing-attack vulnerability. This is the kind of thing you can write correct on paper, lint-clean, and still ship wrong because the easy-and-wrong version compiles. The SDK call exists exactly so you don't have to think about this.
Testing signature verification locally — without burning a real charge
You have three honest options, in increasing order of fidelity.
1. Use the Stripe CLI's stripe trigger. Run stripe listen --forward-to localhost:3000/webhooks/stripe in one terminal — it prints a CLI-specific whsec_. Set that as your STRIPE_WEBHOOK_SECRET. In another terminal, run stripe trigger payment_intent.succeeded. The CLI sends a real signed payload to your handler. The signature is genuine. The downside: you can only test the canonical event shapes Stripe ships with the CLI.
2. Use a real Stripe Dashboard endpoint pointed at a forwarder. Create a free WebhookWhisper endpoint, paste it into the Stripe Dashboard as a webhook URL, copy the signing secret Stripe shows you, and add a forwarding rule from the WebhookWhisper endpoint to http://localhost:3000/webhooks/stripe. Then use the Dashboard's "Send test webhook" button or trigger real test-mode events from your test code. The signature is real, the bytes are unmodified through forwarding (we explicitly preserve the body — that's the whole point), and your local handler verifies the actual Stripe signature.
3. Compute signatures yourself for unit tests. For tests where you want to assert handler behavior on synthetic payloads, use the signature playground to generate a valid Stripe-Signature header for any payload + secret combo, or use the Stripe SDK's test helper:
// Node.js — generate a valid signature for tests
import stripe from 'stripe'
const payload = JSON.stringify({ id: 'evt_test', type: 'payment_intent.succeeded' })
const secret = 'whsec_test_secret'
const timestamp = Math.floor(Date.now() / 1000)
const signature = stripe.webhooks.generateTestHeaderString({ payload, secret, timestamp })
// Now POST `payload` with `Stripe-Signature: ${signature}` to your handler
// and assert it returns 200.
The first option is the fastest for ad-hoc local work. The second is what you want when you're integrating with the real dashboard, want to see actual events, and want a permanent URL you don't have to update every time your laptop sleeps. The third is what your CI pipeline should use.
The production checklist
The minimum bar for a Stripe webhook handler that won't bite you:
- ☑ Verify
Stripe-Signatureon every request — no flag, no toggle, no "skip in dev". - ☑ Pass the raw request body to
constructEvent, never a parsed object. - ☑ Store
STRIPE_WEBHOOK_SECRETin environment variables. Rotate after any teammate offboarding or laptop loss. - ☑ Use separate signing secrets for test mode and live mode.
- ☑ Use separate signing secrets for each webhook endpoint (one for the dashboard endpoint, one for
stripe listenin dev, one per environment). - ☑ Return 200 only after the signature is verified and your idempotency check has either accepted or rejected the event. See webhook best practices for the idempotency pattern.
- ☑ Acknowledge within 30 seconds. Stripe retries on timeout. Enqueue heavy work; don't run it inline.
- ☑ Log
err.messagefromconstructEventfailures with a request ID. The message is specific enough to debug from. - ☑ Cap your body parser at a reasonable size (1 MB is more than enough). Reject larger requests early.
- ☑ If you're behind Cloudflare / API Gateway / a WAF, verify on the edge or disable any body-transforming features on the webhook route.
- ☑ Alert on signature-verification failure rate. A sudden spike means either an attacker probing or a deploy that broke the handler.
Frequently asked questions
What's the difference between STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET?
STRIPE_SECRET_KEY (starts with sk_) is your API key — used to make outbound calls to Stripe (creating customers, charging cards, listing payments). STRIPE_WEBHOOK_SECRET (starts with whsec_) is your webhook signing secret — used to verify incoming webhooks from Stripe. They're different values, scoped to different operations. Don't reuse one for the other; the API will reject the secret key as a webhook secret because the prefix is wrong.
Can I verify the signature without using the Stripe SDK?
Yes — see the "Roll your own verifier" section above for the canonical implementation. The math is straightforward: HMAC-SHA256 over {timestamp}.{raw_body}, hex-encoded, constant-time-compared to the v1 field in the header, plus a 5-minute timestamp tolerance check. The SDK exists to handle the constant-time comparison and the header parsing for you, not because the algorithm is exotic.
Why does my signature verify in development but fail in production?
Three possibilities, in order of likelihood. (1) You used the test-mode whsec_ in production by mistake — these are separate values per mode, and a test-mode secret will not verify live events. (2) A production-only proxy (Cloudflare, an API gateway, or an NGINX with body buffering) is mutating the request body. (3) Your production handler uses different middleware ordering than dev — e.g. express.json() registered globally before the webhook route. Check the secret first; it's the most common.
Should I disable timestamp tolerance during local testing?
Only if you're replaying captured events more than 5 minutes after they were originally sent. For everything else, keep the default — it's the only thing protecting you from someone who managed to capture a real signed webhook (e.g. via a leaked log file) from re-firing it weeks later. If you do need to disable it for a specific test, pass a larger tolerance to constructEvent: stripe.webhooks.constructEvent(body, sig, secret, 86400) for 24 hours. Never push that to production.
What's the right way to test signature verification in a CI pipeline?
Generate a valid signature in the test itself, using stripe.webhooks.generateTestHeaderString({ payload, secret }) (Node) or its equivalent in your language. Set a fixed test secret in your CI environment, generate the header for each test payload, POST it at your handler, and assert the handler returned 200. This avoids any dependency on a live Stripe account or network calls — the signature is real HMAC math, just with a secret you control.
Closing
The summary: use the SDK, pass the raw body, use a separate whsec_ per endpoint per mode, keep the 300-second timestamp tolerance on, and constant-time-compare. Ninety-five percent of the bugs come from the raw-body issue or from mismatched secrets. The remaining five percent come from middleware between Stripe and your verifier mutating the body — which is why we built WebhookWhisper's forwarding to be byte-transparent: the bytes Stripe sends to us are the bytes we send to your localhost.
If you're stuck on a verification failure right now, the fastest debugging path is to use the signature playground to compute what the signature should be for the exact payload you're receiving, then compare to the v1 in the header. If they don't match, you have a body or secret problem. If they do match but your code rejects it anyway, you have a comparison or parsing problem in your code path.
And if you want to skip having to write any of this — receive real signed Stripe webhooks at a permanent URL, forward them to your localhost without rotating ngrok URLs, replay them when your handler crashes — that's the wedge WebhookWhisper exists for. Free endpoint, no card required.