What is Standard Webhooks? Headers, signing and examples

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

HeaderMeaning
webhook-idUnique message ID. It stays the same across retries, so use it to deduplicate. See idempotency.
webhook-timestampUnix time in seconds when this attempt was sent. Receivers reject values too far from now.
webhook-signatureOne or more space-separated signatures, each v1,<base64 HMAC-SHA256>.

How signing works

  1. Signed content: {webhook-id}.{webhook-timestamp}.{body}, where body is the exact raw bytes sent.
  2. Key: strip whsec_ from the secret and base64-decode the rest. Those bytes are the HMAC key, not the text.
  3. Signature: base64(HMAC-SHA256(key, signed_content)), sent as v1,<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-timestamp is 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.

Frequently asked questions

What is Standard Webhooks?

Standard Webhooks is an open specification for how to send webhooks, so receivers can verify every compliant provider the same way. It defines three headers, webhook-id, webhook-timestamp and webhook-signature, an HMAC-SHA256 signing scheme over the id, timestamp and body, a whsec_ secret format and a way to rotate secrets.

How do I verify a Standard Webhooks signature?

Build the string webhook-id, a dot, webhook-timestamp, a dot, then the exact raw body. Strip the whsec_ prefix from your secret and base64-decode the rest to get the key. Compute HMAC-SHA256, base64-encode it and compare it in constant time with each v1 entry in the webhook-signature header. Also reject timestamps more than a few minutes from the current time.

Who uses Standard Webhooks?

Svix started the spec, and a steering group with members from companies including Zapier, Twilio, ngrok and Supabase maintains it. OpenAI, Anthropic and the Webhook Relay Outbound Gateway sign their webhooks this way, and reference libraries exist for most languages.

Why are there two signatures in the webhook-signature header?

The header can carry several space-separated signatures so that secrets can be rotated without downtime. During a rotation the sender signs with both the old and the new secret, and the receiver accepts the request if any signature matches.