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.
- Discord API image data: send a Sume image as a data URI
Discord takes avatar and icon images as a data URI in JPEG, PNG or GIF. Fetch the Sume result, base64-encode it with the right content type, and send it.
- Dotnet resilience handler retries POST: Sume key rule
The .NET standard resilience handler retries every HTTP method by default, POST included. How to keep a paid Sume submit from running twice.
- 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.
Written by Sume