// Verify a CallQA webhook signature (Node.js 18+, no dependencies). // // CallQA-Signature: t=,v1=.")> // // Use the RAW body (Buffer), not a re-serialized JSON object: with Express, // mount express.raw({ type: "application/json" }) on the webhook route. // // Run a tiny receiver locally: // CALLQA_WEBHOOK_SECRET=whsec_... node verify-webhook.js # listens on :8080 "use strict"; const crypto = require("node:crypto"); const http = require("node:http"); const TOLERANCE_S = 300; class InvalidSignature extends Error {} function verify(secret, header, rawBody, toleranceS = TOLERANCE_S, now = Date.now() / 1000) { if (!header) throw new InvalidSignature("missing CallQA-Signature header"); let ts = null; const signatures = []; for (const part of header.split(",")) { const i = part.indexOf("="); const key = part.slice(0, i).trim(); const value = part.slice(i + 1).trim(); if (key === "t") ts = value; else if (key === "v1") signatures.push(value); } if (!ts || !/^\d+$/.test(ts) || signatures.length === 0) { throw new InvalidSignature("malformed CallQA-Signature header"); } if (Math.abs(now - Number(ts)) > toleranceS) { throw new InvalidSignature("timestamp outside the tolerance window"); } const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, "utf8"); const expected = crypto .createHmac("sha256", secret) .update(Buffer.concat([Buffer.from(`${ts}.`, "utf8"), body])) .digest(); const ok = signatures.some((sig) => { const given = Buffer.from(sig, "hex"); return given.length === expected.length && crypto.timingSafeEqual(given, expected); }); if (!ok) throw new InvalidSignature("signature mismatch"); return JSON.parse(body.toString("utf8")); } module.exports = { verify, InvalidSignature }; if (require.main === module) { const secret = process.env.CALLQA_WEBHOOK_SECRET; if (!secret) throw new Error("set CALLQA_WEBHOOK_SECRET"); const seen = new Set(); http .createServer((req, res) => { const chunks = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => { let event; try { event = verify(secret, req.headers["callqa-signature"], Buffer.concat(chunks)); } catch (err) { res.writeHead(400).end(String(err.message)); return; } if (!seen.has(event.id)) { seen.add(event.id); // deliveries are retried: deduplicate on the event id console.log(`${event.type}: ${JSON.stringify(event.data)}`); } res.writeHead(204).end(); }); }) .listen(8080); }