bun test for a Sume webhook verifier: five cases to run on Bun 1.4.2
Five bun:test cases for a Sume webhook verifier: fresh, tampered, stale, rotation and an empty secret, plus a pin to Bun 1.4.2 or later. Run on Bun 1.4.0.

Five tests cover the ways a Sume webhook verifier fails in production: a fresh signed body passes, a changed body fails, a stale timestamp fails, either entry in a rotation header passes, and an empty secret throws instead of accepting everything. bun test runs them with no extra packages. We ran them on Bun 1.4.0; the newest release we found, Bun 1.4.2, is dated September 5, 2026, so pin that or later in CI and do not rely on whichever Bun the runner happens to have.
These cases come straight from the webhook docs: the signature is HMAC-SHA256 over <timestamp>.<raw_body> in x-sume-webhook-signature: sume-v1=<hex>, the receiver should reject timestamps outside a replay window of about five minutes, and during a signing-secret rotation the header carries two entries separated by a comma.
The five cases and why they matter
Each case blocks one real incident. The tampered body shows that you verify the raw bytes. The stale case shows that a replayed request is refused. The rotation case matters because Sume signs with both the new and the previous secret for 24 hours after a rotation, and a verifier that checks only the first entry breaks during the window. The empty secret case matters because an unset environment variable makes an HMAC over an empty key, and a signature made with it is trivially forgeable.
| Case | Expected | Rule from the docs |
|---|---|---|
| Fresh signed body | Accepted | HMAC over timestamp, dot, raw body |
| Body with one added space | Rejected | Signature covers the raw bytes |
| Timestamp 301 seconds old | Rejected | Replay window, 300 s default in the SDK |
| Header with an old entry and a new entry | Accepted | Rotation sends sume-v1=new,sume-v1=old |
| Empty secret | Throws | A verifier must refuse an empty secret |
Steps
- Put the verifier in its own module that takes the raw string, the headers, the secret and the current time. Passing the time makes the stale case deterministic.
- Write the tests first, then point your route at the module. Do not re-serialize the JSON before verifying.
- Run
bun testin CI on a pinned Bun version, and rerun after any Bun upgrade. - Keep the test secret obviously fake and different from the production secret, so a fixture can never be mistaken for a real credential in a log.
- Add one integration test that calls the real test-delivery endpoint,
POST /v1/webhooks/test-deliveries, against a staging URL; that needs theaccount:writescope.
verify.ts
The function compares with timingSafeEqual and checks the length first, because it throws on buffers of different sizes.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifySume(raw: string, headers: Headers, secret: string, now = Date.now() / 1000): boolean {
if (!secret) throw new Error("signing secret is empty");
const ts = headers.get("x-sume-webhook-timestamp") ?? "";
if (!/^\d+$/.test(ts) || Math.abs(now - Number(ts)) > 300) return false;
const want = createHmac("sha256", secret).update(`${ts}.${raw}`).digest();
return (headers.get("x-sume-webhook-signature") ?? "").split(",").some((p) => {
const got = Buffer.from(p.trim().replace("sume-v1=", ""), "hex");
return got.length === want.length && timingSafeEqual(got, want);
});
}verify.test.ts
Run it with bun test. The fixed now value makes the clock part of the test, not a flaky variable.
import { expect, test } from "bun:test";
import { createHmac } from "node:crypto";
import { verifySume } from "./verify";
const secret = "test-secret";
const body = '{"event":"job.completed","job_id":"job_1"}';
const sign = (ts: number, raw = body, s = secret) =>
new Headers({
"x-sume-webhook-timestamp": String(ts),
"x-sume-webhook-signature": `sume-v1=${createHmac("sha256", s).update(`${ts}.${raw}`).digest("hex")}`,
});
const now = 1_790_000_000;
test("accepts a fresh signed body", () => expect(verifySume(body, sign(now), secret, now)).toBe(true));
test("rejects a changed body", () => expect(verifySume(body + " ", sign(now), secret, now)).toBe(false));
test("rejects a stale timestamp", () => expect(verifySume(body, sign(now - 301), secret, now)).toBe(false));
test("accepts either rotation entry", () => {
const h = sign(now);
h.set("x-sume-webhook-signature", `sume-v1=00,${h.get("x-sume-webhook-signature")}`);
expect(verifySume(body, h, secret, now)).toBe(true);
});
test("refuses an empty secret", () => expect(() => verifySume(body, sign(now), "", now)).toThrow());What Sume does not do
Sume does not ship a test fixture generator for webhook signatures; build your own signed bodies as above. The SDK exports verifyWebhook if you would rather import a verifier, which returns false rather than throwing on a bad delivery.
Sources
Related posts
More in Developers
- Lost video callback? Sweep pending jobs after the 10-attempt window
Sume retries a job webhook 10 times, 30 seconds apart. If none gets through, the job is still done. A 30-line sweeper that polls pending jobs recovers it.
- callback_url on /v1/videos: the Sume job envelope that arrives
A /v1/videos callback_url delivers Sume's job.completed, job.failed or job.canceled envelope, not video.generation.* events. Payload, signature, checks.
- callback_url or webhook_url? Which Sume route takes which field name
POST /v1/videos takes callback_url; the model endpoints take mode webhook with webhook_url. Both must be public HTTPS and both deliver signed job events.
- Cancel every queued Sume job without cancelling a running one
A Python script that lists queued jobs, checks the cancelable flag, posts the cancel, and handles 409 job_generation_already_started when the race is lost.
Written by Sume