Runs and results
POST …/runs answers immediately with a receipt. The run takes minutes. This page is about the
time after the 202. It tells you how to know that the run finished, and what the finished receipt
contains.
There are two ways to know the result, and the two ways give the same receipt:
| Webhook | Poll | |
|---|---|---|
| You do | Send communication.webhook_url on the create. Verify the signature. Answer 2xx. | Read the run until status is terminal. |
| You get | One signed POST for each run, when it completes or fails. | The same receipt, on your schedule. |
| Costs you | One public HTTPS endpoint. | One timer for each run in progress, and read budget. |
| Available | api.dev.sume.com and api.sume.com. | On all hosts. |
Production integrations use the two ways. The webhook is the fast path. A read of result_url is
the backup for the day when your endpoint is down. When a delivery fails, the run does not
change.
Lifecycle
| Status | Meaning |
|---|---|
queued | Accepted, not started. |
processing | The run is in progress. |
completed | Finished. output, artifacts[], and primary_output_url are populated. |
failed | Finished with an error. error gives the cause. artifacts[] still holds all the media that the run made. |
canceled | POST …/cancel stopped the run. The word has one l. |
skipped | The run never started: you sent on_active_run: "skip", and a different run was in progress. |
next_action on the receipt tells you what to do. It is poll_status while the run is queued
or processing, retry_later on a skipped run, and none on each terminal run. Those are the
only three values that a Format run emits.
Poll
The receipt holds its own URLs. Use these URLs. Do not build the paths yourself.
| URL on the receipt | Endpoint | Returns |
|---|---|---|
| (the receipt itself) | GET /v1/format-runs/{run_id} | The full receipt at all statuses. Most integrations poll this URL. |
status_url | GET /v1/format-runs/{run_id}/status | The small poll payload: status, next_action, cancelable, expires_at, queue, timestamps, and the URLs again. When the run is terminal, it also holds usage, and error on a failure. It never holds output, artifacts, or primary_output_url. |
result_url | GET /v1/format-runs/{run_id}/result | The full receipt when the run is terminal. While the run is in progress, it returns 409 run_not_completed with details.status. |
events_url | GET /v1/format-runs/{run_id}/events | The phase timeline. Refer to Watch a run progress. |
cancel_url | POST /v1/format-runs/{run_id}/cancel | Stops the run. Refer to Cancel. |
A loop that reads the full receipt and stops on a terminal status:
Use these three practices to keep a poll loop correct:
- Back off. Long-form video is 15 to 30 minutes of work. Thus, a poll each second gives you nothing and uses read budget. Double the gap, up to one minute. A
429or503during the loop is temporary, because the run continues to execute and to spend. Thus, wait and poll again, and do not think that the run failed. - Use
expires_atas your ceiling. A non-terminal receipt holds the deadline after which Sume force-finalizes the run asfailed. The deadline is 90 minutes fromcreated_at, or earlier when the run is older than 25 minutes and was silent for 10. Use this value to set your own timeout, and do not invent a different one. When the run is terminal, it isnull. - Read
queueifqueuedlasts.queue.stateiswaitingwhile the pickup is in the normal window. It isruntime_unavailablewhen the run waited longer than that window and nothing claimed it. Thenretry_after_secondstells you how long to back off.positionis alwaysnull, because Sume does not publish the queue depth. If aruntime_unavailablelasts more than a few minutes, send a support ticket with therequest_id.
In TypeScript, subscribeFormatRun creates the run and runs this loop for you.
waitForRun does the loop for a run id that you already have.
Webhook
If you send communication.webhook_url on the create, Sume POSTs the terminal receipt to it one
time. This occurs when the run completes or fails, on api.dev.sume.com and on api.sume.com. A
canceled or skipped run never delivers. The cancel call answers you directly, and a skipped run is
already terminal on the create response.
| Field | Branch on it for |
|---|---|
event | Always format.run.terminal for a Format run. Route on it. You do not need to examine the body. |
request_id, run_id | Equal, and stable across retries. They are the dedupe key. |
status | OK when the run completed, ERROR when it failed. |
outcome | ok: completed with output. degraded: completed and billed, with real media in artifacts[]. But output is null because the projection did not match your schema, and output_error gives the cause. error: the run did not complete. Branch here when the question is "did I get usable output". |
payload | The run receipt. It is byte-identical to data from GET /v1/format-runs/{run_id}, thus one handler serves the two transports. It is null only when the receipt was more than 1 MiB. Then error.code is payload_too_large, and error.result_url gives the address to fetch it. |
error | null on OK. On other statuses, { code, message }, the same as payload.error. |
created_at | The time when Sume built this delivery body. Use it to put deliveries in order. You cannot use request_id for the order, because it repeats on retries. |
Verify every delivery
Each POST has three headers:
The signature is HMAC-SHA256 over <timestamp>.<raw_body> with the signing secret of your
workspace. You can read this secret on the Webhooks tab of the dashboard, or from
GET /v1/webhooks/signing-secret (each key with account:read can read it).
Before you parse the body, verify the signature against the raw bytes. Reject timestamps outside a five-minute window. Compare the fingerprint header with the fingerprint next to the secret. This makes sure that the two sides hold the same secret. The Cookbook has complete receivers in Node and Python. In TypeScript, the check is one call:
Delivery rules
| Property | Value |
|---|---|
| When | One time for each run, on completed or failed. Never on canceled or skipped. |
| Success | All 2xx codes, in not more than 10 seconds. Record the event in durable storage. Then answer. Then do the work. |
| Retries | Up to 10 attempts. The backoff is the longer of two values: exponential (30 s × 2^(attempt−1), with jitter), or your Retry-After on a 429/503. The maximum backoff is one hour. |
| Redirects | Sume does not follow redirects. A 3xx is a failed attempt, thus register the final URL. |
| URL rules | Public HTTPS only. Localhost, private ranges, credentials in the URL, and plain HTTP give 400 invalid_request at create. Sume examines the URL again at delivery time. |
| Dedupe | On request_id. Each retry repeats it. |
A delivery outcome never changes the run. After ten refused attempts, you have a failed
delivery and a run that is still completed. Fetch the run from result_url.
Check what happened to a delivery
Each receipt for a run created with a webhook_url has a webhook_delivery block:
status | Means |
|---|---|
not_armed | Sume stored the URL, and no delivery is scheduled yet. The run is still in progress. |
pending, retrying | Armed. next_attempt_at is the time of the next attempt. |
delivered | Your endpoint answered 2xx. |
failed, exhausted | Sume stopped the attempts. last_status_code and last_error (our transport error, never your body) give the cause. The run did not change. |
You can replay a terminal delivery after you repair your receiver, or to do a test of a receiver.
To replay, call POST /v1/format-runs/{run_id}/webhook/redeliver with formats:write and an empty
body. It re-POSTs the current receipt with a new timestamp and signature. It does not use one of
the ten automatic attempts. The call returns 409 webhook_not_configured when the run had no URL.
It returns 409 run_not_terminal while the run is still in progress.
The full contract is on Run webhooks. That page also gives the
generation-job webhooks that POST /v1/models/… emits on a different event set.
Watch a run progress
status tells you if a run is done. GET /v1/format-runs/{run_id}/events tells you what the run
does now:
preparing is all the work before the agent gets the run. running is the time when the agent
does the recipe, and most of the time goes there. finalizing is teardown and output harvest. Each
entry has a status (pending, running, done, warning, error, skipped), and a
duration_ms when the phase measured itself.
Consecutive entries with the same phase and status become one entry, and its at continues to
increase. Thus, at on the last entry is the progress clock of the run. If this clock does not
move for several minutes, the run is stalled, not slow. Sume will finalize the run at the bounds
above.
It is a phase timeline, not a log stream. Sume does not publish agent output, tool calls, or sandbox internals here, and will not publish them in the future. There is no push channel for progress. Poll this endpoint, or ignore it and use the terminal webhook.
The run receipt
GET /v1/format-runs/{run_id} returns the full shape at all statuses.
| Field | Notes |
|---|---|
id, object | arun_…, and format.run. |
format | { id, slug, title, version }. id is the opaque skl_…. version is the Format version that ran. Later edits do not change it. |
status, next_action, cancelable | Refer to Lifecycle. cancelable is true while the run is queued or processing. |
trigger | { source: "api", idempotency_key } for each run that you create. |
created_at, started_at, finished_at | The last two are null until the events occur. |
expires_at, queue | The deadline and the pickup state. Refer to Poll. |
output_schema | { name, strict, source }: the schema that gave output its shape. source is request_override when you sent a schema. |
output | The structured result. null on each non-terminal status, and null when no result satisfied output_schema. A failed run still publishes the partial result that it wrote, if there is one. |
output_error | { code, message, details } when the run could not produce output. Examine it before you read output. |
primary_output_key, primary_output_url | The one item to show. Both are null unless the run is completed. |
artifacts[] | Each durable file that the run generated: { id, type, url, content_type, size_bytes, width, height, duration_ms, checksum_sha256 }. Empty until the run is terminal. Also populated on failures. |
usage | { currency, billable_amount_usd_micros, generation_spend_cap_usd_micros, debited_usd_micros, held_usd_micros, refunded_usd_micros, final, cap }. Refer to Reading usage. null when the API could not read the spend. |
error | { code, message }. Non-null only when the run is failed. |
skip_reason | Set on a skipped run. |
webhook_delivery | The delivery state for the URL that you registered, or null if you registered no URL. |
idempotency_hit | true when this receipt is an idempotency replay, not a new run. |
thread_id, previous_run_id | The conversation that this turn occurred in, and the run that it continued. Refer to Continue a run. |
model | The catalog id that the orchestrator ran on. |
request_id | Log it. Support asks for this value. |
status_url, result_url, events_url, cancel_url | Refer to Poll. |
Media URLs are durable media.sume.com HTTPS URLs. They do not expire. Each person who has the
URL can open it. Thus, if your product needs per-customer access control, proxy or copy the
media.
Continue a run
A Format run is one agent turn. If you send previous_run_id on a new POST …/runs, the next
turn continues the same conversation. Sume replays to the agent what the agent produced. Thus, the
agent can do one part again and keep the other parts as they are. Live-commerce integrations use
this method to retry a single scene, and they do not pay for the full show again.
The run that you name must be continuable. Its receipt shows this: thread_id is not null, and
the run completed or has a non-empty artifacts[]. You can continue a failed run that left
work. You cannot continue a run that left nothing.
| Refusal | Means |
|---|---|
404 previous_run_not_found | The id is unknown, or a different owner has the run. |
400 previous_run_format_mismatch | That run started on a different Format. Continue it on the Format where it started. |
409 previous_run_not_terminal | The run did not finish yet. Poll it. Then call again. |
400 previous_run_not_resumable | There is nothing to continue: no thread_id, or the run is not completed and has no artifacts. details shows previous_run_status, has_thread, and artifact_count. Start a new run. |
A continuation is a new run, with a new id, a new receipt, its own spend cap, and its own single
webhook. The original run never changes. The two runs share thread_id, which is read-only. To
continue, name previous_run_id, not a thread id (a thread id gives 400 unknown_parameter). Bind
the same output_schema on each turn, because it is per run and not inherited. artifacts[] on a
continued run lists all the media that the full conversation generated, but usage stays per run.
Cancel
The cancel call needs formats:write, and it is idempotent. In the two cases, the current receipt
comes back, and cancel_effect tells you which case occurred. canceled means that this call
stopped a run in progress. no_op means that the run already finished before the call. You pay for
the generation that the run completed before the cancel, and usage shows it.
A canceled run never delivers a webhook. If your integration is webhook-only, cancel is the one path where no delivery will arrive. Use the receipt that this call returns.
List runs for a Format
The list shows the newest runs first. limit is 1–100, and the default is 20.
GET /v1/formats/{format_id}/runs is the opaque twin. If no run of the Format ever started over the
API, the call returns an empty list, not a 404.
The response is a page: send next_cursor back as cursor until has_more is false. The cursor
is opaque and keyset over (created_at, id). Thus, runs created while you page do not move rows. A
cursor that is not ours gives 400 invalid_request.
Routes that do not exist
Three paths that callers guess, and what to use in their place:
| Guess | Use |
|---|---|
GET /v1/format-runs | There is no cross-Format list. List the runs of each Format, or keep your own index, with the data.id that you stored at create as the key. |
GET /v1/formats/{handle}/{slug}/runs/{run_id} | Read runs at /v1/format-runs/{run_id}. The Format path only creates and lists runs. |
GET /v1/format-runs/{run_id}/messages | Sume does not publish the conversation over the API. events_url gives the phase timeline. output and artifacts[] hold the result. |
usage
usage.billable_amount_usd_micros is the generation spend of this run. Sume enforces the cap of
the run against this total, and the total counts reserved and captured amounts. This value increases
while the run is in progress, and settles when the run terminates. It does not include the LLM turn
of the agent, thus it is not the total cost of the run. usage.cap gives the same calculation in
parts: limit_usd_micros, counted_usd_micros (this value), and remaining_usd_micros.
The cost is usage.debited_usd_micros: the real amount that the wallet deducted for the run and
its thread. This amount contains the captured ledger rows of all operation types, and also the LLM
row of the turn. held_usd_micros are holds that are still open (not spend yet).
refunded_usd_micros are holds that Sume gave back (not spend). final changes to true when no
hold is open.
GET /v1/usage?run_id= uses the same rows and the same fold. Thus, the
receipt, the ledger, and the answer of an agent always agree. usage is null when the API could
not read the spend at all. This is different from 0. The three wallet fields are null on
receipts written before the ledger answered.
Errors on the run endpoints
| Status | error.code | What to do |
|---|---|---|
| 401 | unauthorized | The key is missing, malformed, revoked, or unknown. |
| 403 | insufficient_scope | The key does not have formats:read (reads) or formats:write (cancel, redeliver). Mint a new key. |
| 404 | format_run_not_found | The run id is unknown, or a different owner has the run. A run that you cannot see gives the same result as a run that does not exist. |
| 409 | run_not_completed | You sent GET …/result before the run was terminal. details.status holds the current status. Poll status_url. Then retry. |
| 429 | rate_limited | Wait for retry-after. Polls use the read budget. This budget is separate from the write budget, and much larger. |
| 503 | studio_agent_upstream_unavailable | An outage on the Sume side, not a problem with your key. Retry at a later time. The run continues. |
Errors and spend gives all the codes, together with the run-time failures
(error and output_error).
Next
- Errors and spend: all codes, what a
failedrun holds, credits, and rate limits - Structured output: how to shape
output, and what to do when it is null - Cookbook: a webhook receiver, a scene retry, a batch
- Run webhooks: the complete delivery contract
- Waiting for runs:
subscribeFormatRun,waitForRun, and the phase timeline from TypeScript