verifyWebhook in a fetch handler: four rules, 204 for unknown events
Use @sume-com/sdk verifyWebhook on the raw body, await it, treat false as 401 and answer unknown events with 204. A runnable handler for Workers, Deno and Node.

Pass verifyWebhook the raw body string, the request headers and your signing secret, await it, and return 401 when it comes back false. It does not throw on a bad delivery, it checks the replay window before the HMAC, and it accepts the two-signature header Sume sends during a secret rotation. The handler below runs on any runtime with fetch and WebCrypto: Node 18+, Bun, Deno or Cloudflare Workers.
The handler
Read the body with request.text() before you do anything else with the request. Parse JSON only after the signature passes. The secret comes from SUME_COM_WEBHOOK_SIGNING_SECRET, the name the Sume delivery worker uses, and the function refuses to run without it.
import { verifyWebhook } from "@sume-com/sdk";
const seen = new Set(); // swap for a unique-key table
export async function POST(request) {
const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET;
if (!secret) return new Response("signing secret not configured", { status: 500 });
const body = await request.text(); // raw bytes, before JSON.parse
const ok = await verifyWebhook({ body, headers: request.headers, secret });
if (!ok) return new Response("bad signature", { status: 401 });
let event;
try {
event = JSON.parse(body);
} catch {
return new Response(null, { status: 204 }); // signed but not JSON: log it, do not loop
}
const key = event.job_id ?? event.request_id;
if (key && !seen.has(key)) {
seen.add(key); // durable write first, work after the response
}
return new Response(null, { status: 204 });
}The four rules
The SDK page names four rules that decide whether verification works. Each one answers a bug that shows up in a first receiver.
| Rule | Why | Common mistake |
|---|---|---|
| Pass the raw body | The key order and whitespace are part of the signed bytes | Letting a framework parse JSON first and re-serializing it |
await it | It uses WebCrypto, so it is async | Using the unresolved promise, which is always truthy |
| Branch on the return value | It returns false instead of throwing | Wrapping it in try/catch and treating a throw as the only failure |
| Constant-time compare | The replay window is checked first, then the HMAC | Comparing the header with ===, which fails during a rotation |
Unknown events get a 204
Sume sends job events (job.completed, job.failed, job.canceled) and run events (format.run.terminal, action.run.terminal, agent.run.terminal) with the same signature scheme, so one endpoint can serve both, and the SDK page recommends exactly that. The payloads differ: a job body has job_id, a run body has run_id, and both carry request_id. Route on the event field and never assume a field that only one family has.
A new event type that your code does not know should return 204. A 500 is a failed attempt, and Sume retries failed attempts up to ten times, so an unknown event that throws turns into ten calls for nothing.
Rotation readiness
After a rotation, Sume signs each delivery with both the new and the previous secret for 24 hours and sends the two signatures in one header, newest first and separated by a comma. A hand-written check that compares the whole header to one value fails every delivery in that window. verifyWebhook in @sume-com/sdk 0.2.0 handles it, so upgrade the receiver before you rotate and not after.
The x-sume-webhook-secret-fingerprint header carries a 12-character fingerprint of the secret. When a signature does not verify, compare it to the fingerprint on the Webhooks page, and paste only that into a ticket, never the secret.
Store first, then work
The handler above keeps its seen-set in memory so that it runs by itself. In production, make that a table with a unique key on the job or run id and write the row before you return. Return 2xx in well under the 10-second attempt budget, then do the real work, such as downloading media, from a queue. The delivery is at-least-once, and the unique key is what makes a repeat harmless.
Test the handler without Sume. Sign a fixture body with a throwaway secret, post it to your route, and check that you get 204. Then change one byte of the body, or move the timestamp outside the 300-second replay window, and check that you get 401. Those two checks cover most of what goes wrong in a first receiver, and neither needs a paid job.
Finally, decide what the handler does with a delivery it verified but cannot process, for example a body that is not JSON. Log it and return 204. A signed but unusable event is a bug to read in your logs, and a retry will not fix it.
Sources
Related posts
More in Developers
- Wan 3.0 API request cheat sheet: three modes, 2 to 30 seconds
Wan 3.0 on Sume: the request body for text, first/last frame and reference modes, the 480p/720p/1080p rates and the 2 to 30 second window, on one page.
- GPT Image 1 to GPT Image 2.5 on Sume: what changes in the output
Moving from GPT Image 1 to ChatGPT Image 2.5 on Sume changes the response (URL, not base64), default quality, size grid and failures.
- What is a partial transcript in streaming speech to text?
A partial is a provisional transcript a streaming model revises as audio arrives. Why subtitles for a finished clip only need final text and word times.
- What to show a viewer while an avatar video job is queued
Avatar jobs on Sume are async: queued, processing, then completed, failed or canceled. A status-to-UI map for waiting screens, with polling rules.
Written by Sume