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
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.
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.
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:
- 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.
- Deploy code that accepts a signature matching either the current secret or the copied one.
- Go back to that tab and save.
- 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.