# Verifying signatures

> Confirm a webhook really came from Inboundr by validating its HMAC signature.

Every delivery carries an `X-Inboundr-Signature` header. Verify it with your
endpoint's `secret` (the `whsec_…` value shown once when you created the
endpoint) before trusting the payload.

<Note>
  Deliveries also still carry the original `X-Inbound-Signature` (no `r`) with
  an identical value, so handlers written before the rename keep working. Read
  `X-Inboundr-Signature` in new code — or use the `inboundr` npm package, whose
  `readSignatureHeader` accepts either.
</Note>

## Signature format

```
X-Inboundr-Signature: t=1721471400,v1=6a2f…e19b
```

- `t` — the Unix timestamp when the signature was generated.
- `v1` — `HMAC-SHA256(secret, "{t}.{rawBody}")`, hex-encoded.

The signed message is the timestamp, a literal `.`, then the **raw request
body**. Compute the HMAC over the exact bytes you received — don't re-serialize
the JSON.

## Verify it

<CodeGroup>

```ts TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=")),
  );
  const t = parts.t;
  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && timingSafeEqual(a, b);
}
```

```python Python
import hmac, hashlib

def verify(raw_body: str, header: str, secret: str) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    expected = hmac.new(
        secret.encode(), f"{parts['t']}.{raw_body}".encode(), hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

```php PHP
<?php
function verify(string $rawBody, string $header, string $secret): bool {
    parse_str(str_replace(",", "&", $header), $parts);
    $expected = hash_hmac("sha256", "{$parts['t']}.{$rawBody}", $secret);
    return hash_equals($expected, $parts["v1"] ?? "");
}
```

</CodeGroup>

<Warning>
  Use a **constant-time** comparison (`timingSafeEqual` / `compare_digest` /
  `hash_equals`), not `==`, to avoid timing attacks.
</Warning>

## Reject stale deliveries

To defend against replay, reject deliveries whose timestamp `t` is too far from
now (for example, more than 5 minutes):

```ts
const ageSeconds = Math.abs(Date.now() / 1000 - Number(t));
if (ageSeconds > 300) return false;
```

## Reading the raw body

Frameworks that auto-parse JSON can change the bytes. Read the raw body first:

- **Express** — `express.raw({ type: "application/json" })`, then `JSON.parse`
  after verifying.
- **Next.js route handlers** — `await req.text()`, verify, then `JSON.parse`.
- **Fastify** — enable `rawBody` and hash that.
- **PHP** — read `file_get_contents("php://input")`, verify, then `json_decode`.
