Run webhooks

Every run surface accepts a communication.webhook_url. When the run completes or fails, Sume sends one signed POST to that URL carrying the same receipt the poll endpoints return. Delivery matches fal’s webhook shape: envelope status is OK or ERROR for those outcomes — not for cancel.

This is the alternative to a poll loop. You still get status_url and result_url, and polling remains supported — a webhook just saves you from running a loop per run.

Availability

EnvironmentDelivery
Development — api.dev.sume.comLive. Your endpoint is called.
Production — api.sume.comLive. Your endpoint is called.

Supplying communication.webhook_url arms delivery in both environments. Polling status_url / result_url remains supported as a backup. Any run whose caller already supplied a webhook_url (including older runs that reach a terminal state after enablement) can receive a POST — register only endpoints you still want traffic on.

This page covers run webhooks. Generation-job webhooks (job.completed and friends, from POST /v1/models/...) are a separate surface with a separate event set — see Webhooks. The signature scheme is identical, so one verifier covers both.

Ask for a webhook

Send communication.webhook_url when you start the run. It works the same on all three surfaces.

Create a Format run

POST /v1/formats/{handle}/{slug}/runs

Required

FieldNotes
communication.webhook_urlPublic HTTPS URL, max 2048 characters. Localhost, private-network, and non-HTTPS URLs are rejected with 400 invalid_request.
communication.callback_urlAccepted alias for webhook_url. Identical behavior. Send one or the other.
communication.modeasync (default) or webhook. The URL is what arms delivery; mode is descriptive.
Top-level webhook_url / callback_url / modefal-shaped aliases. Normalized into communication.*. Same value on both layers is fine; conflicting values return 400 invalid_request.

The URL is re-validated as a public HTTPS URL at delivery time, not only when you submit. Redirects are not followed — a 3xx is not a delivery.

Events

One terminal event per run family. The outcome lives in status and payload.status, not in the event name.

SurfaceEventReceipt object
Action runsaction.run.terminalaction.run
Format runsformat.run.terminalformat.run
Agent Completionsagent.run.terminalagent.run

A partner embedding only Formats can route on event === "format.run.terminal" without inspecting the body.

One per turn, not one per artifact

A run is one agent turn, so its terminal event fires exactly once — however many clips, images, or intermediate files that turn produced. There is no per-artifact run event, and none is planned.

That matters for continued runs: continuing a run starts a new run, which delivers its own single terminal webhook under the new run id. The original run's webhook already fired and will not fire again.

If you want progress inside a turn, that is the generation-job layer (Webhooks), which fires per job as each one completes. Job events name a job, not a step of your recipe.

Payload

FieldNotes
eventSee the table above.
request_idEquals run_id. Stable across retries — use it to dedupe.
run_idThe run this delivery is about.
objectThe receipt's own object.
statusOK when the run completed, ERROR when it failed. Binary — see outcome.
outcomeok, degraded, or error. Branch on this when the question is "did I get usable output".
created_atWhen this delivery body was built. Use it to order deliveries — request_id cannot, since it is stable across retries.
payloadThe run receipt. null only on overflow — see below.
errornull when status is OK; otherwise { code, message }.

does not always mean you got output

A run can complete, bill you, and produce real media in artifacts[] while still failing to project that media into your output_schema. status is OK on that path — the run genuinely completed — but output is null and output_error says why. That is what outcome: "degraded" names.

A handler written against status alone keeps working; it just cannot tell ok from degraded.

request_id

The envelope's request_id is the run id — it is the dedupe key, and it is deliberately stable across retries. The receipt nested at payload.request_id is a correlation id for the call that produced it, and it is the run id here but an HTTP req_… id when you read the same receipt from GET /v1/format-runs/{run_id}. Dedupe on the envelope's request_id (or run_id, which equals it) and ignore the nested one.

usage.billable_amount_usd_micros carries the generation spend attributed to the run, and usage is null when that could not be read — see Runs and results. GET /v1/usage stays the authoritative billing record.

is the receipt

