Route Sume job webhooks: handle job.canceled, answer 204 to the rest
Sume sends job.completed, job.failed and job.canceled; run webhooks use another name. A TypeScript router that handles each and returns 204 for the rest.

Switch on event, handle job.completed, job.failed, and job.canceled, and return 204 for anything else. Sume sends only terminal job events, so there are three job cases. The SDK page adds that run webhooks use format.run.terminal with a run_id, and that you should never assume a body has run_id. An unknown event answered with 204 does not become a 500 and a retry storm.
Canceled jobs are the case people forget, because the happy path is job.completed. A cancel arrives as its own event, with status: "ERROR" and an error object.
The events and their fields
Facts from the Webhooks and Verifying webhooks pages, read 2026-10-09.
| Event | Surface | Status field | Extra |
|---|---|---|---|
job.completed | Generation job | OK | payload.artifacts[] |
job.failed | Generation job | ERROR | error object |
job.canceled | Generation job | ERROR | error object |
format.run.terminal | Format run | See Run webhooks | run_id |
The router
Verify first, on the raw body, and only then parse. The function refuses an empty secret, so a missing environment variable fails closed. The job id is also in request_id, so dedupe on that, because a retry after a slow response can deliver the same event again.
import { verifyWebhook } from "@sume-com/sdk";
export async function route(request: Request, secret: string): Promise<Response> {
if (!secret) return new Response("webhook secret not set", { status: 500 });
const body = await request.text(); // raw, before JSON.parse
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":
await onDone(event.job_id, event.payload.artifacts);
break;
case "job.failed":
case "job.canceled":
await onStopped(event.job_id, event.event, event.error);
break;
default:
break; // unknown or run event: acknowledge, do not 500
}
return new Response(null, { status: 204 });
}
declare function onDone(id: string, artifacts: unknown[]): Promise<void>;
declare function onStopped(id: string, kind: string, error: unknown): Promise<void>;Why 204 for the default
A delivery counts as delivered when your receiver returns a success status. If the router throws on an event it does not know, Sume treats that as a failed attempt and retries, up to 10 attempts at a 30 second gap by default. A new event type then multiplies into a pile of retries across every job. Answering 204 for what you do not handle avoids that.
Test all three cases, not only the first. Replay a captured completed body, then a failed one, then a body with an event name you made up, and assert the response codes: 204, 204, 204. Add a fourth case with a changed byte in the body and assert 401. Those four tests catch most router mistakes before a real cancel does.
Keep the work behind the response short. Each attempt has a 10 second timeout, so write the event to a queue or a table, return, and process it afterwards. And keep polling as a fallback: if a delivery is exhausted, POST /v1/jobs/{job_id}/webhook/redeliver sends it again, or you can read the job directly.
Sources
Related posts
More in Developers
- Sume webhook signature fails: compare the secret fingerprint first
Webhook signature mismatch? Compare x-sume-webhook-secret-fingerprint with the dashboard before touching code. It is the one value safe to paste in a ticket.
- Sume webhook_url without a mode field: it runs in webhook mode
Send webhook_url and omit mode, and Sume treats the submit as mode webhook: 202, job id in the first response, signed terminal callback, poll as backup.
- Text to speech API voice id: list avatars where voice.status is ready
Sume TTS rejects voice names from other services before any credit is held. Use avatar_id or avatar_handle, or a UUID or voi_ id copied verbatim.
- Three blind retries of a 30 s Seedance 720p submit can reserve $52.00
Without an Idempotency-Key, a timeout and two retries on one 30 s Seedance 2.5 clip can book $52.002. The same loop with one key, in curl, plus Wan 3.0 totals.
Written by Sume