A webhook endpoint is a public URL that accepts requests from anyone. The signature is the only thing that says a request came from the provider. Nearly every "signature verification failed" ticket is one of three things: the body was changed before hashing, the secret is not the one for this endpoint, or the timestamp check rejected an old request.
| Provider | Header | Signed string | Encoding |
|---|---|---|---|
| Stripe | Stripe-Signature: t=…,v1=… | t + "." + body | hex, HMAC-SHA256 |
| GitHub | X-Hub-Signature-256: sha256=… | body | hex |
| Shopify | X-Shopify-Hmac-Sha256 | body | base64 |
| Slack | X-Slack-Signature: v0=… | "v0:" + timestamp + ":" + body | hex |
| Standard Webhooks | webhook-signature: v1,… | id + "." + timestamp + "." + body | base64; key is the base64-decoded part of whsec_… |
An HMAC changes completely if one byte changes. If your framework parses the JSON and you hash JSON.stringify(req.body), key order, whitespace and Unicode escapes may differ from what was sent. Read the unparsed bytes: express.raw({ type: "application/json" }) on that route, await request.text() in Next.js route handlers and Workers, request.get_data() in Flask. Also check for middleware that trims or re-encodes the body.
crypto.timingSafeEqual, hmac.compare_digest), not ==.Paste headers, raw body and secret into the tool. If the signature fails but a variation matches (compact versus pretty JSON, trailing newline, CRLF, whitespace in the secret), it says which, and that points at the layer in your stack that altered the bytes.