payload is byte-identical to the data object of GET /v1/{family}-runs/{run_id} for the same run. The poll response wraps it in { "data": ... }; the webhook does not.

It is built by the same code path the poll endpoint calls, so it cannot drift.

Failure, cancelation, and skips

A failed run arrives with status: "ERROR" and a populated error. payload is still the full receipt — a failed run's receipt carries artifacts and output_error, and you usually want them.

error.code mirrors payload.error.code when the receipt carries one — typically a specific reason such as output_schema_unsatisfied, otherwise the family's generic action_run_failed / format_run_failed / agent_run_failed.

A canceled run does not deliver a webhook. Cancel is a separate API path (same idea as fal’s queue cancel — no cancel webhook status). After you POST …/cancel, trust the cancel response and poll status_url until payload.status is canceled; do not wait for a POST.

A skipped run never delivers a webhook. on_active_run: "skip" records a terminal run immediately, without ever starting work, so there is no completion to notify you about — the create response already told you. Read status on the response you got back rather than waiting for a POST that will not arrive. Defaults differ by surface: Format defaults to allow (concurrency); Action defaults to skip — see Calling a Format vs Action API trigger.

Oversized receipts

A receipt over 1 MiB cannot be delivered inline. Sume sends the envelope with payload: null and an error telling you where to fetch it:

status still reports the run's real outcome. A run that succeeded and was too large to ship did not fail.

Signature

Sume signs the raw JSON body with HMAC-SHA256 over <timestamp>.<raw_body>.

Verify against the raw request body, before any JSON parse or re-serialize. Reject a timestamp outside your replay window — five minutes is a reasonable default.

Read your signing secret on the Webhooks tab of the dashboard (/dashboard/webhooks), or from GET /v1/webhooks/signing-secret with any API key carrying account:read. It is derived for your workspace — nobody else's secret verifies a delivery signed for you. Store it as SUME_COM_WEBHOOK_SIGNING_SECRET (the same name the delivery worker uses when signing).

Every delivery carries x-sume-webhook-secret-fingerprint, and webhook_delivery.signing_secret_fingerprint on the run receipt repeats it. Compare it with the fingerprint shown next to the secret in the dashboard to confirm both sides hold the same one, without sending the secret anywhere.

In TypeScript, @sume-com/sdk ships this check — see Verifying webhooks:

The scheme in full, for a receiver that cannot use it — another language, or a gateway in front of your app:

This is the same verifier that validates generation-job webhooks. Write it once.

Delivery behavior

PropertyValue
WhenOnce per run, when it completes or fails.
SuccessAny 2xx.
RetriesUp to 10 attempts total, then webhook_delivery.status is exhausted.
Backoffmin(max(30s × 2^(attempt−1) with jitter, Retry-After), 1h). Honour Retry-After on 429/503.
Timeout10s per attempt.
RedirectsNot followed. A 3xx is a failed attempt.

Return 2xx quickly, after durably recording the event — do your processing afterward. A slow endpoint burns the 10-second attempt budget and gets retried.

Dedupe on request_id. It is the same value on every retry of the same run.

A delivery outcome never changes the run itself. An endpoint that refuses all ten attempts leaves you with a failed delivery and a run that is still completed; fetch it from result_url.

Send test and Redeliver

Send test lives once on /dashboard/webhooks (also POST /v1/webhooks/test-deliveries, account:write). It fires a dummy webhook.test payload. It is not a replay of a real Format run.

To replay a real terminal call, use Redeliver on that delivery row, or:

Requires formats:write. Empty body. Re-POSTs the current format.run.terminal receipt with a fresh timestamp and signature. This still works after automatic attempts are exhausted — it does not consume one of the automatic 10.

409 webhook_not_configured if the run had no webhook_url. 409 run_not_terminal if it is still running. A run you cannot see is 404 format_run_not_found. Missing formats:write is 403 insufficient_scope, never 404.

Dedupe on request_id / run_id. Redeliver does not send to a different URL.

Job redeliver is documented on Webhooks.

Next