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
| Environment | Delivery |
|---|---|
Development — api.dev.sume.com | Live. Your endpoint is called. |
Production — api.sume.com | Live. 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
| Field | Notes |
|---|---|
communication.webhook_url | Public HTTPS URL, max 2048 characters. Localhost, private-network, and non-HTTPS URLs are rejected with 400 invalid_request. |
communication.callback_url | Accepted alias for webhook_url. Identical behavior. Send one or the other. |
communication.mode | async (default) or webhook. The URL is what arms delivery; mode is descriptive. |
Top-level webhook_url / callback_url / mode | fal-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.
| Surface | Event | Receipt object |
|---|---|---|
| Action runs | action.run.terminal | action.run |
| Format runs | format.run.terminal | format.run |
| Agent Completions | agent.run.terminal | agent.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
| Field | Notes |
|---|---|
event | See the table above. |
request_id | Equals run_id. Stable across retries — use it to dedupe. |
run_id | The run this delivery is about. |
object | The receipt's own object. |
status | OK when the run completed, ERROR when it failed. Binary — see outcome. |
outcome | ok, degraded, or error. Branch on this when the question is "did I get usable output". |
created_at | When this delivery body was built. Use it to order deliveries — request_id cannot, since it is stable across retries. |
payload | The run receipt. null only on overflow — see below. |
error | null 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
| Property | Value |
|---|---|
| When | Once per run, when it completes or fails. |
| Success | Any 2xx. |
| Retries | Up to 10 attempts total, then webhook_delivery.status is exhausted. |
| Backoff | min(max(30s × 2^(attempt−1) with jitter, Retry-After), 1h). Honour Retry-After on 429/503. |
| Timeout | 10s per attempt. |
| Redirects | Not 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
- Verifying webhooks —
verifyWebhookandSUME_COM_WEBHOOK_SIGNING_SECRET - Runs and results — the receipt, field by field
- Advanced: run a schedule via API
- Calling a Format
- Embed a Format in your product — the whole partner integration, end to end
- Webhooks — generation-job webhooks, the other surface