Verify a Sume avatar video webhook in a Cloudflare Worker
Cloudflare Worker verifier for Sume avatar video webhooks: crypto.subtle HMAC-SHA256 over timestamp.body, timingSafeEqual per entry, empty secret refused.
The short answer
Import the secret with crypto.subtle.importKey, sign <timestamp>.<raw body> with HMAC SHA-256, and compare the sume-v1= string to every header entry with crypto.subtle.timingSafeEqual. Cloudflare's Web Crypto page (read 2026-10-04) lists timingSafeEqual as a non-standard extension that compares two buffers in a way that resists timing attacks.
The Worker verifier
The secret lives in a Worker secret binding named SUME_COM_WEBHOOK_SIGNING_SECRET, so a missing binding returns a 500 instead of verifying with an empty key. Read the body with request.text() once, then reuse the string.
const enc = new TextEncoder();
const hex = (b) => [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, "0")).join("");
export default {
async fetch(request, env) {
const secret = env.SUME_COM_WEBHOOK_SIGNING_SECRET;
if (!secret) return new Response("signing secret not set", { status: 500 });
const body = await request.text();
const ts = request.headers.get("x-sume-webhook-timestamp") ?? "";
const header = request.headers.get("x-sume-webhook-signature") ?? "";
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return new Response("stale", { status: 401 });
}
const key = await crypto.subtle.importKey(
"raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const mac = await crypto.subtle.sign("HMAC", key, enc.encode(`${ts}.${body}`));
const want = enc.encode(`sume-v1=${hex(mac)}`);
let ok = false;
for (const entry of header.split(",")) {
const got = enc.encode(entry.trim());
if (got.length === want.length && crypto.subtle.timingSafeEqual(got, want)) ok = true;
}
if (!ok) return new Response("bad signature", { status: 401 });
// store the event durably (a queue or D1), then acknowledge
return new Response("ok", { status: 200 });
},
};What Sume sends
Sume signs the raw JSON body of every terminal job event. An avatar talking video submitted with mode: "webhook" and a public HTTPS webhook_url ends in one of three events, and your endpoint must verify the signature before it trusts the payload. The contract from the webhook docs:
| Item | Value |
|---|---|
| Events | job.completed, job.failed, job.canceled (terminal only) |
| Timestamp header | x-sume-webhook-timestamp |
| Signature header | x-sume-webhook-signature: sume-v1=<hex>, comma-separated during rotation, newest first |
| Signed string | <timestamp>.<raw_body>, HMAC-SHA256, hex |
| Replay window | 300 seconds by default |
| Secret | SUME_COM_WEBHOOK_SIGNING_SECRET, from the dashboard Webhooks tab |
| Delivery | Up to 10 attempts, 30 s apart, 10 s timeout per attempt |
Why this Worker code is shaped this way
Cloudflare documents timingSafeEqual as taking two buffers, so the code checks the lengths first and only then calls it. The Worker has a comment where you would write to a queue or D1 before replying, and that write has to finish inside the 10 seconds Sume allows per attempt.
| Fact | Detail |
|---|---|
| Key import | crypto.subtle.importKey with { name: "HMAC", hash: "SHA-256" } |
| Signing | crypto.subtle.sign("HMAC", key, data) |
| Constant time | timingSafeEqual(a, b) compares two buffers resistant to timing attacks |
| Status | timingSafeEqual is a non-standard extension to Web Crypto |
Operating it
Verify against the raw bytes you received, never a parsed and re-serialized object, because any change in spacing breaks the HMAC. Return a 2xx only after you have stored the event durably, and use job_id as the idempotency key, since a delivery can arrive more than once. If your endpoint was down, POST /v1/jobs/{job_id}/webhook/redeliver (scope jobs:write) re-sends the real terminal event with a fresh timestamp and signature, and POST /v1/webhooks/test-deliveries sends a signed dummy webhook.test payload to try your route first. Keep polling the job status as a fallback, because ten refused attempts end automatic delivery.
The code refuses an empty secret and a stale or non-numeric timestamp, checks every sume-v1= entry in the header so a rotation never rejects a good delivery, and does not stop at the first match. A Standard avatar clip costs $0.184 per second, so a rejected webhook is not a rejected video: the render is already paid for and fetchable from the job result. For the same check in other languages, see the Node and Python versions, and the queue limits by plan if you submit many clips at once.
Sources
Related posts
More in Sume Avatar 1.0
- Verify a Sume avatar video webhook in C# with FixedTimeEquals
C# and .NET verifier for Sume avatar video webhooks: HMACSHA256 over timestamp.body, FixedTimeEquals, 300 s window, empty secret refused.
- Verify a Sume avatar video webhook in Deno with crypto.subtle.verify
Deno verifier for Sume avatar video webhooks: crypto.subtle.verify HMAC over timestamp.body, each sume-v1 entry checked, 300 s window, empty secret refused.
- Verify a Sume avatar video webhook in Go with hmac.Equal
Go verifier for Sume avatar video webhooks: HMAC-SHA256 over timestamp.body, hmac.Equal across every sume-v1 entry, 300 s replay window, empty secret refused.
- Verify a Sume avatar video webhook in Java with MessageDigest.isEqual
Java verifier for Sume avatar video webhooks: HmacSHA256 over timestamp.body, MessageDigest.isEqual for each sume-v1 entry, 300 s window, empty secret refused.
Written by Sume