Deno KV: dedupe Sume webhooks with atomic check on a null versionstamp
Deno.serve and Deno KV: verify the Sume HMAC with crypto.subtle.verify, then claim job_id with kv.atomic().check({ versionstamp: null }) so retries commit once.

Claim the job with kv.atomic().check({ key, versionstamp: null }).set(key, event).commit(). The check passes only if the key does not exist, so the first delivery of a job_id commits and every retry gets ok: false. Verify the signature first with crypto.subtle.verify, which compares the digest in constant time, and answer 204 to both the new delivery and the duplicate.
Sume's webhook docs say delivery is at-least-once, with up to 10 attempts, and that job_id is the idempotency key. A KV key built from that id turns the rule into one atomic operation, with no table or lock.
The server
Run it with deno run --unstable-kv -A server.ts on Deno versions where KV is still flagged. The signed string is <timestamp>.<raw_body>, and the header holds sume-v1=<hex> entries.
const secret = Deno.env.get("SUME_COM_WEBHOOK_SIGNING_SECRET") ?? "";
if (!secret) throw new Error("SUME_COM_WEBHOOK_SIGNING_SECRET is empty");
const enc = new TextEncoder();
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
const kv = await Deno.openKv();
const unhex = (h: string) => Uint8Array.from(h.match(/../g) ?? [], (b) => parseInt(b, 16));
async function valid(req: Request, raw: string): Promise<boolean> {
const ts = Number(req.headers.get("x-sume-webhook-timestamp"));
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
for (const e of (req.headers.get("x-sume-webhook-signature") ?? "").split(",")) {
const hex = e.trim().replace(/^sume-v1=/, "");
if (!/^[0-9a-f]{64}$/.test(hex)) continue;
if (await crypto.subtle.verify("HMAC", key, unhex(hex), enc.encode(`${ts}.${raw}`))) return true;
}
return false;
}
Deno.serve({ port: 8080 }, async (req) => {
const raw = await req.text();
if (req.method !== "POST" || !(await valid(req, raw))) return new Response(null, { status: 401 });
const event = JSON.parse(raw);
if (event.job_id) {
await kv.atomic().check({ key: ["sume_job", event.job_id], versionstamp: null })
.set(["sume_job", event.job_id], { event: event.event, at: Date.now() }).commit();
}
return new Response(null, { status: 204 });
});Outcomes of the atomic claim
The result of commit() tells you which delivery you saw, but the answer to Sume is the same for both.
| Delivery | commit().ok | Answer | Effect |
|---|---|---|---|
| First for this job_id | true | 204 | Record written once |
| Retry after a lost 204 | false | 204 | No second write |
| Redeliver from the API | false | 204 | Same job_id, so ignored |
| webhook.test, no job_id | Not called | 204 | Nothing stored |
| Bad signature | Not called | 401 | Nothing stored |
Caveats
- Write the work you want done once (an upload, an email) as a record in the same store, and let a worker act on it. A commit that succeeds before your downstream call leaves the claim without the work if the process dies.
crypto.subtle.verifyneeds the 32 raw bytes of the digest, so the sample decodes the hex and skips anything that is not 64 hex characters.- Top-level
awaitis fine in Deno modules, which is why the sample has nomain()wrapper.
Sources
Related posts
More in Developers
- Design a render tool for a stateless MCP server: job ids as arguments
MCP 2026-07-28 removes protocol sessions. A render tool stays correct if its state lives in a job id the client passes back, as Sume jobs do.
- Cline oversized MCP result cache: keep Sume job results small
Cline's SDK now caches MCP results over 16 MiB and gives a preview plus a cache URI. Keep Sume job output small with jobs_wait include_results.
- Cut a 30-second hook from a video's audio: detach with a range
Pull just seconds 45 to 75 of a Sume-hosted video as a mono mp3 with POST /v1/audio-detach and range. Source and output caps, price and refusals.
- Detect new Sume video models daily: a Python catalog diff
Kling 4.0 and others may land any day. A short Python script diffs GET /v1/videos/models against yesterday's snapshot and prints new and removed model ids.
Written by Sume