# Webhook Signature Verification

Every webhook delivery includes a `Clodo-Signature` header. Verify it against the raw request body using your webhook signing secret.

## Header format

```
Clodo-Signature: t=<unix_timestamp>,v1=<hmac_sha256_hex>
```

- `t` — unix seconds when we signed the request.
- `v1` — lowercase hex HMAC-SHA256 over `<t>.<body_bytes>`, keyed with your secret.

## Algorithm

1. Read the raw bytes of the request body. **Do not parse and re-serialize** — key order would change and the HMAC would not match.
2. Parse `t` and `v1` from the `Clodo-Signature` header.
3. Reject if `abs(now - t) > 300` (5-minute replay window).
4. Compute `expected = HMAC_SHA256(secret_utf8, f"{t}.".encode("ascii") + body_bytes).hexdigest()`.
5. Constant-time compare `expected` with `v1`.

## Python verifier

```python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5 * 60


def verify(secret: str, body: bytes, signature_header: str) -> bool:
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    if "t" not in parts or "v1" not in parts:
        return False
    try:
        t = int(parts["t"])
    except ValueError:
        return False
    if abs(int(time.time()) - t) > TOLERANCE_SECONDS:
        return False
    signed = f"{t}.".encode("ascii") + body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])
```

Flask example:

```python
@app.post("/webhooks/clodo")
def receive():
    body = request.get_data()  # raw bytes
    if not verify(SECRET, body, request.headers.get("Clodo-Signature", "")):
        return "", 401
    payload = json.loads(body)
    # ... handle payload ...
    return "", 200
```

## Node verifier

```javascript
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export function verify(secret, body, signatureHeader) {
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((p) => p.split("=", 2)),
  );
  if (!parts.t || !parts.v1) return false;
  const t = parseInt(parts.t, 10);
  if (!Number.isFinite(t)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) return false;
  const signed = Buffer.concat([Buffer.from(`${t}.`, "ascii"), body]);
  const expected = crypto
    .createHmac("sha256", secret)
    .update(signed)
    .digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Express example (use `express.raw` so `req.body` stays as bytes):

```javascript
app.post(
  "/webhooks/clodo",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ok = verify(SECRET, req.body, req.get("Clodo-Signature") || "");
    if (!ok) return res.status(401).end();
    const payload = JSON.parse(req.body.toString("utf8"));
    // ... handle payload ...
    res.status(200).end();
  },
);
```

## Common mistakes

- Parsing the JSON before verifying. Most frameworks consume the body as a stream; once consumed it is gone. Use raw-body middleware (`express.raw`, `request.get_data()`, etc.) and verify against the bytes.
- Comparing strings with `==`. Use `hmac.compare_digest` (Python) or `crypto.timingSafeEqual` (Node).
- Trusting `t` without bounds-checking. The 5-minute window is the replay defense.

## See also

- [Webhook Signing Secrets](https://docs.clodo.ai/guides/webhook-secrets) for minting and rotating the secret.
- [Webhook Events](https://docs.clodo.ai/guides/webhook-events) for the catalog of events and their payload shapes.
- [Webhook Retry Policy](https://docs.clodo.ai/guides/webhook-retries) for retry schedule.
