Standard Webhooks Specification Explained

By ThunderHooks Team · · 3 min read
Standard Webhooks Specification Explained

Standard Webhooks Specification Explained

If you've implemented webhook signing before, you know the problem: every provider does it differently. Stripe uses Stripe-Signature with HMAC-SHA256. GitHub uses X-Hub-Signature-256. Shopify uses X-Shopify-Hmac-Sha256. The algorithm is the same, but the header names, encoding, and timestamp handling are all different.

Standard Webhooks fixes this. It's an open specification that defines exactly how to sign and verify webhook payloads. Created by Svix and backed by Zapier, Twilio, ngrok, Supabase, and Kong.

The Three Headers

Standard Webhooks defines three HTTP headers on every webhook delivery:

webhook-id

A unique identifier for this message. Format: any unique string (typically a UUID). This serves two purposes:

  1. Deduplication. If your endpoint receives the same webhook-id twice, the second is a retry — don't process it again.
  2. Signature input. The ID is part of what gets signed, preventing replay attacks.

webhook-timestamp

Unix timestamp (seconds since epoch) of when the webhook was sent. Example: 1700000000.

This enables timestamp validation. If the timestamp is more than 5 minutes old, reject the webhook — it might be a replay of a captured request.

webhook-signature

The HMAC-SHA256 signature in the format v1,<base64-encoded-signature>.

The v1 prefix allows future signature algorithm upgrades without breaking existing verifiers. Multiple signatures can be included (comma-separated) for key rotation.

How Signing Works

The signature is computed over a specific format:

HMAC-SHA256(secret, "${webhook-id}.${webhook-timestamp}.${body}")

The three components (message ID, timestamp, body) are joined with dots. The body is the raw request body as a string.

Signing Example (Go)

func signPayload(msgID string, timestamp time.Time, payload []byte, secret string) string {
    ts := fmt.Sprintf("%d", timestamp.Unix())
    toSign := fmt.Sprintf("%s.%s.%s", msgID, ts, string(payload))
    
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(toSign))
    sig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
    
    return "v1," + sig
}

Signing Example (Node.js)

const crypto = require('crypto');

function signPayload(msgId, timestamp, payload, secret) {
  const toSign = `${msgId}.${timestamp}.${payload}`;
  const sig = crypto
    .createHmac('sha256', secret)
    .update(toSign)
    .digest('base64');
  return `v1,${sig}`;
}

How Verification Works

On the receiving side:

  1. Extract webhook-id, webhook-timestamp, and webhook-signature from headers
  2. Read the raw request body (before any JSON parsing)
  3. Validate the timestamp is within your tolerance window (typically 5 minutes)
  4. Recompute the HMAC using your signing secret
  5. Compare with constant-time comparison (prevent timing attacks)

Verification Example (Go)

func verifyWebhook(r *http.Request, secret string) error {
    msgID := r.Header.Get("webhook-id")
    timestamp := r.Header.Get("webhook-timestamp")
    signature := r.Header.Get("webhook-signature")
    
    // Validate timestamp (5 minute tolerance)
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return errors.New("invalid timestamp")
    }
    if time.Since(time.Unix(ts, 0)) > 5*time.Minute {
        return errors.New("timestamp too old")
    }
    
    // Read body
    body, err := io.ReadAll(r.Body)
    if err != nil {
        return err
    }
    
    // Compute expected signature
    toSign := fmt.Sprintf("%s.%s.%s", msgID, timestamp, string(body))
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(toSign))
    expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))
    
    // Constant-time comparison
    if !hmac.Equal([]byte(expected), []byte(signature)) {
        return errors.New("invalid signature")
    }
    
    return nil
}

Verification Example (Python)

import hmac
import hashlib
import base64
import time

def verify_webhook(headers, body, secret):
    msg_id = headers.get('webhook-id')
    timestamp = headers.get('webhook-timestamp')
    signature = headers.get('webhook-signature')
    
    # Validate timestamp
    if abs(time.time() - int(timestamp)) > 300:
        raise ValueError("Timestamp too old")
    
    # Compute expected signature
    to_sign = f"{msg_id}.{timestamp}.{body}".encode()
    expected = "v1," + base64.b64encode(
        hmac.new(secret.encode(), to_sign, hashlib.sha256).digest()
    ).decode()
    
    if not hmac.compare_digest(expected, signature):
        raise ValueError("Invalid signature")

Signing Secrets

The spec recommends prefixing signing secrets with whsec_ to make them easily identifiable. The secret itself should be at least 24 bytes of cryptographic randomness, base64-encoded.

Example: whsec_MIGfMA0GCSqGSIb3DQEBAQUAA4GNA...

This prefix convention helps developers avoid accidentally using an API key or JWT secret as a webhook signing secret — a common mistake.

Key Rotation

Standard Webhooks supports key rotation by including multiple signatures in the webhook-signature header, comma-separated:

webhook-signature: v1,sig_with_new_key,v1,sig_with_old_key

During rotation, sign with both the old and new key. Verifiers check all signatures and accept if any match. After a transition period, stop signing with the old key.

Why Standard Webhooks Matters

For webhook senders (you):

  • One implementation, universal compatibility. Your customers don't need custom verification code per provider.
  • Security by default. HMAC signing, timestamp validation, and replay protection are built in.
  • Ecosystem support. Libraries exist for Go, Node.js, Python, Java, Ruby, PHP, Kotlin, Rust, C#, and Swift.

For webhook receivers (your customers):

  • Familiar interface. Once they've verified one Standard Webhooks implementation, they know how to verify all of them.
  • Less code. One verifyWebhook() function works for every Standard Webhooks-compliant provider.

Who Uses Standard Webhooks?

The specification is maintained at standardwebhooks.com and backed by:

  • Svix (created the spec)
  • Zapier
  • Twilio/Segment
  • ngrok
  • Supabase
  • Kong
  • Liveblocks
  • ThunderHooks

Adoption is accelerating. If you're building webhook delivery today, implementing Standard Webhooks is the right choice.

Quick Reference

Header Value Purpose
webhook-id Unique message ID Deduplication + signature input
webhook-timestamp Unix epoch (seconds) Replay protection
webhook-signature v1,<base64-hmac> Payload integrity verification
Parameter Recommended Value
Algorithm HMAC-SHA256
Secret prefix whsec_
Secret length 24+ bytes
Timestamp tolerance 5 minutes
Signature format v1, + base64(HMAC)

ThunderHooks implements the Standard Webhooks specification for all outbound webhook deliveries. Every event is automatically signed with HMAC-SHA256, with proper webhook-id, webhook-timestamp, and webhook-signature headers. Start sending signed webhooks →

Ready to simplify webhook testing?

Try ThunderHooks free. No credit card required.

Get Started Free