One Sume webhook route for job and run events, 204 on the rest
Route job.* and *.run.terminal events from one verified webhook handler and answer 204 for any unknown event, so a new type cannot trigger retries.

Use one route for both Sume webhook surfaces, verify the signature once, switch on event, and answer 204 for any event name you do not know. A 500 on a new event type makes Sume retry the same delivery, up to 10 attempts 30 seconds apart, for a payload you will never handle. The signature scheme is identical for job webhooks and run webhooks, so one verifier covers both.
The two event families
Job webhooks send terminal events only, with no progress or partial deliveries. Canceled and skipped runs send no run webhook.
| Family | Events | Dedupe on |
|---|---|---|
| Generation job | job.completed, job.failed, job.canceled | job_id |
| Run | format.run.terminal, action.run.terminal, agent.run.terminal | run id or request_id |
The handler
This uses verifyWebhook from @sume-com/sdk 0.2.0, which is async, returns false instead of throwing, and compares in constant time. It reads the raw body first, refuses an empty secret at load time, and keeps the saveOnce stub as a place for a unique-key insert.
import { verifyWebhook } from "@sume-com/sdk";
const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET;
if (!secret) throw new Error("SUME_COM_WEBHOOK_SIGNING_SECRET is empty");
const seen = new Set(); // swap for a unique-key insert
const saveOnce = async (key) => void seen.add(key);
export async function POST(request) {
const body = await request.text(); // raw bytes first
const ok = await verifyWebhook({ body, headers: request.headers, secret });
if (!ok) return new Response("bad signature", { status: 401 });
const event = JSON.parse(body);
switch (event.event) {
case "job.completed":
case "job.failed":
case "job.canceled":
await saveOnce(`job:${event.job_id}`);
break;
case "format.run.terminal":
case "action.run.terminal":
case "agent.run.terminal":
await saveOnce(`run:${event.run_id ?? event.request_id}`);
break;
default:
break; // a new event type is not an error
}
return new Response(null, { status: 204 });
}Why 204 is the right default
Add a log line in the default branch with the event name. It shows you new event types the first day they appear, without making them an incident.
- Sume does not follow redirects and counts a non-2xx as a failed attempt, so an error response retries.
- Retries arrive with the same job or request id, so dedupe on that id before side effects.
- Return fast. Do slow work after the response, because each attempt times out after 10 seconds.
- A bad signature gets 401 and nothing else. Do not reveal why it failed.
Testing the default branch
Send yourself a signed body with an invented event name, such as job.paused, and check that the handler returns 204 and writes nothing. Sume also offers POST /v1/webhooks/test-deliveries, which sends a dummy webhook.test event to your URL. That event is a ready-made unknown event for your default branch, so a passing test delivery shows the whole route works: signature, parse, switch and 204.
Do the same for the opposite case. Send a body with a bad signature and check for 401. Both outcomes belong in your tests before the first paid job.
Sources
Related posts
More in Developers
- urllib3 Retry on POST: retry a Sume submit only with a key
A urllib3 Retry config that retries 429, 502, 503 and 504 on a Sume submit, and a wrapper that refuses to send a POST without an Idempotency-Key.
- Sume video poll usage.cost: reserved while running, captured at end
The usage.cost number is the Sume billable amount: the reservation while a job runs and the captured amount once it settles. wan-3.0 at 720p, 10 s, as math.
- Validate a Gemini Omni Flash 1.1 request in Python before sending
A 28-line Python check for Sume's gemini-omni-flash-1.1 rules: 3-10 s, 10 reference images, 3 reference videos, no audio off, and edit mode exclusions.
- A Veo 3.1 call becomes a Sume Omni job in under 30 lines of Python
Replace a Veo 3.1 request with a Sume gemini-omni-flash-1.1 job: submit, poll every 30 seconds, download the mp4 and read usage.cost. Standard library only.
Written by Sume