Example file

webhook-receiver.mjs

Node: receives Subcontext webhooks, checks the X-Subcontext-Signature header and skips repeats.

webhook-receiver.mjs · JavaScript (Node) Download
// 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}`));

Stuck, or something missing?

Get in touch and a person on the team will help.

Contact us