Anyone who knows your webhook URL can send it a POST request that looks exactly like a real event. The signature header is how you tell the provider's requests apart from everyone else's: recompute an HMAC of the exact bytes you received with your signing secret, compare it with the header in constant time, refuse old timestamps, and skip delivery ids you have already handled.
The checks follow the same pattern everywhere; where a provider signs no timestamp, as GitHub, Shopify and Twilio do not, the delivery id does that job. What changes from provider to provider is the header name, the string that gets signed and the encoding, and that is where most failed verifications come from. This post puts those details side by side, then gives a verifier you can copy.
What does a webhook signature prove?
A valid signature proves two things: the sender holds the same secret as you, and the bytes were not changed on the way. It does not prove the event is new: a request captured once still carries a valid signature when it is sent again. That is why the timestamp and delivery id checks exist.
- HMAC
- A hash computed with a secret key, defined in RFC 2104. Only someone who holds the key can produce the same value for the same message. Five of the six schemes below use HMAC with SHA-256; Twilio uses HMAC with SHA-1.
- Signing secret
- The key you and the provider share, shown when you create the endpoint. Stripe's starts with
whsec_; the Standard Webhooks spec uses the same prefix. - Signed content
- The exact string the sender fed into the HMAC: sometimes the body alone, sometimes a timestamp and an id joined to it. You must rebuild it byte for byte.
- Replay
- Sending a captured, correctly signed request again. The signature still matches, so only a timestamp limit or a list of seen ids stops it.
- Constant-time comparison
- A comparison that takes the same time whether the first byte differs or the last one does, so response times reveal nothing about the expected signature.
How do Stripe, GitHub, Slack, Shopify and Twilio sign their webhooks?
All six schemes below put a signature in a header, and every one of them supports an HMAC. They differ in what goes into it. Read the row for your provider before you write any code: a verifier that hashes the wrong string fails on every request.
| Provider | Signature header | Algorithm and encoding | What is signed | Against replays |
|---|---|---|---|---|
| GitHub | X-Hub-Signature-256: sha256=... | HMAC-SHA256, hex | The request body | No timestamp. X-GitHub-Delivery is a unique id, the same on a redelivery |
| Stripe | Stripe-Signature: t=...,v1=... | HMAC-SHA256, hex digits in the docs' examples (scheme v1) | Timestamp, a dot, the raw body | Timestamp t; the libraries allow 5 minutes by default |
| Slack | X-Slack-Signature: v0=... and X-Slack-Request-Timestamp | HMAC-SHA256, hex | v0:, timestamp, a colon, the raw body | Timestamp; Slack's example rejects anything over 5 minutes off |
| Shopify | X-Shopify-Hmac-SHA256 | HMAC-SHA256, base64 | The raw body | No timestamp. X-Shopify-Webhook-Id to skip duplicates |
| Twilio | X-Twilio-Signature | HMAC-SHA1, base64, keyed with the Auth Token | The full URL, then each POST parameter name and value, sorted | Nothing time-based is in the signed string |
| Standard Webhooks | webhook-signature: v1,... with webhook-id and webhook-timestamp | HMAC-SHA256, base64 (v1); Ed25519 (v1a) | Id, a dot, timestamp, a dot, the body | Timestamp within a tolerance, and webhook-id as an idempotency key |
Sources: each provider's verification page, linked in the first column and listed at the end. For JSON bodies, Twilio adds a bodySHA256 query parameter holding a SHA-256 hash of the raw body (Twilio webhooks security).
How do you verify a webhook signature in Node.js?
The function below checks a request in the Standard Webhooks format. For another provider, change three things: the header names, the signed string (the table above) and the encoding of the signature (hex or base64). The order of the checks stays the same.
- Read the raw body as text, before any JSON parser touches it. In a Fetch API handler, such as a Next.js route handler,
await request.text()gives it to you (MDN). - Check the timestamp first: it is cheap and stops replays of old requests.
- Rebuild the signed string and compute the HMAC with your secret.
- Compare in constant time with every signature in the header.
- Skip ids you have already processed, then answer quickly and do the work in a queue.
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SECONDS = 5 * 60
// Standard Webhooks format: webhook-id, webhook-timestamp, webhook-signature.
export function verifyWebhook(secret, headers, rawBody) {
const id = headers.get('webhook-id')
const timestamp = headers.get('webhook-timestamp')
const signatures = headers.get('webhook-signature')
if (!secret || !id || !timestamp || !signatures) return false
// Too old, too far in the future, or not a number: reject.
const age = Math.abs(Date.now() / 1000 - Number(timestamp))
if (!(age <= TOLERANCE_SECONDS)) return false
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const expected = createHmac('sha256', key)
.update(id + '.' + timestamp + '.' + rawBody)
.digest()
// The header can hold several signatures while a secret is rotated.
return signatures.split(' ').some((entry) => {
const [version, value] = entry.split(',')
if (version !== 'v1' || !value) return false
const received = Buffer.from(value, 'base64')
return received.length === expected.length && timingSafeEqual(received, expected)
})
}
// A route handler: read the body as text, verify it, and only then parse it.
export async function POST(request) {
const rawBody = await request.text()
if (!verifyWebhook(process.env.WEBHOOK_SECRET, request.headers, rawBody)) {
return new Response('invalid signature', { status: 401 })
}
const event = JSON.parse(rawBody)
// Skip it if this webhook-id was already processed, queue the work, answer fast.
return new Response(null, { status: 204 })
}
The length check before timingSafeEqual is not optional: Node.js throws an error when the two buffers have different byte lengths (Node.js crypto docs). An HMAC-SHA256 is always 32 bytes, so checking the length first reveals nothing. request.text() always decodes the body as UTF-8 (MDN); if a provider may send other bytes, read request.arrayBuffer() and hash the bytes instead.
Why must the signature comparison take constant time?
A plain == on two strings usually stops at the first byte that differs. That makes a wrong guess answer a little faster when its first bytes are wrong than when they are right, and timing attacks build on that difference. GitHub's docs say "Never use a plain == operator" and point to crypto.timingSafeEqual (GitHub); Stripe asks for a constant-time comparison "to protect against timing attacks" (Stripe).
== compares 3 of 8 bytes, then stopsexpected5a91c40e7bd238f6
received5a91070e7bd238f6
timingSafeEqual compares all 8 bytes, every timeexpected5a91c40e7bd238f6
received5a91070e7bd238f6
Illustration with 8 made-up bytes; the third byte differs. A real HMAC-SHA256 is 32 bytes. Node.js adds that timingSafeEqual does not make the code around it timing-safe (Node.js crypto docs), so do not branch on partial matches before calling it.
How long should a webhook timestamp stay valid?
Five minutes is the common answer: it is the default tolerance of Stripe's libraries and the limit in Slack's example. Stripe warns against a tolerance of 0, which "disables the recency check entirely". The Standard Webhooks spec asks for "some allowable tolerance" and names no number. A timestamp limit is only one of several clocks a webhook receiver runs on:
- GitHub: answer a delivery with a 2XX10 s
- Stripe libraries: default timestamp tolerance5 min
- Shopify: new secret may take this long to apply1 h
- Stripe: old secret stays valid after a roll24 h
- Stripe live mode: how long it retries3 days
Two of these clocks pull against each other. A retry arrives long after the original send, so a receiver that checks timestamps would reject it if it carried the old one. Stripe handles this by generating "a new signature and timestamp for the new delivery attempt" (Stripe). So neither the signature nor the timestamp can tell you a request is a repeat. Stripe says to log the event ids you have processed and skip them, and to check the object id in data.object too, because it sometimes sends two separate events for one change.
Which mistakes make webhook verification fail?
Parsing the JSON first
Parsing and re-serializing can change spacing, key order and escaping, so the bytes no longer match. Stripe "requires the raw body of the request" and Slack says to hash it "before it has been deserialized".
Accepting every scheme in the header
Stripe also sends a fake v0 signature for test events. Its docs say: "To prevent downgrade attacks, ignore all schemes that aren't v1."
Setting the tolerance to zero
It looks strict, but in Stripe's libraries a tolerance of 0 turns the timestamp check off.
Treating a valid signature as a new event
A GitHub redelivery carries the same X-GitHub-Delivery id as the original (GitHub). Store processed ids and skip repeats, as Shopify and Standard Webhooks also advise.
Rebuilding the wrong URL for Twilio
Twilio signs "the full URL with the scheme, port, query string and any POST parameters". Rebuild the URL Twilio called; if a proxy in front of your app changes the scheme or port, the URL your app sees will not match.
Doing the work before answering
GitHub expects a 2XX response "within 10 seconds of receiving a webhook delivery" (GitHub). Verify, store the event, answer, and run the slow part in a background job.
How do you rotate a webhook signing secret without losing events?
Rotation works because a signature header can carry more than one signature. Stripe can send several v1 signatures and keeps "the previous secret active for up to 24 hours"; the Standard Webhooks header is "a space delimited list of signatures" so that rotation needs no downtime.
Accept both secrets
Deploy a verifier that passes when any signature in the header matches the old or the new secret.
Roll the secret at the provider
Expect a lag: Shopify says it "can take up to an hour" before the new secret is used for the digest.
Watch the failures
Count rejected deliveries per endpoint. A rise right after the roll usually means a server still has only the old secret.
Remove the old secret
After the provider's window has passed (24 hours at Stripe), delete it from your config and your secret store.
Sending webhooks from your own product?
Use the Standard Webhooks format instead of inventing one: webhook-id, webhook-timestamp and a v1, base64 HMAC-SHA256 over id.timestamp.body, with a random secret of 24 to 64 bytes. Your customers can then follow a published spec instead of reverse-engineering yours, and the space-delimited signature list gives you rotation for free. Its technical steering committee lists people from Zapier, Twilio, Lob, Mux, ngrok, Supabase, Svix and Kong (standardwebhooks.com).
Webhook signature questions
Why does verification fail after my framework parses the body?
The HMAC was computed over the exact bytes the provider sent. A parsed and re-serialized body is a different string, so the hash differs. Read the body as raw text or bytes, verify, then parse.
Does a valid signature mean the event is new?
No. It proves who sent the bytes, not when. Reject old timestamps where the provider signs one, and store processed delivery or event ids to skip repeats. GitHub has no timestamp in its signature, so the delivery id is your only replay check there.
Should my own product sign webhooks with HMAC or Ed25519?
Standard Webhooks allows both: v1 is a shared-secret HMAC-SHA256 and v1a is an Ed25519 signature. With Ed25519 your customers verify with a public key and never hold a secret that could sign events.
How fast must my endpoint answer?
GitHub asks for a 2XX response within 10 seconds. Verify, store the event, answer, and do the slow work in a background job.
Sources
- GitHub Docs: Validating webhook deliveries
- GitHub Docs: Webhook events and payloads (delivery headers)
- GitHub Docs: Best practices for using webhooks
- Stripe Docs: Receive Stripe events in your webhook endpoint (signature verification, retries, secret rolling)
- Slack: Verifying requests from Slack
- Shopify: Verify webhook deliveries
- Twilio: Webhooks security (signature validation)
- Twilio: Webhooks security for JSON bodies
- Standard Webhooks specification
- Standard Webhooks: steering committee
- Node.js: crypto.timingSafeEqual(a, b)
- MDN: Request.text()
- RFC 2104: HMAC, Keyed-Hashing for Message Authentication
All pages checked on 2026-10-23.



