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.

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
| Case | Expected | Rule it checks |
|---|---|---|
| Fresh signature, one entry | true | Basic HMAC over timestamp.rawbody |
| New and current signature, comma-separated | true | Accept any sume-v1 entry |
| Body changed by one space | false | Raw bytes are signed |
| Timestamp 600 seconds old | false | Five-minute replay window |
| Empty secret | false | Never 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.mjson 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
- TikTok upload chunk rules: 5 MB to 64 MB, up to 1,000 chunks
TikTok FILE_UPLOAD chunks must be 5 to 64 MB, the last up to 128 MB, 1,000 chunks max. A short planner computes the Content-Range for each PUT.
- TikTok Direct Post post_info fields: title, is_aigc, duet, stitch
A checked list of TikTok post_info fields as of October 2026, with a validator for title length in UTF-16 units, privacy level and the is_aigc AI label.
- Timeline 1.0 warnings after stitching shots: which need a fix
Timeline 1.0 returns soft warnings, not failures. A list of the codes you will see on a stitched AI film, what each means, and which ones are worth acting on.
- TTS boundary_lead_ms: why sentence slices end 70 ms after a word
Sume TTS sentence segments cut boundary_lead_ms after a sentence's last word: 70 ms by default, 0 to 500. The next segment absorbs the pause, and no gap opens.
Written by Sume