# Verifying signatures

> Verify webhook deliveries with the X-JE-Signature HMAC-SHA256 header and the timestamp, and reject replayed requests using a recommended five minute window.

Source: https://docs.customdomain.ai/docs/webhooks/verifying-signatures

Each delivery carries these headers:

* `X-JE-Timestamp: <unix seconds>`: when the delivery was signed.
* `X-JE-Signature: sha256=<hex>`: an HMAC-SHA256, keyed by your endpoint's
  signing secret (`whsec_…`), over the string `${timestamp}.${rawBody}` (the
  timestamp, a dot, then the **exact bytes** of the request body).
* `X-JE-Event-Id: evt_…`: the event `id`, the same on every retry. Use it to
  deduplicate.

Binding the timestamp into the signature lets you reject **replays**: check that
the timestamp is recent (a 5-minute window is recommended) before trusting the
payload. Each attempt, including retries, is re-signed with a fresh timestamp.

```js
const crypto = require('crypto');

function verify(rawBody, tsHeader, sigHeader, secret, toleranceSec = 300) {
  const ts = parseInt(tsHeader, 10);
  if (!Number.isFinite(ts)) return false;
  if (Math.abs(Date.now() / 1000 - ts) > toleranceSec) return false; // replay window
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(`${ts}.`)
    .update(rawBody)
    .digest('hex');
  const a = Buffer.from(sigHeader || ''), b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Compute the HMAC over the exact bytes received (don't re-serialize the JSON) and
prepend `${timestamp}.` before hashing. The comparison must be constant-time.

## Send yourself a signed test delivery

To check a receiver before any real event reaches it, sign a body with your
endpoint's secret the same way and post it to the receiver. Both samples read the
secret from `WEBHOOK_SECRET` and send to a receiver at
`http://localhost:3000/webhooks/cd`; change that to yours.

```bash
BODY='{"id":"evt_test","type":"connection.live","domain":"www.acme.test"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | sed 's/^.* //')
curl -X POST http://localhost:3000/webhooks/cd \
  -H "Content-Type: application/json" \
  -H "X-JE-Timestamp: $TS" \
  -H "X-JE-Signature: sha256=$SIG" \
  -H "X-JE-Event-Id: evt_test" \
  -d "$BODY"
```

```js
import crypto from "node:crypto";

const secret = process.env.WEBHOOK_SECRET; // whsec_…
const body = JSON.stringify({ id: "evt_test", type: "connection.live", domain: "www.acme.test" });
const ts = Math.floor(Date.now() / 1000);
const signature = "sha256=" + crypto.createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex");

const res = await fetch("http://localhost:3000/webhooks/cd", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-JE-Timestamp": String(ts),
    "X-JE-Signature": signature,
    "X-JE-Event-Id": "evt_test",
  },
  body,
});
console.log(res.status);
```

Your receiver should answer `2xx` for this request, and reject it once you change
one byte of the body or send a timestamp older than your window. For real, signed
deliveries without a real domain, register the endpoint on a
[test mode](/docs/guides/test-mode#webhooks) application.
