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.

text
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.

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.

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