signature verification

TypeScript

Drop either of these into your TypeScript server. Uses only Node.js built-in crypto — no extra dependencies. Requires Node.js 18+ or Bun.

Important: The sortObjectKeys helper is required. Without it, your JSON serialization will not match Python's sort_keys=True and your HMAC digest will differ.

Express handler

A complete, ready-to-run endpoint. Replace WEBHOOK_SECRET with your signing key.

import express, { Request, Response } from "express";
import { createHmac, timingSafeEqual } from "crypto";

const app = express();
app.use(express.json());

const WEBHOOK_SECRET = "replace-with-your-sign-key";

app.post("/webhook", (req: Request, res: Response) => {
  const receivedSig = req.headers["webhook-signature"] as string | undefined;
  if (!receivedSig) {
    return res.status(401).json({ error: "Missing signature" });
  }

  const jobs: Record<string, unknown>[] = req.body?.data ?? [];

  // Canonicalize: sort by url, sort object keys, compact JSON
  const sorted = [...jobs]
    .sort((a, b) => (String(a.url) < String(b.url) ? -1 : 1))
    .map(sortObjectKeys);
  const canonical = JSON.stringify(sorted);

  const expected = createHmac("sha256", WEBHOOK_SECRET)
    .update(canonical, "utf8")
    .digest("hex");

  // Constant-time compare — never use ===
  try {
    const safe = timingSafeEqual(
      Buffer.from(expected, "hex"),
      Buffer.from(receivedSig, "hex"),
    );
    if (!safe) return res.status(401).json({ error: "Invalid signature" });
  } catch {
    // timingSafeEqual throws if buffers differ in length (malformed input)
    return res.status(401).json({ error: "Invalid signature" });
  }

  // Process jobs...
  return res.json({ ok: true, received: jobs.length });
});

// Recursively sort object keys — replicates Python's sort_keys=True
function sortObjectKeys(obj: unknown): unknown {
  if (Array.isArray(obj)) return obj.map(sortObjectKeys);
  if (obj !== null && typeof obj === "object") {
    return Object.fromEntries(
      Object.entries(obj as Record<string, unknown>)
        .sort(([a], [b]) => a.localeCompare(b))
        .map(([k, v]) => [k, sortObjectKeys(v)]),
    );
  }
  return obj;
}

app.listen(3000, () => console.log("Listening on :3000"));

Standalone helper

Framework-agnostic function. Pass the parsed job list, your secret, and the header value. Returns a boolean.

import { createHmac, timingSafeEqual } from "crypto";

// Recursively sort object keys — replicates Python's sort_keys=True
function sortObjectKeys(obj: unknown): unknown {
  if (Array.isArray(obj)) return obj.map(sortObjectKeys);
  if (obj !== null && typeof obj === "object") {
    return Object.fromEntries(
      Object.entries(obj as Record<string, unknown>)
        .sort(([a], [b]) => a.localeCompare(b))
        .map(([k, v]) => [k, sortObjectKeys(v)]),
    );
  }
  return obj;
}

/**
 * Returns true if the received signature matches the expected digest.
 * Uses constant-time comparison (timingSafeEqual).
 * Requires Node.js 18+ or Bun.
 */
export function verifyFreshbatchSignature(
  jobs: Record<string, unknown>[],
  secret: string,
  received: string,
): boolean {
  const sorted = [...jobs]
    .sort((a, b) => (String(a.url) < String(b.url) ? -1 : 1))
    .map(sortObjectKeys);
  const canonical = JSON.stringify(sorted);
  const expected = createHmac("sha256", secret)
    .update(canonical, "utf8")
    .digest("hex");
  try {
    return timingSafeEqual(
      Buffer.from(expected, "hex"),
      Buffer.from(received, "hex"),
    );
  } catch {
    return false;
  }
}

See Signature Verification overview for canonicalization rules. For Python, see Python