Standard Webhooks is an open specification for sending webhooks the same way everywhere, so a receiver that can verify one compliant provider can verify them all. It defines three request headers (webhook-id, webhook-timestamp, webhook-signature), one HMAC-SHA256 signing scheme, a whsec_ secret format and a way to rotate secrets. Svix started it, a steering group with members from Zapier, Twilio, ngrok, Supabase and others maintains it, and OpenAI, Anthropic and the Webhook Relay Outbound Gateway sign this way.
POST /webhooks HTTP/1.1
Content-Type: application/json
webhook-id: msg_2xKq8f3Ld9
webhook-timestamp: 1793180400
webhook-signature: v1,yWMOJWJfxba5Z0vHXtZJBR94qbHjqijD6YSPO6RJ6jY=
{"type":"invoice.paid","timestamp":"2026-10-28T09:00:00Z","data":{"invoice_id":"inv_8842","amount":4900}}
That signature is the real HMAC of this example with the secret whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw.
The three headers
| Header | Meaning |
|---|---|
webhook-id | Unique message ID. It stays the same across retries, so use it to deduplicate. See idempotency. |
webhook-timestamp | Unix time in seconds when this attempt was sent. Receivers reject values too far from now. |
webhook-signature | One or more space-separated signatures, each v1,<base64 HMAC-SHA256>. |
How signing works
- Signed content:
{webhook-id}.{webhook-timestamp}.{body}, wherebodyis the exact raw bytes sent. - Key: strip
whsec_from the secret and base64-decode the rest. Those bytes are the HMAC key, not the text. - Signature:
base64(HMAC-SHA256(key, signed_content)), sent asv1,<signature>.
Verifying in Node.js
import crypto from 'node:crypto'
export function verify(rawBody, headers, secret, toleranceSeconds = 300) {
const id = headers['webhook-id']
const ts = headers['webhook-timestamp']
const sigs = headers['webhook-signature']
if (!id || !ts || !sigs) return false
if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSeconds) return false
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const expected = crypto.createHmac('sha256', key).update(`${id}.${ts}.`).update(rawBody).digest()
return sigs.split(' ').some((entry) => {
const [version, sig] = entry.split(',')
if (version !== 'v1' || !sig) return false
const given = Buffer.from(sig, 'base64')
return given.length === expected.length && crypto.timingSafeEqual(given, expected)
})
}
In production, the official standardwebhooks libraries (and Svix's libraries, which accept the same unbranded headers) do this for you.
Why it exists
Before the spec, every provider invented its own scheme: Stripe-Signature with t= and v1= fields over {t}.{body} in hex, GitHub's X-Hub-Signature-256 over the body in hex, Shopify's base64 X-Shopify-Hmac-Sha256, Slack's v0: prefix. Each needs its own verifier code (see webhook signature for the comparison). Standard Webhooks fixes the parts that matter for security in one place:
- The ID and timestamp are signed, so an attacker can't change them or resend a captured request after the tolerance window. See webhook replay.
- Multiple signatures make secret rotation possible without downtime.
- A stable message ID across retries makes deduplication straightforward for at-least-once delivery.
The spec also defines an asymmetric v1a signature scheme (Ed25519) for senders that don't want receivers to hold a signing key, and recommends payload, retry and endpoint-disabling conventions.
Common mistakes
- Using the
whsec_text as the key. Decode the base64 after the prefix. - Millisecond timestamps.
webhook-timestampis seconds, a 10-digit number. A 13-digit value falls outside every tolerance window. - Parsing before verifying. Re-serialized JSON has different bytes. Verify the raw body.
- Checking only the first signature. During rotation there are two. Accept any match.
- Hex instead of base64. Standard Webhooks signatures are base64.
Standard Webhooks in Webhook Relay
The Webhook Relay Outbound Gateway, for sending webhooks to your own customers, signs every delivery with Standard Webhooks headers. Each retry gets a new timestamp and signature but keeps the original webhook-id, and for 24 hours after a secret rotation each delivery carries signatures from both the new and the previous secret. Receiver code for Node.js and Go is in receiving Outbound Gateway webhooks.
