Webhook security
Anyone can POST to a public URL, so verify every delivery before acting on
it. EvoMap signs each webhook with an HMAC keyed by the signing secret you
received when you registered the endpoint. A request that
fails verification must be rejected.
The signature header
Each delivery carries:
X-EvoMap-Webhook-Signature: t=1718000000,v1=<hex-hmac>
t— the Unix timestamp when the signature was created.v1— HMAC-SHA256, hex-encoded, computed over the string`${t}.${rawBody}`(the timestamp, a literal., then the raw request body) using your webhooksecretas the key.
A legacy X-EvoMap-Signature: sha256=<hmac over body> header (HMAC over the body
alone, no timestamp) is also sent for backward compatibility. Prefer
X-EvoMap-Webhook-Signature — the timestamped scheme is what lets you reject
replays.
Verify a delivery
Compute the expected v1 over `${t}.${rawBody}` and compare it to the
header value in constant time. Two rules matter:
- Sign over the raw body bytes, exactly as received — verifying against a re-serialized JSON object will fail, because key order and whitespace differ.
- Reject a delivery whose
tis outside your tolerance window (e.g. ±5 minutes) to guard against replayed captures.
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* @param {string} rawBody - the exact request body bytes
* @param {string} header - value of X-EvoMap-Webhook-Signature
* @param {string} secret - your webhook signing secret (whsec_…)
* @param {number} toleranceSec
* @returns {boolean}
*/
export function verifyWebhook(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=")),
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
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);
}
import hmac, hashlib, time
def verify_webhook(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
t = int(parts.get("t", 0))
if not t or abs(time.time() - t) > tolerance:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Checklist
- Read the raw body first. Capture the body bytes before any JSON parsing or framework middleware re-serializes them.
- Constant-time compare (
timingSafeEqual/hmac.compare_digest) — never==— to avoid timing side channels. - Enforce the timestamp window. A valid signature with a stale
tis a replay; reject it. - Return
2xxonly after verifying. On a verification failure, return4xxand do nothing. - Keep the secret server-side. Rotate it (delete + re-register the webhook) if it may have leaked.
Related
- Webhooks — registration and the one-time signing secret
- Event catalog — the envelope you're verifying
- Delivery & retries — what a rejected delivery triggers