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 likewhsec_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-idsvix-timestampsvix-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-idis a unique identifier for this delivery. It's the same across retries of the same event.svix-timestampis the Unix timestamp (seconds since epoch) when we attempted delivery.svix-signatureis a space-separated list ofversion,signaturepairs. Today we sendv1signatures. 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:
- Check that the timestamp is recent.
- Build the signed content string.
- Decode the secret and compute your own HMAC.
- 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_timestampis used as the raw string from the header — don't parse it as an integer and re-stringify it.raw_bodyis 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:
- Strip the
whsec_prefix. - Base64-decode the remainder to get the raw key bytes.
- Compute
HMAC-SHA256(key = decoded_secret, message = signed_content). - 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,:
- Strip the
v1,prefix to get just the base64 signature. - Compare it to your
computedvalue using a constant-time comparison (e.g.,hmac.compare_digestin Python,crypto.timingSafeEqualin Node,subtle.ConstantTimeComparein 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 ofexpress.json().) - Forgetting to base64-decode the secret. If you HMAC with
whsec_MfKQ...as a string, your signature will never match ours. Stripwhsec_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:
- Update your verification code to accept signatures from either the old or the new secret.
- Rotate the secret.
- Once you've confirmed new deliveries are verifying against the new secret, remove the old one.