Skip to main content

Verifying Webhook Signatures

This page walks through verifying a webhook signature from scratch, using only HMAC-SHA256 and base64 — primitives available in every major language. No SDK required.

If you haven't yet, read Why Verify Webhooks for the motivation.

What you'll need

  • The signing secret for the webhook endpoint. Get it from GET /api/connect/v1/webhooks/{webhook_id}/secret. It looks like whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw.
  • The raw request body — the exact bytes you received. Do not parse and re-serialize the JSON before verifying.
  • The three signature headers we send with every delivery:
    • svix-id
    • svix-timestamp
    • svix-signature

The headers, explained

A typical set of incoming headers looks like this:

svix-id: msg_2abc123xyz
svix-timestamp: 1713283200
svix-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
  • svix-id is a unique identifier for this delivery. It's the same across retries of the same event.
  • svix-timestamp is the Unix timestamp (seconds since epoch) when we attempted delivery.
  • svix-signature is a space-separated list of version,signature pairs. Today we send v1 signatures. The list format exists so we can rotate to new signature schemes in the future without breaking existing consumers — your code should accept any signature in the list that matches.

Verification algorithm

There are four steps:

  1. Check that the timestamp is recent.
  2. Build the signed content string.
  3. Decode the secret and compute your own HMAC.
  4. Compare your computed signature to any of the ones in the header, using a constant-time comparison.

Step 1: Check the timestamp

Reject any request whose svix-timestamp is more than 5 minutes away from your server's current time (either in the past or in the future). This is what prevents replay attacks.

now = current Unix timestamp (seconds)
tolerance = 5 * 60 // seconds

if abs(now - svix_timestamp) > tolerance:
reject

Step 2: Build the signed content

Concatenate three pieces with a single period (.) between each:

signed_content = svix_id + "." + svix_timestamp + "." + raw_body

Important details:

  • svix_timestamp is used as the raw string from the header — don't parse it as an integer and re-stringify it.
  • raw_body is the exact bytes of the request body. Treat it as raw bytes (or a UTF-8 string), not as a parsed-and-re-serialized JSON object. Any whitespace or key-order difference will produce a different signature.

Step 3: Decode the secret and compute the HMAC

The secret we gave you starts with whsec_. The part after that prefix is a base64-encoded key. You need to:

  1. Strip the whsec_ prefix.
  2. Base64-decode the remainder to get the raw key bytes.
  3. Compute HMAC-SHA256(key = decoded_secret, message = signed_content).
  4. Base64-encode the resulting digest.
key_bytes = base64_decode(secret.removeprefix("whsec_"))
digest = hmac_sha256(key_bytes, signed_content)
computed = base64_encode(digest)

Step 4: Compare to any signature in the header

The svix-signature header contains one or more version,signature pairs separated by spaces:

v1,abc123== v1,def456== v2,ghi789==

Split on spaces, then for each pair that starts with v1,:

  1. Strip the v1, prefix to get just the base64 signature.
  2. Compare it to your computed value using a constant-time comparison (e.g., hmac.compare_digest in Python, crypto.timingSafeEqual in Node, subtle.ConstantTimeCompare in Go). A plain == can leak timing information to an attacker.

If any v1 signature matches, the request is authentic. If none match, reject.

Full example (Python)

import base64
import hmac
import hashlib
import time

def verify_webhook(headers, raw_body: bytes, secret: str) -> bool:
# 1. Required headers
svix_id = headers.get("svix-id")
svix_timestamp = headers.get("svix-timestamp")
svix_signature = headers.get("svix-signature")
if not (svix_id and svix_timestamp and svix_signature):
return False

# 2. Timestamp freshness check (5 minutes)
try:
ts = int(svix_timestamp)
except ValueError:
return False
if abs(time.time() - ts) > 5 * 60:
return False

# 3. Build signed content
signed_content = f"{svix_id}.{svix_timestamp}.".encode() + raw_body

# 4. Decode secret and compute HMAC
key = base64.b64decode(secret.removeprefix("whsec_"))
expected = base64.b64encode(
hmac.new(key, signed_content, hashlib.sha256).digest()
).decode()

# 5. Compare against every v1 signature in the header
for pair in svix_signature.split(" "):
version, _, sig = pair.partition(",")
if version == "v1" and hmac.compare_digest(sig, expected):
return True
return False

The same shape works in any language: grab the raw body, build the id.timestamp.body string, HMAC it with the decoded secret, and constant-time-compare.

Common mistakes

A few things that trip people up:

  • Parsing JSON before verifying. Most web frameworks parse the request body into an object for you. The parsed-and-re-serialized JSON is almost never byte-identical to what we sent, so the signature won't match. Get the raw body bytes before any middleware has touched them. (In Express, for example, use express.raw() on the webhook route instead of express.json().)
  • Forgetting to base64-decode the secret. If you HMAC with whsec_MfKQ... as a string, your signature will never match ours. Strip whsec_ and base64-decode the rest first.
  • Using a non-constant-time comparison. == on strings short-circuits and leaks information about how many characters matched. Use your language's constant-time comparison helper.
  • Skipping the timestamp check. The signature alone doesn't protect against replay. Both checks are required.
  • Hard-coding one signing secret across multiple endpoints. Each webhook endpoint has its own secret. If you run multiple endpoints, key your verification by endpoint.

Rotating the signing secret

When you rotate a secret with POST /api/connect/v1/webhooks/{webhook_id}/secret/rotate, the old secret stops signing new requests immediately. If you need a zero-downtime cutover:

  1. Update your verification code to accept signatures from either the old or the new secret.
  2. Rotate the secret.
  3. Once you've confirmed new deliveries are verifying against the new secret, remove the old one.