# Verifying webhook signatures

Every webhook delivery is signed so you can prove it came from qlim8 and wasn't replayed.
Create a subscription with `POST /api/v1/webhooks` (or the `create_webhook` MCP tool); the
response contains a `signing_secret` **shown once** — store it securely.

## Events you can subscribe to

The full event catalog (the only values accepted in `events` when subscribing):

`supplier.invited` · `supplier.connected` · `target.created` · `report.generation_started` ·
`report.generation_completed` · `report.attested` · `usage.quota_threshold` ·
`api_key.created` · `api_key.revoked`

## The signature scheme

qlim8 uses a Stripe-compatible signature header:

```
X-Qlim8-Signature: t=<unix-seconds>,v1=<hex-hmac>
```

- `t` is the Unix timestamp (seconds) at signing.
- `v1` is `HMAC-SHA256(secret, signedPayload)` as a lowercase hex digest.
- **The signed payload is `"<t>.<rawBody>"`** — the timestamp, a literal `.`, then the **raw,
  unparsed** request body bytes. Sign/verify the exact bytes you received; do not re-serialize
  parsed JSON.

To verify a delivery:

1. Parse `t` and `v1` from the header.
2. Reject if `|now - t|` exceeds your tolerance (default **30 seconds**) — this defends against
   replay.
3. Recompute `HMAC-SHA256(secret, "<t>.<rawBody>")` and compare to `v1` using a
   **constant-time** comparison.

Return `2xx` quickly on success; any non-2xx (or timeout) is retried with backoff.

## Verify a delivery

**curl** — verification is a code task, not a curl one, but you can reproduce the expected
signature on the command line to sanity-check a secret:

```bash
SECRET="whsec_<your-signing-secret>"
TS=1718000000                 # the t= value from X-Qlim8-Signature
RAW='{"event":"report.generation_completed","data":{"id":"..."}}'   # exact raw body

EXPECTED=$(printf '%s.%s' "$TS" "$RAW" \
  | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
echo "$EXPECTED"   # compare against the v1= value in the header
```

**TypeScript (Node, fetch/Express)**
```ts
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 30;

export function verifyWebhook(rawBody: string, header: string, secret: string): boolean {
  // 1. Parse "t=<unix>,v1=<hex>"
  let ts: number | null = null;
  let sig: string | null = null;
  for (const part of header.split(",").map((p) => p.trim())) {
    if (part.startsWith("t=")) ts = Number.parseInt(part.slice(2), 10);
    else if (part.startsWith("v1=")) sig = part.slice(3);
  }
  if (!ts || Number.isNaN(ts) || !sig) return false;

  // 2. Replay window
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - ts) > TOLERANCE_SECONDS) return false;

  // 3. Recompute over "<t>.<rawBody>" and compare constant-time
  const expected = createHmac("sha256", secret).update(`${ts}.${rawBody}`, "utf8").digest("hex");
  const a = Buffer.from(sig, "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

// Express: capture the RAW body for this route, not the parsed JSON.
// app.post("/hooks/qlim8", express.raw({ type: "application/json" }), (req, res) => {
//   const ok = verifyWebhook(req.body.toString("utf8"), req.header("X-Qlim8-Signature") ?? "", SECRET);
//   if (!ok) return res.status(400).end();
//   res.status(200).end();
// });
```

**Python (httpx server-agnostic / FastAPI)**
```python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 30

def verify_webhook(raw_body: bytes, header: str, secret: str) -> bool:
    # 1. Parse "t=<unix>,v1=<hex>"
    ts = sig = None
    for part in (p.strip() for p in header.split(",")):
        if part.startswith("t="):
            ts = part[2:]
        elif part.startswith("v1="):
            sig = part[3:]
    if not ts or not ts.isdigit() or not sig:
        return False
    ts_int = int(ts)

    # 2. Replay window
    if abs(int(time.time()) - ts_int) > TOLERANCE_SECONDS:
        return False

    # 3. Recompute over "<t>.<rawBody>" and compare constant-time
    signed_payload = f"{ts_int}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)


# FastAPI:
# @app.post("/hooks/qlim8")
# async def hook(request: Request):
#     raw = await request.body()            # raw bytes, not request.json()
#     if not verify_webhook(raw, request.headers.get("X-Qlim8-Signature", ""), SECRET):
#         raise HTTPException(400)
#     return {"ok": True}
```

## Notes

- The `signing_secret` is returned **once** from `create_webhook` / `POST /api/v1/webhooks`. If
  you lose it, rotate the subscription.
- In `live`, webhook URLs must be HTTPS and are SSRF-validated — private/loopback hosts are
  rejected.
- Inspect delivery attempts (status, HTTP code, latency) with `get_webhook_deliveries` or
  `GET /api/v1/webhooks/:id/deliveries`, and replay one with
  `POST /api/v1/webhooks/:id/deliveries/:deliveryId/replay`.
