Stripe webhook signature verification
Paste a Stripe-Signature header, the raw request body and the endpoint's signing secret to see whether the HMAC matches — and read the exact scheme Stripe uses.
Last reviewed · scheme facts from the site's provider table · Stripe documentation
Check a Stripe signature
The exact rule
These are the facts this verifier implements, read from the same provider table that powers /providers. A mismatch here is a bug in the tool, not a preference.
- Signature header
- stripe-signature = t=X,v1=hex
- Algorithm
- HMAC-SHA256
- Signed string
- {timestamp}.{body}
- Encoding
- hex
- Timestamp / tolerance
- 5 min
- Signing secret
- Endpoint signing secret (whsec_…)
How Stripe signs a webhook
Stripe's scheme is documented as `t=<unix seconds>,v1=<hex>` in the Stripe-Signature header. A delivery looks like `t=1700000000,v1=2e5def…`. The signed string is the timestamp, a literal dot, then the raw request body: `${t}.${body}` in provider notation. The HMAC key is the endpoint's signing secret, and the digest is lowercase hex.
There is no separate timestamp header and no request-id component in the signature. Everything needed to verify is in the header value plus the body. That also means the timestamp must be kept exactly as printed in the header when you build the signed string: do not reformat it, convert it to milliseconds or trim a leading zero, because the string changes and the HMAC will not match.
Multiple v1 values are normal during rotation. When you roll the signing secret in the Stripe Dashboard, old and new deliveries can both arrive, so verify against every v1 value and accept if any one matches. Rejecting because the first value is stale is a common way to break a webhook while rotating keys.
Which secret to use, and what the timestamp means
The secret is the endpoint's signing secret, shown in the Stripe Dashboard under Developers → Webhooks for that endpoint, and printed by `stripe listen` when you test locally. It starts with `whsec_`. It is not your API key and not a restricted key: using either produces a signature that never matches, which is the most common cause of a 400 on the first webhook.
Stripe's libraries default to the last five minutes for the timestamp check, and the provider table this site publishes records a five-minute tolerance. This tool follows a slightly different rule on purpose: it verifies the HMAC without rejecting on age unless a tolerance is supplied, because a captured request is exactly what people paste here while debugging. In your server, enforce Stripe's five-minute window; the code samples show where that check belongs.
The raw body is the input
The most expensive mistake is letting a framework parse the body before the verifier sees it. `JSON.stringify(await request.json())` is not the bytes Stripe sent: key order, whitespace, escaped slashes and number formatting all change, and the HMAC is computed over bytes. If your body parser has already consumed the stream, the verifier cannot recover the raw body — read it once into a string and pass that same string to both the verifier and `JSON.parse`.
In Next.js, `await request.text()` gives the raw body before any parsing. In Express, register `express.raw({ type: "application/json" })` on the webhook route and use `req.body` as a Buffer. In PHP, `file_get_contents("php://input")` is the body; `$_POST` is not. The samples below all assume you already have the raw bytes.
A sample signature header
This sample header was generated from the sample body above with a made-up secret, so it only verifies against that exact secret. Load a fresh sample in the tool to get a complete, self-consistent set.
Header(s)
{
"stripe-signature": "t=1700000000,v1=8bde3041386df0771a3e37808def3ee45da299507b125bc80547263f4446eeaf"
}Raw body
{"id":"evt_example_1","type":"charge.succeeded","created":1700000000,"data":{"object":{"id":"ch_example","amount":499}}}Example secret (not a real credential)
whsec_example_secret_not_real
Verify it in your own server
Three complete implementations of the same rule. Each reads the exact header(s), HMACs the raw body with the secret and compares in constant time; the comment at the top says where the raw body comes from in that runtime.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the exact bytes you received. In Next.js: await request.text().
export function verifyStripeSignature(rawBody, sigHeader, secret) {
let timestamp = "";
const candidates = [];
for (const part of sigHeader.split(",")) {
const i = part.indexOf("=");
if (i < 0) continue;
const key = part.slice(0, i);
const value = part.slice(i + 1);
if (key === "t") timestamp = value;
if (key === "v1") candidates.push(value);
}
const expected = createHmac("sha256", secret)
.update(timestamp + "." + rawBody)
.digest("hex");
const expectedBuffer = Buffer.from(expected);
return candidates.some((candidate) => {
const candidateBuffer = Buffer.from(candidate);
return (
expectedBuffer.length === candidateBuffer.length &&
timingSafeEqual(expectedBuffer, candidateBuffer)
);
});
}Python
import hashlib
import hmac
def verify_stripe_signature(raw_body: bytes, sig_header: str, secret: str) -> bool:
timestamp = ""
candidates = []
for part in sig_header.split(","):
key, _, value = part.partition("=")
if key == "t":
timestamp = value
elif key == "v1":
candidates.append(value)
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return any(hmac.compare_digest(expected, candidate) for candidate in candidates)PHP
<?php
// $rawBody = file_get_contents("php://input"); // exact bytes, no parsing yet
function verify_stripe_signature(string $rawBody, string $sigHeader, string $secret): bool {
$timestamp = "";
$candidates = [];
foreach (explode(",", $sigHeader) as $part) {
[$key, $value] = array_pad(explode("=", $part, 2), 2, "");
if ($key === "t") {
$timestamp = $value;
}
if ($key === "v1") {
$candidates[] = $value;
}
}
$expected = hash_hmac("sha256", $timestamp . "." . $rawBody, $secret);
foreach ($candidates as $candidate) {
if (hash_equals($expected, $candidate)) {
return true;
}
}
return false;
}Common Stripe failures
Each row is a failure seen while verifying this scheme, not a generic webhook checklist. The symptom column is what the checker or your handler reports; the fix is the change that resolves it.
| Mistake | Symptom | Fix |
|---|---|---|
| API key or restricted key used as the signing secret | Every delivery fails with a signature mismatch, often on the first test. | Use the endpoint's `whsec_` signing secret from Developers → Webhooks; each endpoint has its own. |
| Body parsed then re-serialised | The signature fails only in the framework, while a curl replay of the raw body passes. | Read the raw body once (`request.text()`, `express.raw`) and HMAC that string before JSON parsing. |
| Timestamp reformatted or converted | The verifier says the signature does not match even though the header looks right. | Build `${t}.${body}` from the header's exact `t` string; do not parse it to milliseconds or add decimals. |
| Only the first v1 value checked | Verification breaks during key rotation but works before and after. | Accept when any `v1` in the header matches; Stripe sends several during rotation. |
| Timestamp tolerance ignored | Old captured requests verify forever; replay protection is absent. | Reject when `|now - t|` exceeds 300 seconds in the handler. The tool can enforce this for you while testing. |
| Test secret used against live deliveries | Test-mode deliveries pass; live ones fail with the same code. | Use the signing secret of the endpoint in the mode you are checking; test and live endpoints have separate secrets. |
| Trailing newline pasted into the secret | The same secret from the dashboard fails, while a typed copy passes. | Trim environment values; a newline in the secret changes the HMAC key and nothing else. |
| Comparing with a non-constant-time equality | Not visible in tests, but it leaks timing information on a public endpoint. | Use `timingSafeEqual`/`hmac.compare_digest`/`hash_equals`, after checking the lengths. |
Stripe signature FAQ
- Where do I find the Stripe signing secret?
- Stripe Dashboard → Developers → Webhooks → your endpoint → Signing secret. For local testing, `stripe listen` prints a `whsec_` secret for the session; that one is not the live endpoint's secret.
- Does this tool send my secret to a third party?
- No third party. The page posts the values to AnyHook's own `/api/tools/verify-signature` route, which computes the HMAC and returns a masked result. The secret is not stored or logged.
- Why does Stripe send more than one v1 value?
- To let you rotate a signing secret without dropping deliveries: during the rotation window both old and new secrets are valid. Accept a request when any v1 value matches the HMAC.
- Why does my signature fail after JSON.parse?
- Re-serialising JSON is not byte-preserving. Key order, whitespace and escaping change, and the HMAC covers the original bytes. Read the body as a string first and parse it only after verifying.
- Does the timestamp really need checking?
- For replay protection, yes — that is the whole reason it is in the signed string. Without it, a captured request remains valid indefinitely. Enforce Stripe's five-minute default in production; this page lets you debug old captures without the clock getting in the way.
- Can I verify a Stripe signature without any network call?
- Yes. The samples on this page are complete functions over the raw body and header; they use only the standard crypto module of Node, Python or PHP.
What this tool cannot tell you
- It cannot prove Stripe sent the request. A valid HMAC proves knowledge of the signing secret; it says nothing about the sender's IP address or TLS path.
- It cannot detect a replay unless you supply a tolerance while checking. The default here checks the HMAC only, which is deliberate for debugging captured requests.
- It cannot tell you whether the event was already processed. Idempotency by event `id` is your handler's job.
- It cannot verify a request whose body your framework already parsed. Bring the raw bytes.
Related
Other schemes
Next checks
A valid signature does not mean the delivery succeeded. Time your handler against each sender's response budget, and keep the event so a failure can be replayed.