One Sume webhook endpoint for job and run events: route on event
Job and run webhooks share one HMAC scheme but not one payload. A 27-line TypeScript handler verifies once, routes on event, and answers 204 to the rest.

A single endpoint can receive both Sume job webhooks and Sume run webhooks, because they use the same sume-v1 signature. Verify once with verifyWebhook from @sume-com/sdk, then switch on event. Do not expect run_id in every body, and answer 204 to any event name you do not know, so a new event type does not cause a retry storm.
The payloads differ even though the signature does not. A job event carries job_id and a payload.artifacts list. A run event carries the full run receipt. Routing on the event field keeps the two shapes apart.
The events
Sume sends terminal events only. There are no progress or partial deliveries, so a webhook cannot drive a progress bar.
| Event | Surface | Dedupe on |
|---|---|---|
| job.completed, job.failed, job.canceled | Generation jobs from /v1/models/... and model endpoints | job_id |
| action.run.terminal, format.run.terminal, agent.run.terminal | Action, Format, and Agent Completion runs | request_id of the run receipt |
| webhook.test | Send test from the dashboard | Nothing. It is not a job or a run. |
The handler
It refuses to run without a secret, reads the raw body before any parsing, and stores the event before it returns. saveOnce is yours to write; a unique database key on the string it receives is enough to make retries harmless.
import { verifyWebhook } from "@sume-com/sdk";
export async function POST(request: Request) {
const body = await request.text();
const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET;
if (!secret) return new Response("webhook secret not configured", { status: 500 });
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}`, event);
break;
case "format.run.terminal":
await saveOnce(`run:${event.request_id}`, event);
break;
default:
break; // webhook.test and future events: acknowledge, do not retry
}
return new Response(null, { status: 204 });
}
declare function saveOnce(key: string, event: unknown): Promise<void>;Behavior to plan for
Job webhooks need a public HTTPS webhook_url; localhost and private-network URLs are rejected when you submit.
- Return a
2xxonly after the event is stored. Sume retries network errors and non-2xx responses, up to 10 attempts in total. - Each attempt has a 10-second timeout, so a slow endpoint spends the budget.
- After ten refused attempts the job is still terminal; only the delivery failed. Keep polling the status URL as a backstop.
- Redeliver re-sends the real terminal event with a fresh timestamp and signature, and does not use one of the automatic ten.
Where the secret comes from
Read it from the Webhooks tab of the dashboard, or from GET /v1/webhooks/signing-secret with an account:read key, and store it as SUME_COM_WEBHOOK_SIGNING_SECRET. It is derived per workspace, so a valid signature proves Sume signed the delivery for you. It is not your API key; keep the two apart.
Keeping the handler small
Resist the urge to do work inside the request. Verify, store, and return. A separate worker reads the stored events, downloads media from the job artifacts, and updates your own records. The request then finishes well inside the 10-second timeout, and a crash in the worker never causes a redelivery.
Store the event name next to the id. A job and a run may share nothing, but your table will be easier to query when each row says which kind it is.
Add a test for each branch: a job event, a run event, an unknown event, and a bad signature. The last one should return a client error and store nothing.
If the worker finds an event it cannot process, mark the row as parked and move on. Do not return an error to Sume for it, because a retry carries the same body and will fail the same way.
Sources
Related posts
More in Integrations
- Stdlib Python Sume webhook receiver: http.server, 204 for webhook.test
A 29-line http.server receiver that refuses an empty secret, checks sume-v1 in a 300-second window, takes the two-signature header, and 204s webhook.test.
- Roo Code alwaysAllow for Sume MCP: which tools to auto-approve
Roo Code alwaysAllow skips the approval click. Auto-approve Sume read tools only, and keep paid ones manual or behind dry_run and a spend cap.
- Roo Code MCP timeout 60 s default: add Sume as streamable-http
Roo Code defaults MCP requests to 60 seconds and allows 1 to 3600. Here is the streamable-http entry for Sume with a timeout that fits jobs_wait.
- Sume MCP idempotency_key: retry a timed-out paid call safely
Reuse the same idempotency_key to retry a paid Sume MCP call that timed out. A new key makes a new job. A reused key with a different payload returns 409.
Written by Sume