# Verify webhook signatures

Your webhook endpoint is a URL on the public internet that accepts `POST` requests and is told about consent evidence, including phone numbers and IP addresses. A signature is how it tells our deliveries apart from anyone else's.

Set a signing secret under **Organization → Settings** and every delivery carries an `X-EC-Signature` header. Without one the header is absent, and there is nothing for your endpoint to check.

## The scheme

We sign the timestamp and the body together, so a captured request cannot be replayed with a new timestamp or a different payload.

```
message   = "{X-EC-Timestamp}.{raw request body}"
signature = "sha256=" + hex(HMAC_SHA256(your secret, message))
```

`X-EC-Timestamp` is Unix **seconds**. The `timestamp` field inside the payload is the same instant in milliseconds, and it is the header value that goes into the message.

The secret looks like `whsec_` followed by 32 hexadecimal characters. Treat it as a credential: it lives in your environment configuration, not in your repository.

## Verifying a delivery

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

const MAX_AGE_SECONDS = 300;

export function verifyExpressConsentWebhook({ rawBody, headers, secret }) {
  const signature = headers["x-ec-signature"];
  const timestamp = headers["x-ec-timestamp"];
  if (!signature || !timestamp) return false;

  // Reject anything too old to be a live delivery.
  const ageSeconds = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (!Number.isFinite(ageSeconds) || Math.abs(ageSeconds) > MAX_AGE_SECONDS) return false;

  const expected =
    "sha256=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}
```

Three things in there are load-bearing.

**`rawBody` is the bytes we sent.** Not an object, and not `JSON.stringify()` of a parsed object.

**The age check is yours to make.** We do not expire a signature; the header only tells you when we assembled the delivery. Five minutes is a reasonable window, and it needs to tolerate a clock skewed in either direction, which is why the example takes an absolute value. Every retry is signed afresh, and the whole retry sequence is over in about eight minutes, so a five-minute window never rejects a legitimate one.

**Compare in constant time.** A signature checked with `===` leaks how much of a guess was right, one byte at a time.

> **Warning: Parsing the body before you verify it breaks verification**
>
> JSON body parsers replace the raw bytes with an object, and re-serializing that
> object is not guaranteed to reproduce them. Key order, whitespace, and number
> formatting are all free to differ between parsers, proxies, and runtimes. It may
> well appear to work on your stack today and then fail silently when one of those
> changes, which is the worst way for this to break. In Express, register
> `express.raw({ type: "application/json" })` on the webhook route and parse it
> yourself afterwards. Most frameworks have an equivalent; if yours does not expose
> the raw body, that is the problem to solve before writing any of the code above.

## Rejecting what does not verify

Answer `401`. It is a `4xx`, so we treat it as a permanent failure and do not retry, which is correct for a request that was never ours, and is also why a misconfigured secret loses records rather than queueing them. See [set up webhooks](https://app.expressconsent.com/docs/webhooks).

Log the rejection with the `X-EC-Webhook-Id` header. If the delivery really was ours, that identifier is the fastest way for us to find it.

## Rotating the secret

There is one secret at a time and no overlap period, so swapping it in one step rejects whatever is in flight.

**Rotate** generates a replacement and shows it immediately, marked *Unsaved*. Nothing changes until you save, and that gap is what lets you rotate without a window of failures:

1. Press **Rotate** and copy the new secret. **Keep the tab open**: leaving discards it, and pressing **Rotate** again produces a different value rather than showing you that one.
2. Deploy code that accepts a signature matching either the current secret or the copied one.
3. Go back to that tab and save.
4. Deploy again, dropping the old secret.

If holding a tab open across a deploy is not practical, answer `503` instead of `401` while the two are out of step. A `503` is retried and a `401` is not, but the retry sequence spans about eight minutes, so that buys you one fast deploy rather than a maintenance window.

**Remove** stages the deletion the same way: the badge reads *Removal pending save* until you save. Once saved, deliveries continue unsigned, so an endpoint that requires a signature starts rejecting every one of them.

## Next

- [The cdr.completed event](https://app.expressconsent.com/docs/webhooks/cdr-completed): Every field on the webhook payload, when each one is present, and what it means.
- [API conventions](https://app.expressconsent.com/docs/reference/api): Base URL, authentication, the response envelope, and pagination for the server-to-server API.
