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

# Webhook Signature Verification

> Verify the X-Numero-Signature on every webhook before trusting the payload.

Always verify the signature before trusting a webhook — it proves the delivery is genuinely from Numero and wasn't tampered with or replayed.

## The signature header

Each delivery carries:

```
X-Numero-Signature: t=1717412400123,v1=<base64 HMAC-SHA256>
```

* `t` — the timestamp the signature was generated.
* `v1` — the signature.

The **signed string is `"{t}.{rawRequestBody}"`** — the `t` value, a literal dot, then the exact raw bytes of the request body.

## How to verify

1. Parse `t` and `v1` from the `X-Numero-Signature` header.
2. Build the signed string: `` `${t}.${rawBody}` `` (use the **raw** body, before JSON parsing).
3. Compute `HMAC-SHA256` of that string with your subscription's **signing secret**, Base64-encode it.
4. Compare to `v1` using a **constant-time** comparison.
5. Reject deliveries whose `t` is older than your tolerance (e.g. 5 minutes) to prevent replays.

> **Deprecated:** a bare-body `X-Webhook-Signature` (HMAC-SHA256 of just the body, Base64) is still sent
> alongside `X-Numero-Signature` so older verifiers keep working. It carries no timestamp and so cannot
> detect a replay. Verify `X-Numero-Signature` instead; do not build new integrations on the bare-body header.

## Code examples

### Node.js

```javascript theme={null}
const crypto = require("crypto");

function verifyNumeroSignature(rawBody, header, secret, toleranceMs = 5 * 60 * 1000) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() - t) > toleranceMs) return false; // replay protection

  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("base64");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express — note express.raw so req.body is the RAW bytes
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifyNumeroSignature(req.body.toString(), req.get("X-Numero-Signature"), process.env.WEBHOOK_SECRET);
  if (!ok) return res.status(401).send("Invalid signature");

  const event = JSON.parse(req.body);
  console.log("Received:", event.event);
  res.status(200).send("OK");
});
```

### Python

```python theme={null}
import hmac, hashlib, base64, time

def verify_numero_signature(raw_body: str, header: str, secret: str, tolerance_ms: int = 5 * 60 * 1000) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", 0))
    if not t or abs(int(time.time() * 1000) - t) > tolerance_ms:
        return False  # replay protection

    expected = base64.b64encode(
        hmac.new(secret.encode(), f"{t}.{raw_body}".encode(), hashlib.sha256).digest()
    ).decode()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

### C\#

```csharp theme={null}
using System.Security.Cryptography;
using System.Text;

public static bool VerifyNumeroSignature(string rawBody, string header, string secret, long toleranceMs = 5 * 60 * 1000)
{
    var parts = header.Split(',').Select(p => p.Split('=', 2)).ToDictionary(p => p[0], p => p[1]);
    if (!long.TryParse(parts.GetValueOrDefault("t"), out var t)) return false;
    var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
    if (Math.Abs(now - t) > toleranceMs) return false; // replay protection

    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var expected = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{rawBody}")));
    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(expected),
        Encoding.UTF8.GetBytes(parts.GetValueOrDefault("v1", "")));
}
```

## Important

* Verify against the **raw request body** — before parsing it to JSON.
* Always use a **constant-time** comparison (`timingSafeEqual` / `compare_digest` / `FixedTimeEquals`).
* Enforce the **timestamp tolerance** so an intercepted delivery can't be replayed later.
* Use the envelope `id` to **dedupe** — the same event may be delivered more than once.
* Never skip verification in production.
