# Verifying a Connect webhook signature

The signature is `sha256=` followed by the hex HMAC-SHA256 of the string `<timestamp>.<raw body>`, keyed with the subscription secret, where the timestamp is the value of `X-Connect-Timestamp` and the body is the exact bytes received. Compare in constant time, and reject a timestamp far from now. Delivery itself is not a registered capability, so this is a specification to build against.

- **Status:** Not yet
- **Audience:** developer
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/developers/webhook-signatures/

## The computation

Two headers take part. `X-Connect-Timestamp` carries Unix seconds at signing. `X-Connect-Signature` carries the digest. The signed string is the timestamp, a single full stop, then the raw request body — not a re-serialisation of the parsed JSON, and not the body with whitespace normalised.

```text
signed_string = X-Connect-Timestamp + "." + raw_body_bytes
digest        = HMAC-SHA256(key = subscription_secret, message = signed_string)
header_value  = "sha256=" + lowercase_hex(digest)
```

Including the timestamp inside the signed string is what makes the timestamp check meaningful. If the digest covered only the body, an attacker replaying a captured request could rewrite the timestamp header freely and your freshness check would pass on every replay.

## Three verifiers

Each takes the raw body as bytes, the two header values and the secret, and returns a boolean. None of them parses JSON, because parsing before verifying is the mistake the ordering exists to prevent.

```python
import hmac, hashlib, time

def verify(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    try:
        age = abs(time.time() - int(timestamp))
    except ValueError:
        return False
    if age > 300:                       # five minutes
        return False
    expected = "sha256=" + hmac.new(
        secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)
```

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(body, signature, timestamp, secret) {
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > 300) return false;
  const expected =
    "sha256=" +
    createHmac("sha256", secret)
      .update(`${timestamp}.`)
      .update(body)            // body is a Buffer, not a parsed object
      .digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

```go
func Verify(body []byte, signature, timestamp, secret string) bool {
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
        return false
    }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(timestamp + "."))
    mac.Write(body)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(signature))
}
```

## The four ways a verifier passes its own tests and fails in production

**Verifying a re-serialised body** — A framework that hands you a parsed object and a convenience `JSON.stringify` of it will produce a different byte sequence — key order, spacing, unicode escaping. The digest is over what arrived. Capture the raw bytes in middleware before any parser runs.
**Comparing with `==`** — String equality returns early on the first differing byte, which leaks how much of a guess was right. Both facts and the specification say constant time: `hmac.compare_digest`, `timingSafeEqual`, `hmac.Equal`.
**Skipping the timestamp window** — Without it, any request ever signed with the current secret is valid forever. Five minutes is a reasonable window; the specification says only to reject a timestamp outside a few minutes.
**Testing only the happy path** — A verifier that has never been shown a bad signature failing is not known to reject anything. Write the negative test first: flip one byte of the body and assert `false`.

## Secrets, rotation and what to log

The secret belongs to the subscription, so it is the one value that must never appear in a log line, an error message returned to a caller, or a bug report. Log the delivery attempt identifier from `X-Connect-Delivery` and the event `id` instead; both are safe and both are what anyone investigating will ask for.

During a rotation a receiver that accepts either of two secrets keeps working across the change, and one that accepts exactly one does not. Write the verifier to take a list of candidate secrets and return true if any matches — the cost is one extra HMAC per request, which is nothing, and it removes the need to coordinate a cutover.

> **Note** A failed verification should answer 401 and stop. Do not answer 2xx to an unverified request to 'avoid retries' — that converts a security control into a hint.

## Questions

### Is the signature over the headers as well?

Only over the timestamp and the body, joined by a full stop. `X-Connect-Event` is a routing convenience mirrored from the payload, so trust the `type` inside the verified body rather than the header when the two could disagree.

### What encoding is the digest in?

Lowercase hexadecimal, prefixed with `sha256=`. Compare the whole header value including the prefix, or strip the prefix from both sides before comparing — not one of each, which is a length mismatch that some constant-time helpers report as false and others refuse outright.

### Can I test a verifier without receiving a real delivery?

Yes, and that is the sensible way round. Generate a body, pick a timestamp, compute the digest with the same algorithm and feed it to your handler. The test is worth more than a live delivery because you can also generate the failures: stale timestamp, wrong secret, altered body.

## Related

- [Outbound webhooks](https://connectbyjbrh.com/developers/webhooks-outbound/)
- [The AsyncAPI description](https://connectbyjbrh.com/developers/asyncapi-events/)
- [Integration keys](https://connectbyjbrh.com/developers/integration-keys/)
- [Building an agent on Connect safely](https://connectbyjbrh.com/developers/agent-safety/)
- [Security and isolation](https://connectbyjbrh.com/docs/security/)

## What this page is based on

- `webapp/developers/asyncapi.yaml` — the signature and header definitions
- `docs-source/facts.py` — CAPABILITY_STATUS
- Connect capability registry (docs-source/facts.py)
