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