# 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))
```

```python
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.
