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

# Verifying signatures

> Confirm a webhook really came from Vocily with HMAC-SHA256.

Every delivery is signed so you can prove it came from Vocily and wasn't replayed. Verify **every**
request before acting on it.

## The signature

Every delivery carries two headers:

```http theme={"dark"}
Vocily-Timestamp: 1730000000000
Vocily-Signature: t=1730000000000,v1=1be13707222ef2ff263ac7e7a0e26d53f454ca2380c090736ae156b1ac9986c9
```

The `Vocily-Signature` header is a comma-separated list of `key=value` parts:

| Part | Meaning                                                                                                                                                                                |
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `t`  | The Unix-millisecond timestamp when we signed the request. It's also baked into the signed string, so it can't be tampered with.                                                       |
| `v1` | The signature: `HMAC-SHA256(secret, "<t>." + rawBody)`, hex-encoded. **`v1` = "version 1 of the signing scheme"** — the value after it is the actual digest you recompute and compare. |

Where `secret` is the signing secret you set on the endpoint and `rawBody` is the **exact bytes** we
sent (see the warning below).

<Note>
  **`v1` can appear more than once.** During a secret rotation we send one `v1` per active secret
  (old + new), e.g. `t=…,v1=<old>,v1=<new>`. Your verifier should **accept if *any* `v1` matches**, so
  both secrets work until the rotation grace window closes — zero dropped webhooks. The `v1` prefix also
  lets us add a future scheme (`v2=…`) later without breaking existing receivers.
</Note>

## How to verify

<Steps>
  <Step title="Read the RAW body">
    Use the exact received bytes. **Do not** re-serialize parsed JSON — key order, whitespace, and
    escaping change the bytes and the signature will never match.
  </Step>

  <Step title="Parse the header">
    Split `Vocily-Signature` into `t` and one or more `v1` values.
  </Step>

  <Step title="Reject stale timestamps">
    If `|now − t| > 300000 ms` (5 minutes), drop it — this blocks replay of a captured delivery.
  </Step>

  <Step title="Recompute and compare">
    Compute `HMAC-SHA256(secret, "<t>." + rawBody)` and **constant-time compare** against each `v1`.
    Accept if any matches (during a secret rotation we send two `v1` values so old and new both verify).
  </Step>
</Steps>

<Warning>
  Always compare in constant time (`hmac.compare_digest`, `crypto.timingSafeEqual`, `hash_equals`) —
  never `==` on the hex string.
</Warning>

## Examples

<CodeGroup>
  ```python Python theme={"dark"}
  import hashlib, hmac, time

  TOLERANCE_MS = 300_000

  def verify(raw_body: bytes, signature_header: str, secret: str, now_ms: int | None = None) -> bool:
      ts, v1s = None, []
      for part in signature_header.split(","):
          key, _, value = part.strip().partition("=")
          if key == "t":
              ts = int(value)
          elif key == "v1":
              v1s.append(value)
      if ts is None or not v1s:
          return False
      now_ms = int(time.time() * 1000) if now_ms is None else now_ms
      if abs(now_ms - ts) > TOLERANCE_MS:
          return False
      signed = f"{ts}.".encode() + raw_body
      expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
      return any(hmac.compare_digest(expected, v1) for v1 in v1s)
  ```

  ```javascript Node.js theme={"dark"}
  const crypto = require("crypto");
  const TOLERANCE_MS = 300_000;

  function verify(rawBody, signatureHeader, secret, nowMs = Date.now()) {
    let ts = null;
    const v1s = [];
    for (const part of signatureHeader.split(",")) {
      const [k, v] = part.trim().split("=");
      if (k === "t") ts = parseInt(v, 10);
      else if (k === "v1") v1s.push(v);
    }
    if (ts === null || v1s.length === 0) return false;
    if (Math.abs(nowMs - ts) > TOLERANCE_MS) return false;

    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${ts}.`)
      .update(rawBody) // rawBody must be the exact received bytes (Buffer/string)
      .digest("hex");
    const exp = Buffer.from(expected);
    return v1s.some((v1) => {
      const buf = Buffer.from(v1);
      return buf.length === exp.length && crypto.timingSafeEqual(buf, exp);
    });
  }
  ```
</CodeGroup>

<Note>
  Frameworks that give you parsed JSON by default (e.g. Express) need the **raw body**. In Express, use
  `express.raw({ type: "application/json" })` on the webhook route and verify before parsing.
</Note>

## Test vector

Before you go live, confirm your code is correct against this **known-answer test** — fixed inputs
with the exact `v1` they must produce. No live webhook needed. (Example secret; don't use it in
production.)

```
secret : whsec_docs_example_do_not_use
t      : 1730000000000
body   : {"event_id":"evt_docexample","event_type":"call.ended"}
signed : 1730000000000.{"event_id":"evt_docexample","event_type":"call.ended"}
v1     : 1be13707222ef2ff263ac7e7a0e26d53f454ca2380c090736ae156b1ac9986c9
```

* **`signed`** is the exact string that gets hashed: `"<t>." + body`.
* **`v1`** is the expected result: `HMAC-SHA256(secret, signed)` in hex.

### Check your implementation

Feed the vector into the `verify()` from above: build the header as `t=<t>,v1=<v1>`, pass the raw
`body`, and set `now = t` (so the fixed old timestamp doesn't trip the 5-minute freshness check). It
must return **true** for the real body and **false** for a tampered one.

<CodeGroup>
  ```python Python theme={"dark"}
  raw_body = b'{"event_id":"evt_docexample","event_type":"call.ended"}'
  header   = "t=1730000000000,v1=1be13707222ef2ff263ac7e7a0e26d53f454ca2380c090736ae156b1ac9986c9"
  secret   = "whsec_docs_example_do_not_use"

  assert verify(raw_body, header, secret, now_ms=1730000000000) is True            # correct implementation
  assert verify(raw_body + b"x", header, secret, now_ms=1730000000000) is False    # detects tampering
  ```

  ```javascript Node.js theme={"dark"}
  const rawBody = '{"event_id":"evt_docexample","event_type":"call.ended"}';
  const header  = "t=1730000000000,v1=1be13707222ef2ff263ac7e7a0e26d53f454ca2380c090736ae156b1ac9986c9";
  const secret  = "whsec_docs_example_do_not_use";

  console.assert(verify(rawBody, header, secret, 1730000000000) === true);          // correct implementation
  console.assert(verify(rawBody + "x", header, secret, 1730000000000) === false);   // detects tampering
  ```
</CodeGroup>

If the first line passes, your HMAC logic is correct and you're ready for live deliveries. If it
fails, check the two most common bugs: **re-serializing the JSON** instead of using the raw bytes, and
the exact **`"<t>." + body`** format of the signed string.

## No-code hosts

Zapier, Make, n8n, and similar "catch hook" tools usually **can't compute HMAC**, so they can't verify
the signature — the authenticity layer is decorative there. Fine for prototypes; for anything
sensitive, point Vocily at your own server and verify.
