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.

4 min readSume
All posts

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.

Verifier test cases and the Sume rule behind each (read 2026-10-08)
CaseExpectedRule from the docs
Fresh signed bodyAcceptedHMAC over timestamp, dot, raw body
Body with one added spaceRejectedSignature covers the raw bytes
Timestamp 301 seconds oldRejectedReplay window, 300 s default in the SDK
Header with an old entry and a new entryAcceptedRotation sends sume-v1=new,sume-v1=old
Empty secretThrowsA 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 test in 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 the account:write scope.

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

All Developers posts

Written by Sume