Verifying signatures
Webhooks are in active development. This is the specification.
Every delivery is signed. Verify before you act, and verify before you parse.
An unverified webhook endpoint is an unauthenticated write API into your own system, and anyone who learns the URL can drive it.
Headers
svix-id: 01JGQ7XN2M4T8V6R0KZC3PWA5E
svix-timestamp: 1756654931
svix-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
The scheme is Svix-compatible, so the off-the-shelf libraries work unmodified. Haven chose it rather than inventing one because a scheme partners already have a verified implementation of is a scheme partners get right.
The algorithm
Concatenate the id, the timestamp and the raw body with periods, HMAC-SHA256 it with your base64-decoded signing secret, and base64 the result.
signed_content = f"{svix_id}.{svix_timestamp}.{raw_body}"
expected = base64(hmac_sha256(base64_decode(secret_without_prefix), signed_content))
import base64, hashlib, hmac, time
def verify(secret: str, headers: dict, raw_body: bytes) -> bool:
msg_id = headers["svix-id"]
timestamp = headers["svix-timestamp"]
signatures = headers["svix-signature"].split(" ")
if abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for candidate in signatures:
version, _, value = candidate.partition(",")
if version != "v1":
continue
if hmac.compare_digest(value, expected):
return True
return False
Four things that go wrong
The raw body, not the parsed one. Sign over the bytes you received. Re-serializing parsed JSON reorders keys and changes whitespace, and the signature will not match. In most frameworks this means capturing the body before any JSON middleware.
Constant-time comparison. Use hmac.compare_digest or your language's equivalent. A == on the signature leaks it a byte at a time to anyone willing to measure.
The timestamp. Reject anything more than five minutes from now. Without it a captured delivery is replayable forever, and the signature stays valid because the payload has not changed.
Multiple signatures. The header can carry several space-separated values. Accept the delivery if any v1 entry matches. This is not an edge case: it is how rotation works.
Rotation
During a rotation, deliveries carry two signatures: one under the new secret and one under the old. Both are valid until the overlap expires.
That is why the loop above iterates rather than comparing against the first value. A verifier that checks only the first signature will fail every delivery the moment you rotate, which is precisely when you are least able to debug it.
Rotate by minting a new secret, deploying it alongside the old one, confirming deliveries verify, then retiring the old.
Responding
Return 2xx as soon as the event is durably stored. Anything else is a failure and will be retried.
Do not return 2xx after doing the work, if the work is slow. Do not return 4xx for an event type you do not recognize: unknown types are a normal consequence of Haven adding events, and a 4xx will dead-letter the delivery permanently. Ignore what you do not handle and acknowledge it.
Testing
Send a test event to any registered endpoint from your developer console. It is signed identically to a real delivery, so it exercises your verification path rather than bypassing it.