> ## Documentation Index
> Fetch the complete documentation index at: https://docs.trycontour.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Verifying signatures

> Confirm a request really came from Contour and wasn't altered.

Verification is optional. Without it your endpoint still works; with it you
know a request really came from Contour.

Each request carries:

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

`v1` is `HMAC-SHA256(secret, "<t>." + raw_body)`, hex encoded. To verify:

1. Read the raw request body **as bytes, before any JSON parsing**.
2. Split the header on `,`, then each part on `=`, to get `t` and `v1`.
3. Compute `HMAC-SHA256` over `t + "." + raw_body` with your `whsec_` secret.
4. Compare with `v1` using a constant-time comparison.
5. Reject if `t` is more than 5 minutes from now (replay protection).

<Warning>
  **The most common mistake** is verifying against a re-serialized JSON object.
  Your framework's `JSON.stringify(req.body)` or `json.dumps(request.json)`
  will not reproduce our exact bytes. Always use the raw body.
</Warning>

<CodeGroup>
  ```js Node (Express) theme={null}
  const crypto = require("crypto");
  const express = require("express");

  const SECRET = process.env.CONTOUR_WEBHOOK_SECRET; // whsec_...
  const app = express();

  // Keep the raw body: express.json() would parse and discard it.
  app.post(
    "/webhooks/contour",
    express.raw({ type: "application/json" }),
    (req, res) => {
      const header = req.get("X-Contour-Signature") || "";
      const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
      const t = parts.t;
      const v1 = parts.v1;

      const expected = crypto
        .createHmac("sha256", SECRET)
        .update(`${t}.`)
        .update(req.body) // Buffer: the raw bytes
        .digest("hex");

      const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
      const valid =
        v1 && v1.length === expected.length &&
        crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));

      if (!fresh || !valid) return res.status(401).send("bad signature");

      const event = JSON.parse(req.body.toString("utf8"));
      // Respond first, then process (or hand off to a queue).
      res.sendStatus(200);
      handle(event); // event.event, event.call.call_id, event.call.metadata, ...
    }
  );
  ```

  ```python Python (FastAPI) theme={null}
  import hmac, hashlib, os, time
  from fastapi import FastAPI, Request, Response

  SECRET = os.environ["CONTOUR_WEBHOOK_SECRET"]  # whsec_...
  app = FastAPI()

  @app.post("/webhooks/contour")
  async def contour_webhook(request: Request):
      raw = await request.body()                      # bytes, not request.json()
      header = request.headers.get("X-Contour-Signature", "")
      parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
      t, v1 = parts.get("t", ""), parts.get("v1", "")

      expected = hmac.new(SECRET.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
      fresh = t.isdigit() and abs(time.time() - int(t)) < 300
      if not fresh or not hmac.compare_digest(expected, v1):
          return Response(status_code=401)

      event = await request.json()
      # Respond quickly; do the real work in a background task or queue.
      handle(event)
      return Response(status_code=200)
  ```
</CodeGroup>

Flask: use `request.get_data()` for the raw bytes; everything else is the same.

## Check your implementation against this fixed example

Feed these exact values into your code; it must produce the signature shown.

```
secret:    whsec_test_secret_do_not_use
t:         1700000000
raw body:  {"call":{"call_id":"call_test_00000000","duration_ms":84000,"status":"ended"},"created_at":"2026-01-01T12:01:24+00:00","event":"call.ended","id":"00000000-0000-0000-0000-000000000001"}

signature: 3b34e54e51c75a4f2a6d68c34c02648bbdadee04163ab5ae39b31ab068adae8f
header:    t=1700000000,v1=3b34e54e51c75a4f2a6d68c34c02648bbdadee04163ab5ae39b31ab068adae8f
```

The raw body is one line with no spaces. Copy it exactly. Skip the freshness
check while testing with this vector, since the timestamp is in the past.

## Rotating the secret

`POST /v1/webhook-endpoints/{id}/rotate-secret` returns a new secret and the
old one stops working immediately. Update your handler's configuration first,
then rotate. Retried deliveries are re-signed with the current secret.
