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:
- Deduplication. If your endpoint receives the same
webhook-idtwice, the second is a retry — don't process it again. - 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:
- Extract
webhook-id,webhook-timestamp, andwebhook-signaturefrom headers - Read the raw request body (before any JSON parsing)
- Validate the timestamp is within your tolerance window (typically 5 minutes)
- Recompute the HMAC using your signing secret
- 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 →