Deno 2.9 Deno.test.each: a case table for a Sume webhook verifier
Deno 2.9 adds Deno.test.each. Table-test a sume-v1 verifier: valid, rotated, empty secret, stale timestamp and tampered body, with WebCrypto only.

Short answer
Put every way a Sume webhook can fail into one array and let Deno.test.each register an independent test per row. Deno 2.9, per its release post, added Deno.test.each for parameterized tests from input tables, with object cases and $key name templates. The verifier under test uses WebCrypto and refuses an empty secret.
The signature rules come from Sume's webhooks page: HMAC SHA-256 over <timestamp>.<raw_body>, a sume-v1= prefix, a five-minute tolerance, and a comma-separated header during a secret rotation.
What the release post documents
The release post dates Deno 2.9 to 25 June 2026. Two of its testing additions matter for a verifier suite.
| Item | What the post says |
|---|---|
| Deno.test.each | array cases spread as arguments; object cases with interpolated names |
| Name templates | printf-style tokens such as %i and %s, and $key.nested for object access |
| t.assertSnapshot() | built into the test context; snapshots live in __snapshots__/<file>.snap |
| Updating snapshots | deno test --update-snapshots |
The verifier and its case table
Save as verify_test.ts and run deno test. The sign helper builds the header the way Sume does; the cases then break one property at a time. Every case sends a header with a stale entry first, because the docs say the newest secret comes first and any matching entry is enough, so the valid row also proves the rotation path.
import { assertEquals } from "jsr:@std/assert";
async function sign(secret: string, ts: string, body: string) {
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${ts}.${body}`));
return "sume-v1=" + [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
function same(a: string, b: string) {
let d = a.length ^ b.length;
for (let i = 0; i < Math.min(a.length, b.length); i++) d |= a.charCodeAt(i) ^ b.charCodeAt(i);
return d === 0;
}
async function verify(secret: string, ts: string, header: string, body: string, now: number) {
if (!secret || !Number.isFinite(Number(ts)) || Math.abs(now - Number(ts)) > 300) return false;
const want = await sign(secret, ts, body);
return header.split(",").map((e) => same(e.trim(), want)).includes(true);
}
const NOW = 1_780_000_000, BODY = '{"event":"job.completed"}';
Deno.test.each([
{ label: "valid", secret: "s3cret", skew: 0, body: BODY, ok: true },
{ label: "empty secret", secret: "", skew: 0, body: BODY, ok: false },
{ label: "stale timestamp", secret: "s3cret", skew: 301, body: BODY, ok: false },
{ label: "tampered body", secret: "s3cret", skew: 0, body: BODY + " ", ok: false },
])("$label -> $ok", async ({ secret, skew, body, ok }) => {
const ts = String(NOW - skew);
const header = "sume-v1=stale," + await sign("s3cret", ts, BODY);
assertEquals(await verify(secret, ts, header, body, NOW), ok);
});Adding the rows you will actually hit
Extend the table with a header that has no sume-v1= entry, a timestamp that is not a number, and a body that differs only by key order. The last one is the common production bug: a framework parsed and re-serialized the JSON, and the signature no longer matches the bytes Sume signed.
The official TypeScript SDK ships verifyWebhook for the same job; the table above is for teams that keep the verifier in their own tree.
What Sume does and does not do
Sume signs every delivery with the workspace's signing secret, adds x-sume-webhook-secret-fingerprint so you can compare secrets without sending them, and lets you Redeliver a job's real terminal event with a fresh timestamp. Send test posts a dummy webhook.test body to a URL you type.
Sume does not sign progress events, because it sends none, and it will not accept a localhost or non-HTTPS webhook URL.
Sources
Related posts
More in Developers
- Deno 2.9 t.assertSnapshot: contract-test the Sume status envelope
Deno 2.9 builds assertSnapshot into the test context. Snapshot the field names and types of GET /v1/jobs/:id/status for a completed job to catch API drift.
- A dry-run flag for Sume API calls: print the request, skip the spend
Add DRY_RUN to code that calls the Sume API: build the body, key and spend cap, print them, and send nothing. Review a batch before it costs money.
- Dub one Short into 8 languages: Python fan-out and the total cost
Detach and transcribe once, then run one TTS job and one render per language. A Python fan-out and the per-Short bill, from Sume's catalog rates.
- eBay Media API video upload: 150 MB, MP4, and the LIVE status
eBay's Media API takes a listing video in two calls: createVideo with the exact byte size (max 150 MB), then uploadVideo as MP4. Prep the file with Sume.
Written by Sume