Signed webhooks for Sume video runs: events, retries, verification
Sume sends one HMAC-SHA256 signed POST when a Format, Action, or Agent Completion run completes or fails. Verify the raw body and dedupe on request_id.

A Sume run webhook is one signed POST that Sume sends to your communication.webhook_url when an Action, Format, or Agent Completion run completes or fails, carrying the same receipt the poll endpoints return. It is signed with HMAC-SHA256 over <timestamp>.<raw_body>, so your receiver verifies the raw body, dedupes on request_id, and returns a 2xx quickly.
The rules below come from Sume's Run webhooks, generation-job Webhooks, and SDK Verifying webhooks pages. Delivery is live on production, api.sume.com, and polling stays supported as a backup.
How do I ask for a webhook?
Send communication.webhook_url when you start the run. It works the same on all three run surfaces.
- The URL must be public HTTPS, at most 2048 characters. Localhost, private-network, and non-HTTPS URLs are rejected with
400 invalid_request. communication.callback_urlis an accepted alias.communication.modeisasync(the default) orwebhook, but it is descriptive: the URL is what arms delivery.- Sume re-validates the URL at delivery time. Redirects are not followed, so a
3xxis not a delivery. - Generation jobs from model endpoints such as
/v1/avatar-1.0/generatetakemode: "webhook"withwebhook_urland sendjob.*events instead.
Which events will my endpoint receive?
Terminal events only. A run is one agent turn, so its event fires exactly once however many clips or images the turn produced, and continuing a run starts a new run with its own webhook. The outcome lives in status and payload.status, not in the event name. Format run lifecycle covers the receipt itself.
| You called | Event | Dedupe on |
|---|---|---|
| A Format run | format.run.terminal | request_id, which equals run_id |
| An Action run | action.run.terminal | request_id, which equals run_id |
| An Agent Completion | agent.run.terminal | request_id, which equals run_id |
| A model endpoint job | job.completed, job.failed, job.canceled | job_id |
What is in a run webhook payload?
The envelope wraps the run receipt:
statusisOKwhen the run completed andERRORwhen it failed.outcomeisok,degraded, orerror; branch on it when the question is whether you got usable output.degradedmeans the run completed and made real media inartifacts[], but could not project it into youroutput_schema, sooutputisnull.request_idis stable across retries, so it is the dedupe key. Usecreated_atto order deliveries.payloadis byte-identical to thedataobject ofGET /v1/{family}-runs/{run_id}, so one handler serves webhook and poll.- A receipt over 1 MiB arrives with
payload: nulland error codepayload_too_large. Fetch it fromresult_url. - Canceled and skipped runs never deliver a run webhook. Trust the cancel response or the create response instead.
How do I verify the webhook signature?
Each delivery carries x-sume-webhook-timestamp and x-sume-webhook-signature: sume-v1=<hex_signature>. In TypeScript, verifyWebhook from @sume-com/sdk runs the check, as in the handler below. What matters:
- Pass the raw body. A parsed and re-serialized object does not verify, because key order and whitespace are part of what was signed.
verifyWebhookisasync, returnsfalserather than throwing, compares in constant time, and enforces a replay window,toleranceSeconds, that defaults to300.- Read the signing secret on the dashboard Webhooks tab or from
GET /v1/webhooks/signing-secretwith a key carryingaccount:read. It is derived for your workspace. Store it asSUME_COM_WEBHOOK_SIGNING_SECRET. - If a signature will not verify, compare the
x-sume-webhook-secret-fingerprintheader with the fingerprint in the dashboard. - Run and job webhooks share one secret and one scheme, so one verifier covers both. Route on
event, and answer unknown events with204.
import { verifyWebhook } from "@sume-com/sdk";
export async function POST(request: Request) {
const body = await request.text(); // raw, before any JSON.parse
const ok = await verifyWebhook({
body,
headers: request.headers,
secret: process.env.SUME_COM_WEBHOOK_SIGNING_SECRET!,
});
if (!ok) return new Response("bad signature", { status: 401 });
const event = JSON.parse(body);
await recordTerminalRun(event.request_id, event); // dedupe on request_id
return new Response(null, { status: 204 }); // fast 2xx, then work
}What happens if my endpoint is down?
Return any 2xx quickly, after durably recording the event, and process afterward. A delivery outcome never changes the run itself; if every attempt is refused, read the run from result_url.
- Redeliver re-POSTs the real terminal event with a fresh timestamp and signature. It still works after automatic attempts are exhausted and does not consume one of the automatic 10.
- Send test (
POST /v1/webhooks/test-deliveries,account:write) fires a dummywebhook.testpayload. It is not a replay of a real run.
| Property | Run webhooks | Job webhooks |
|---|---|---|
| Attempts | Up to 10 attempts total, then exhausted | Up to 10 attempts total |
| Spacing | min(max(30s × 2^(attempt−1) with jitter, Retry-After), 1h) | A fixed delay, 30s by default, not exponential backoff |
| Timeout | 10s per attempt | 10s per attempt |
| Redeliver | Format runs: POST /v1/format-runs/{run_id}/webhook/redeliver with formats:write | POST /v1/jobs/{job_id}/webhook/redeliver with jobs:write |
How do I rotate the webhook signing secret?
Use Webhooks → Rotate secret, or POST /v1/webhooks/signing-secret/rotate with a key carrying account:write. Rotation is not a cutover: for 24 hours afterwards, Sume signs every delivery with both secrets and sends them comma-separated in x-sume-webhook-signature, newest first. Accept a delivery when any sume-v1= entry matches.
verifyWebhook in @sume-com/sdk 0.2.0 already handles the multi-signature header. A hand-rolled verifier that compares the header for equality fails on every delivery during the window, so upgrade the receiver before you rotate.
Sources
Related posts
Written by Sume