Deno.serve webhook receiver for Sume jobs: Web Crypto HMAC, rotation
Verify Sume job webhooks in Deno with Web Crypto: refuse an empty secret, accept any rotation entry, 300 s tolerance, return 204. Retry window is 370 s.

A Sume job webhook is verified by recomputing an HMAC SHA-256 of timestamp.raw_body with your signing secret and comparing it with any sume-v1= entry in the x-sume-webhook-signature header. The Deno receiver below does that with Web Crypto, refuses to run with an empty secret, rejects timestamps more than 300 seconds old, and returns 204 after it has handled the event. Sume treats any 2xx as delivered.
What Sume sends
Webhooks carry terminal events only: job.completed, job.failed and job.canceled. The JSON body has event, request_id, job_id, status and a payload. The signature header can hold more than one entry during a secret rotation, newest first, separated by commas, and you accept the delivery if any entry matches. The secret itself is yours: read it from the dashboard Webhooks tab or from GET /v1/webhooks/signing-secret, and store it as SUME_COM_WEBHOOK_SIGNING_SECRET.
| Rule | Value |
|---|---|
| Signed string | <timestamp>.<raw_body> |
| Headers | x-sume-webhook-timestamp, x-sume-webhook-signature |
| Signature format | sume-v1=<hex>, comma-separated during rotation |
| Replay tolerance | 5 minutes is the suggested default |
| Attempts | Up to 10, 30 s apart, 10 s timeout each |
| Upper bound of the retry window | 10 x 10 s + 9 x 30 s = 370 s |
| Idempotency on your side | Use job_id |
Why the raw body
The signature covers the exact bytes Sume sent. The handler therefore reads req.text() once, verifies that string, and only then calls JSON.parse. Parsing first and re-serializing would change whitespace and break the match. The comparison loop touches every character of every candidate and does not return early, so timing does not reveal how close a forged signature was.
The check also rejects when the header is missing, because an empty string splits into one empty entry that cannot equal the expected value.
const enc = new TextEncoder();
const hex = (b: ArrayBuffer) => [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, "0")).join("");
export async function verify(body: string, headers: Headers, secret: string): Promise<boolean> {
if (!secret) throw new Error("refusing to verify with an empty secret");
const ts = Number(headers.get("x-sume-webhook-timestamp"));
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const want = `sume-v1=${hex(await crypto.subtle.sign("HMAC", key, enc.encode(`${ts}.${body}`)))}`;
let ok = false;
for (const part of (headers.get("x-sume-webhook-signature") ?? "").split(",")) {
const got = part.trim();
let diff = got.length ^ want.length;
for (let i = 0; i < want.length; i++) diff |= (got.charCodeAt(i) || 0) ^ want.charCodeAt(i);
if (diff === 0) ok = true;
}
return ok;
}
const secret = Deno.env.get("SUME_COM_WEBHOOK_SIGNING_SECRET") ?? "";
Deno.serve({ port: 8787 }, async (req) => {
const body = await req.text();
if (!(await verify(body, req.headers, secret))) return new Response("bad signature", { status: 401 });
const event = JSON.parse(body);
console.log(event.event, event.job_id);
return new Response(null, { status: 204 });
});Deploying it
Run with deno run -A receiver.ts and put it behind public HTTPS; Sume rejects localhost and non-HTTPS callback URLs. Store the event before you return 204, because a non-2xx or a slow answer is retried. After ten refused attempts the delivery fails while the job still reaches its real state, so keep a slow poll on status_url as a backup.
The docs do not say whether each automatic retry is re-signed with a fresh timestamp, so a receiver should not depend on that. The manual redeliver route does send a fresh timestamp and signature.
To test it locally, sign a body yourself with the same function: compute HMAC SHA-256 over the timestamp, a dot and the body, prefix the hex with sume-v1=, and send the headers with curl to the running server. A correct signature gives 204, a changed body gives 401, and a start without SUME_COM_WEBHOOK_SIGNING_SECRET fails at the first request with the empty-secret error, which is what you want. Log the x-sume-webhook-secret-fingerprint header on failures; comparing it with the fingerprint in the dashboard tells you whether the sender and receiver use the same secret without exposing it.
- Return 204 only after the event is stored.
- Deduplicate on job_id.
- Test with a rotated secret by sending two entries.
Sources
Related posts
More in Developers
- Idempotency-Key from a body hash plus a take number (Node, Sume)
Build an Idempotency-Key for Sume /v1/videos from a SHA-256 of the body plus a take counter, so a retry replays and a re-roll pays. Node code.
- Sume STT returns 400 for diarize: a two-speaker fix at $0.60 an hour
Sume STT 1.0 fixes diarize and tag_audio_events server-side and rejects them with 400. For two speakers, transcribe each track and merge by word times.
- Do Sume result URLs expire? media.sume.com vs /videos content
Sume Format artifact URLs on media.sume.com do not expire and are public. The /v1/videos content route needs your key. What to store, plus a Python download.
- Edit an existing clip with Gemini Omni Flash 1.1: video_url on Sume
Sume exposes Omni video edit mode through the Video Router video_url field: the prompt is the instruction, resolution defaults to 720p, no duration.
Written by Sume