Test a Sume webhook receiver with node:test and signed fixtures

Three node:test cases that sign their own Sume webhook bodies: fresh, rotation header, and the three rejections. Runs with node --test.

4 min readSume
All posts

You can test a Sume webhook receiver without Sume sending anything: sign a body yourself with HMAC-SHA256 over timestamp.rawbody, prefix it with sume-v1=, and assert on the verifier. The 27-line file below uses only node:test, node:assert and node:crypto, covers the happy path, the rotation header and three rejections, and runs with node --test.

Write these before you rotate a signing secret. Rotation sends two comma-separated signatures for 24 hours, and a verifier that was only ever tested with one signature is the usual way it breaks; the bug in verifyWebhook returns false during a Sume secret rotation is exactly that case.

What each case proves

Test matrix against the documented scheme, read 2026-10-02 from docs.sume.com
CaseExpectedRule it checks
Fresh signature, one entrytrueBasic HMAC over timestamp.rawbody
New and current signature, comma-separatedtrueAccept any sume-v1 entry
Body changed by one spacefalseRaw bytes are signed
Timestamp 600 seconds oldfalseFive-minute replay window
Empty secretfalseNever verify against nothing

The test file

It imports the verifier from verify.mjs, a file holding the verifySume function from the rotation post linked above and signs fixtures with createHmac. Swap the import for your own function if you wrote one.

import { test } from "node:test";
import assert from "node:assert/strict";
import { createHmac } from "node:crypto";
import { verifySume } from "./verify.mjs";

const secret = "whsec_test";
const body = JSON.stringify({ event: "job.completed", job_id: "job_1" });
const now = () => Math.floor(Date.now() / 1000);
const sign = (s, ts, b = body) => "sume-v1=" + createHmac("sha256", s).update(`${ts}.${b}`).digest("hex");
const headers = (sig, ts) => new Headers({
  "x-sume-webhook-timestamp": String(ts), "x-sume-webhook-signature": sig });
const check = (h, over = {}) => verifySume({ body, headers: h, secret, ...over });

test("accepts a fresh signature", async () => {
  const ts = now();
  assert.equal(await check(headers(sign(secret, ts), ts)), true);
});
test("accepts either entry during a rotation", async () => {
  const ts = now();
  assert.equal(await check(headers(`${sign("new", ts)},${sign(secret, ts)}`, ts)), true);
});
test("rejects a tampered body, stale timestamp and empty secret", async () => {
  const ts = now();
  assert.equal(await check(headers(sign(secret, ts, body + " "), ts)), false);
  assert.equal(await check(headers(sign(secret, ts - 600), ts - 600)), false);
  assert.equal(await check(headers(sign("", ts), ts), { secret: "" }), false);
});

Running it

Add one more case per behavior you rely on: a missing signature header, a non-numeric timestamp, and a header whose entries are all wrong. Each is a one-line variation on the helpers above, and each is a way a hand-rolled verifier throws instead of returning false. A thrown exception in a webhook route becomes a 500, and a 500 makes Sume retry the delivery up to 10 times.

  • node --test hook.test.mjs on Node 20 or newer.

Limits

These tests share the code that signs and verifies, so they prove the verifier matches the written scheme, not that Sume signs what the docs say. One real delivery or a dashboard Send test, which posts a signed webhook.test payload to a URL you type, closes that gap. Test the route as well: the usual production failure is a framework that parsed the body before your handler saw it, which a unit test of the verifier cannot catch.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume