Example file
webhook-receiver.mjs
Node: receives Subcontext webhooks, checks the X-Subcontext-Signature header and skips repeats.
// Receives Subcontext webhooks (the "Webhook endpoint" connection), in Node 18
// or later with no packages.
//
// Each delivery is a POST with a JSON body and these headers:
// X-Subcontext-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
// Idempotency-Key: <the same value on every retry of this delivery>
//
// Run it:
// SUBCONTEXT_SIGNING_SECRET=THE_SIGNING_SECRET node webhook-receiver.mjs
// Subcontext only sends to https addresses on the public internet, so put it
// behind your usual HTTPS web server or load balancer.
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
// The Signing secret you entered on the Webhook endpoint connection.
const SECRET = process.env.SUBCONTEXT_SIGNING_SECRET;
const PORT = Number(process.env.PORT || 4100);
const TOLERANCE_SECONDS = 300;
if (!SECRET) throw new Error("Set SUBCONTEXT_SIGNING_SECRET to the connection's signing secret.");
const seen = new Set(); // Idempotency-Keys already handled. Use your database in real code.
// true when the header was made from this exact body with your secret, recently.
export function verifySignature(rawBody, header, secret, now = Date.now() / 1000) {
const parts = Object.fromEntries(
String(header || "").split(",").map((part) => part.split("=", 2)).filter((pair) => pair.length === 2),
);
const timestamp = Number(parts.t);
if (!parts.v1 || !Number.isInteger(timestamp)) return false;
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
}
async function handle(event) {
// Your work goes here: create or update the record in your CRM, and so on.
const name = event.contact?.name || "someone";
console.log(`${event.event_type || event.type}: ${name} (${event.contact_id || event.id})`);
}
createServer(async (req, res) => {
if (req.method !== "POST") {
res.writeHead(405).end();
return;
}
// Read the body exactly as it arrived. The signature covers these bytes.
let raw = "";
for await (const chunk of req) raw += chunk;
if (!verifySignature(raw, req.headers["x-subcontext-signature"], SECRET)) {
// 400, 401 or 403 each refuse only this delivery. Later ones still arrive.
res.writeHead(400).end("bad signature");
return;
}
const key = req.headers["idempotency-key"];
if (key && seen.has(key)) {
res.writeHead(200).end("already had it");
return;
}
const event = JSON.parse(raw);
// Answer quickly (within 15 seconds), then do the slow work.
res.writeHead(200).end("ok");
if (key) seen.add(key);
handle(event).catch((error) => console.error("handling failed", error));
}).listen(PORT, () => console.log(`Webhook receiver on http://localhost:${PORT}`));