Verify a webhook
Draft. The sending side is built and tested; the verifier below is tested against real signed deliveries in Node. It has not been run inside n8n, Make or Zapier. The production host names are not live yet.
What is sent
A webhook destination sends each lead as a POST with a JSON body. The signature is the only proof that it came from ContactForms, so check it before you trust the body.
X-CF-Timestamp: Unix seconds when this attempt was signed.X-CF-Signature:v1=followed by the hex HMAC-SHA256 of<X-CF-Timestamp>.<raw body>, keyed with your webhook secret.X-CF-Signature-Prev: only for 24 hours after you replace the secret; the same signature made with the previous secret.X-CF-Delivery-IdandIdempotency-Key: the same on every attempt of the same lead. They are not signed; the signed body carries the same value as itsid, and that is the one to trust.X-CF-Event:lead.created, orpingfor a test.
The secret starts with whsec_ and is shown once, when the webhook is created. Keep it out of code you commit.
The rules
- Compute the HMAC over the raw bytes you received. Re-serialised JSON will not match.
- Compare in constant time.
- Refuse a timestamp older than 5 minutes or more than 60 seconds ahead.
- Answer a failed check with 401. A 4xx is final: the lead is not sent again.
- Answer 2xx once you have the lead. A 5xx, a timeout or a 429 is retried with the same delivery id, so process each delivery id once. Take the id from the signed body, require the header to match it, and remember handled ids in a persistent store with an atomic set-if-absent that outlives the retry horizon (about 30 hours). Your tool has to provide that store.
Verifier
Node. This text is the one the tests run against real deliveries.
const crypto = require("crypto");
// rawBody: the request body exactly as received (string or Buffer), NOT re-serialised JSON.
// headers: lower-case header names. secret: the whsec_ value shown once when you created the webhook.
function verifyCfSignature({ secret, rawBody, headers, nowSeconds, windowSeconds = 300 }) {
const one = (name) => (Array.isArray(headers[name]) ? headers[name][0] : headers[name]);
const ts = Number(one("x-cf-timestamp"));
// During a secret change the sender signs with the new secret (x-cf-signature) and the old one (x-cf-signature-prev).
// A receiver that still holds only one of them matches one of the two.
const sent = [one("x-cf-signature"), one("x-cf-signature-prev")].filter(Boolean);
if (!secret || !Number.isInteger(ts) || sent.length === 0) return { ok: false, reason: "malformed" };
const now = nowSeconds ?? Math.floor(Date.now() / 1000);
if (now - ts > windowSeconds) return { ok: false, reason: "too_old" };
if (ts - now > 60) return { ok: false, reason: "too_new" };
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), "utf8");
const expected = Buffer.from(
"v1=" + crypto.createHmac("sha256", secret).update(Buffer.concat([Buffer.from(ts + "."), body])).digest("hex"),
);
const match = sent.some((s) => {
const got = Buffer.from(String(s));
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
if (!match) return { ok: false, reason: "bad_signature" };
// The signature now vouches for the body, so the delivery id is read FROM the body. The header copy is not signed: it
// must be there and equal the signed id, and nothing is ever keyed on the header alone.
let parsed;
try {
parsed = JSON.parse(body.toString("utf8"));
} catch (e) {
return { ok: false, reason: "bad_body" };
}
const id = parsed && typeof parsed.id === "string" ? parsed.id : "";
if (!id) return { ok: false, reason: "no_id" };
if (one("x-cf-delivery-id") !== id) return { ok: false, reason: "id_mismatch" };
return { ok: true, deliveryId: id, event: parsed.event, body: parsed };
}
// ---- Remember each delivery id, so a retry or a replay does the work once ----
// The sender retries one lead for hours, so the memory must outlive your process and the whole retry horizon, and claiming an
// id must be ATOMIC (two copies arriving together: exactly one gets "claimed"). Your tool has to provide a store with:
// get(key) -> a string, or nothing set(key, value, ttlSeconds) delete(key)
// setIfAbsent(key, value, ttlSeconds) -> true for exactly one caller, false for the rest
const RETRY_HORIZON_SECONDS = 109710;
// Result: "claimed" (first time: do the work, then completeDelivery), "done" (already done: answer 200 and do nothing),
// "in_progress" (another copy is working on it: answer 503 so the sender tries again later).
async function claimDelivery(store, deliveryId, { now = Math.floor(Date.now() / 1000), leaseSeconds = 900, ttlSeconds = RETRY_HORIZON_SECONDS } = {}) {
const key = "cf:" + deliveryId;
for (let i = 0; i < 2; i++) {
if (await store.setIfAbsent(key, JSON.stringify({ state: "processing", at: now }), ttlSeconds)) return "claimed";
const raw = await store.get(key);
if (!raw) continue; // expired between the two calls: claim again
const cur = JSON.parse(raw);
if (cur.state === "done") return "done";
if (now - cur.at <= leaseSeconds) return "in_progress";
await store.delete(key); // the worker that claimed it died: its lease ran out, so this copy takes over
}
return "in_progress";
}
async function completeDelivery(store, deliveryId, { now = Math.floor(Date.now() / 1000), ttlSeconds = RETRY_HORIZON_SECONDS } = {}) {
await store.set("cf:" + deliveryId, JSON.stringify({ state: "done", at: now }), ttlSeconds);
}
async function releaseDelivery(store, deliveryId) {
await store.delete("cf:" + deliveryId); // call when your work failed and you want the sender's next attempt to run it again
}
const r = verifyCfSignature({ secret: process.env.CF_WEBHOOK_SECRET, rawBody, headers });
if (!r.ok) return respond(401, { error: r.reason });
const state = await claimDelivery(store, r.deliveryId);
if (state === "done") return respond(200, { received: true }); // already handled
if (state === "in_progress") return respond(503, { retry: true }); // another copy is working on it
// ... do the work (make it idempotent on r.deliveryId) ...
await completeDelivery(store, r.deliveryId);
respond(200, { received: true });
n8n, Make and Zapier
Starting points live in the repository under docs/integrations/: an n8n workflow that runs this verifier and remembers delivery ids, and a Zapier build guide with Code steps. Neither has been imported into the real tool yet. There is no Make template: it is not ready, because a Make scenario has not been shown to verify the signature before accepting.
Templates that depend on email are not available yet.