---
title: Runs and results
description: The Format run receipt, and the two ways to get it. Poll the run, or take one signed webhook. Lifecycle, progress, continuing, canceling, listing.
---

`POST …/runs` answers immediately with a receipt; the run itself takes minutes. This page is
about what happens after the `202`: how you learn the run finished, and what the finished
receipt contains.

There are two ways to learn, and they carry the identical 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 per run, when it completes or fails. | The same receipt, on your schedule. |
| Costs you | One public HTTPS endpoint. | One timer per in-flight run, and read budget. |
| Available | `api.dev.sume.com` and `api.sume.com`. | Everywhere. |

Production integrations do both: the webhook is the fast path, and a read of `result_url` is
the backup for the day your endpoint is down. Nothing about the run changes when a delivery
fails.

## Lifecycle

```text
queued -> processing -> completed | failed | canceled | skipped
```

| Status | Meaning |
|---|---|
| `queued` | Accepted, not started. |
| `processing` | The run is working. |
| `completed` | Finished. `output`, `artifacts[]` and `primary_output_url` are populated. |
| `failed` | Finished with an error. `error` says why; `artifacts[]` still carries whatever was made. |
| `canceled` | Stopped by `POST …/cancel`. One `l`. |
| `skipped` | Never ran: you sent `on_active_run: "skip"` and another run was in flight. |

`next_action` on the receipt says what to do: `poll_status` while `queued` or `processing`,
`retry_later` on a `skipped` run, `none` on every terminal run. Those are the only three
values a Format run emits.

## Poll

The receipt carries its own URLs; follow them rather than building paths.

| URL on the receipt | Endpoint | Returns |
|---|---|---|
| (the receipt itself) | `GET /v1/format-runs/{run_id}` | The full receipt at any status. Most integrations poll this one. |
| `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. Once terminal it also carries `usage` and, on a failure, `error`, but never `output`, `artifacts` or `primary_output_url`. |
| `result_url` | `GET /v1/format-runs/{run_id}/result` | The full receipt once terminal; `409 run_not_completed` with `details.status` while the run is in flight. |
| `events_url` | `GET /v1/format-runs/{run_id}/events` | The phase timeline. See [Watch a run progress](#watch-a-run-progress). |
| `cancel_url` | `POST /v1/format-runs/{run_id}/cancel` | Stops the run. See [Cancel](#cancel). |

A loop that reads the full receipt and stops on a terminal status:

```bash
RUN_ID="arun_e43e6c5cb2b74052"
SLEEP=5

while :; do
  RUN=$(curl -sS "https://api.sume.com/v1/format-runs/$RUN_ID" \
    -H "Authorization: Bearer $SUME_API_KEY")
  STATUS=$(echo "$RUN" | jq -r '.data.status')
  case "$STATUS" in
    queued|processing) sleep "$SLEEP"; SLEEP=$(( SLEEP < 60 ? SLEEP * 2 : 60 )) ;;
    *) break ;;
  esac
done

echo "$RUN" | jq '{status: .data.status, primary: .data.primary_output_url, error: .data.error, output_error: .data.output_error}'
```

Three habits keep a poll loop honest:

- **Back off.** Long-form video is 15 to 30 minutes of work; polling every second buys nothing
  and spends read budget. Double the gap up to a minute. A `429` or `503` during the loop is
  transient: the run is still executing and still spending, so wait and poll again rather than
  treating it as a failed run.
- **Use `expires_at` as your ceiling.** A non-terminal receipt carries the deadline past which
  the run is force-finalized as `failed`: 90 minutes from `created_at`, or sooner when the run
  is older than 25 minutes and has been silent for 10. Set your own timeout from it rather
  than inventing one. It is `null` once the run is terminal.
- **Read `queue` if `queued` lasts.** `queue.state` is `waiting` while pickup is inside the
  normal window and `runtime_unavailable` once the run has waited past it with nothing
  claiming it; then `retry_after_seconds` says how long to back off. `position` is always
  `null`: Sume does not publish queue depth. A `runtime_unavailable` that lasts more than a few
  minutes is worth a support ticket with the `request_id`.

In TypeScript, [`subscribeFormatRun`](/sdk/runs) creates the run and runs this loop for you;
[`waitForRun`](/sdk/runs) does the loop for a run id you already have.

## Webhook

Send `communication.webhook_url` on the create and Sume POSTs the terminal receipt to it once,
when the run completes or fails, on both `api.dev.sume.com` and `api.sume.com`. A canceled or
skipped run never delivers: cancel answers you directly, and a skipped run is already terminal
on the create response.

```json
{
  "event": "format.run.terminal",
  "request_id": "arun_e43e6c5cb2b74052",
  "run_id": "arun_e43e6c5cb2b74052",
  "object": "format.run",
  "status": "OK",
  "outcome": "ok",
  "created_at": "2026-08-23T23:41:02.118Z",
  "payload": {
    "id": "arun_e43e6c5cb2b74052",
    "object": "format.run",
    "status": "completed",
    "format": { "id": "skl_…", "slug": "live-commerce", "title": "Live commerce", "version": 23 },
    "output": { "full_video": { "type": "video", "url": "https://media.sume.com/artifacts/artf_…/full_video.mp4", "…": "…" } },
    "primary_output_url": "https://media.sume.com/artifacts/artf_…/full_video.mp4",
    "artifacts": [{ "id": "artf_…", "type": "video", "url": "https://media.sume.com/artifacts/artf_…/full_video.mp4", "content_type": "video/mp4" }],
    "usage": { "currency": "USD", "billable_amount_usd_micros": 14959638, "generation_spend_cap_usd_micros": 120000000 },
    "result_url": "https://api.sume.com/v1/format-runs/arun_e43e6c5cb2b74052/result",
    "thread_id": "thr_…"
  },
  "error": null
}
```

| Field | Branch on it for |
|---|---|
| `event` | Always `format.run.terminal` for a Format run. Route on it without inspecting the body. |
| `request_id`, `run_id` | Equal, and stable across retries: the dedupe key. |
| `status` | `OK` when the run completed, `ERROR` when it failed. |
| `outcome` | `ok`: completed with output. `degraded`: completed and billed, real media in `artifacts[]`, but `output` is `null` because the projection did not match your schema, and `output_error` says why. `error`: the run did not complete. Branch here when the question is "did I get usable output". |
| `payload` | The run receipt, byte-identical to `data` from `GET /v1/format-runs/{run_id}`, so one handler serves both transports. `null` only when the receipt was over 1 MiB; then `error.code` is `payload_too_large` and `error.result_url` says where to fetch it. |
| `error` | `null` on `OK`; otherwise `{ code, message }`, mirroring `payload.error`. |
| `created_at` | When this delivery body was built. Order deliveries by it; `request_id` cannot, since it repeats on retries. |

### Verify every delivery

Every POST carries three headers:

```text
x-sume-webhook-timestamp: 1785000000
x-sume-webhook-signature: sume-v1=<hex hmac-sha256>
x-sume-webhook-secret-fingerprint: <12 hex chars>
```

The signature is HMAC-SHA256 over `<timestamp>.<raw_body>` with your workspace's signing
secret, which you read on the dashboard's Webhooks tab or from
`GET /v1/webhooks/signing-secret` (any key with `account:read`). Verify against the raw bytes
before parsing, reject timestamps outside a five-minute window, and compare the fingerprint
header with the one shown next to the secret to confirm both sides hold the same one. The
[Cookbook](/formats/cookbook#a-webhook-receiver) has complete receivers in Node and Python;
in TypeScript the check is one call:

```ts
import { verifyWebhook } from "@sume-com/sdk";

const ok = await verifyWebhook({
  body: rawBody,
  headers: request.headers,
  secret: process.env.SUME_COM_WEBHOOK_SIGNING_SECRET!,
});
```

### Delivery rules

| Property | Value |
|---|---|
| When | Once per run, on `completed` or `failed`. Never on `canceled` or `skipped`. |
| Success | Any `2xx`, within 10 seconds. Record the event durably, answer, then do the work. |
| Retries | Up to 10 attempts. Backoff is the longer of exponential (30 s × 2^(attempt−1), with jitter) and your `Retry-After` on a `429`/`503`, capped at one hour. |
| Redirects | Not followed. A `3xx` is a failed attempt, so register the final URL. |
| URL rules | Public HTTPS only. Localhost, private ranges, credentials in the URL and plain HTTP are `400 invalid_request` at create, and the URL is checked again at delivery time. |
| Dedupe | On `request_id`; every retry repeats it. |

A delivery outcome never changes the run. Ten refused attempts leave you with a failed
*delivery* and a run that is still `completed`. Fetch it from `result_url`.

### Check what happened to a delivery

Every receipt for a run created with a `webhook_url` carries a `webhook_delivery` block:

```json
{
  "webhook_delivery": {
    "url": "https://acme.example.com/hooks/sume",
    "event_type": "format.run.terminal",
    "status": "delivered",
    "attempts": 1,
    "max_attempts": 10,
    "next_attempt_at": null,
    "last_attempt_at": "2026-08-23T23:41:04.000Z",
    "delivered_at": "2026-08-23T23:41:04.000Z",
    "last_status_code": 200,
    "last_error": null,
    "signature_version": "sume-v1",
    "signing_secret_fingerprint": "2b3844659f5e"
  }
}
```

| `status` | Means |
|---|---|
| `not_armed` | The URL is stored and nothing is scheduled yet: the run is still going. |
| `pending`, `retrying` | Armed; `next_attempt_at` is when the next attempt is due. |
| `delivered` | Your endpoint answered `2xx`. |
| `failed`, `exhausted` | Sume gave up. `last_status_code` and `last_error` (our transport error, never your body) say why. The run is unchanged. |

To replay a terminal delivery, after fixing your receiver or to test one, call
`POST /v1/format-runs/{run_id}/webhook/redeliver` with `formats:write` and an empty body. It
re-POSTs the current receipt with a fresh timestamp and signature and does not consume one of
the automatic ten. `409 webhook_not_configured` when the run had no URL,
`409 run_not_terminal` while it is still running.

The full contract, including the generation-job webhooks that `POST /v1/models/…` emits on a
different event set, is on [Run webhooks](/agents/run-webhooks).

## Watch a run progress

`status` says whether a run is done. `GET /v1/format-runs/{run_id}/events` says what it is
doing:

```json
{
  "data": [
    { "at": "2026-08-23T23:23:41.000Z", "phase": "preparing", "status": "done", "duration_ms": 1840 },
    { "at": "2026-08-23T23:31:12.000Z", "phase": "running", "status": "running", "duration_ms": null },
    { "at": "2026-08-23T23:40:57.000Z", "phase": "finalizing", "status": "done", "duration_ms": 620 }
  ]
}
```

`preparing` is everything before the agent has the run in hand; `running` is the agent working
the recipe, which is where the time goes; `finalizing` is teardown and output harvest. Each
entry carries 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 collapse into one whose `at` keeps advancing, so `at` on the last entry is the run's
progress clock. If it stops moving for several minutes the run is stalled, not slow, and will
be finalized at the bounds above.

It is a phase timeline, not a log stream. Agent output, tool calls and sandbox internals are
not published here and will not be. There is no push channel for progress: poll this
endpoint, or skip it and take the terminal webhook.

## The run receipt

`GET /v1/format-runs/{run_id}` returns the full shape at any status.

| Field | Notes |
|---|---|
| `id`, `object` | `arun_…`, and `format.run`. |
| `format` | `{ id, slug, title, version }`. `id` is the opaque `skl_…`; `version` is the Format version that actually ran, unchanged by later edits. |
| `status`, `next_action`, `cancelable` | See [Lifecycle](#lifecycle). `cancelable` is `true` while `queued` or `processing`. |
| `trigger` | `{ source: "api", idempotency_key }` for every run you create. |
| `created_at`, `started_at`, `finished_at` | The last two are `null` until they happen. |
| `expires_at`, `queue` | The deadline and the pickup state. See [Poll](#poll). |
| `output_schema` | `{ name, strict, source }`: which schema shaped `output`. `source` is `request_override` when you sent one. |
| `output` | The structured result. `null` on every non-terminal status, and `null` when nothing satisfied `output_schema`. A `failed` run still publishes whatever partial result it authored. |
| `output_error` | `{ code, message, details }` when `output` could not be produced. Check it before reading `output`. |
| `primary_output_key`, `primary_output_url` | The one thing to show. Both `null` unless `completed`. |
| `artifacts[]` | Every durable file the run generated: `{ id, type, url, content_type, size_bytes, width, height, duration_ms, checksum_sha256 }`. Empty until terminal; populated on failures too. |
| `usage` | `{ currency, billable_amount_usd_micros, generation_spend_cap_usd_micros }`. See [Reading `usage`](#reading-usage). `null` when spend could not be read. |
| `error` | `{ code, message }`. Non-null only when `failed`. |
| `skip_reason` | Set on a `skipped` run. |
| `webhook_delivery` | Delivery state for the URL you registered, or `null` if you registered none. |
| `idempotency_hit` | `true` when this receipt is an idempotency replay rather than a new run. |
| `thread_id`, `previous_run_id` | The conversation this turn happened in, and the run it continued. See [Continue a run](#continue-a-run). |
| `model` | The catalog id the orchestrator ran on. |
| `request_id` | Log it; it is what support asks for. |
| `status_url`, `result_url`, `events_url`, `cancel_url` | See [Poll](#poll). |

Media URLs are durable `media.sume.com` HTTPS URLs. They do not expire, and they are public to
anyone holding the URL, so proxy or copy them if your product needs per-customer access
control.

## Continue a run

A Format run is one agent turn. Send `previous_run_id` on a new `POST …/runs` and the next
turn continues the same conversation: the agent is replayed what it produced, so it can redo
one part and leave the rest alone. This is how live-commerce integrations retry a single scene
without paying for the whole show again.

```bash
curl -sS -X POST "https://api.sume.com/v1/formats/acme/live-commerce/runs" \
  -H "Authorization: Bearer $SUME_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-8823-lc-v1-retry-sc7" \
  -d '{
    "previous_run_id": "arun_e43e6c5cb2b74052",
    "instruction": "Retry the selected scene only. Keep every other scene and the voice track unchanged.",
    "input": { "scene_id": "sc_7" },
    "output_schema": { "…": "the same schema you bound on the first run" },
    "primary_output_key": "full_video",
    "generation_spend_cap_usd": 8
  }'
```

The run you name must be continuable, which you can read off its own receipt: `thread_id` is
not `null`, and it either `completed` or has a non-empty `artifacts[]`. A `failed` run that
left work behind can be continued; one that left nothing cannot.

| Refusal | Means |
|---|---|
| `404 previous_run_not_found` | Unknown id, or another owner's run. |
| `400 previous_run_format_mismatch` | That run was created on a different Format. Continue it where it started. |
| `409 previous_run_not_terminal` | It has not finished. Poll it, then call again. |
| `400 previous_run_not_resumable` | Nothing to continue: no `thread_id`, or neither `completed` nor any artifacts. `details` reports `previous_run_status`, `has_thread` and `artifact_count`. Start a fresh run. |

A continuation is a new run: new id, new receipt, its own spend cap, its own single webhook.
The original run never changes. Both share `thread_id`, which is read-only. Continue by naming
`previous_run_id`, never by sending a thread id (that is `400 unknown_parameter`). Bind the
same `output_schema` every turn; it is per run, not inherited. `artifacts[]` on a continued
run lists everything the whole conversation generated, while `usage` stays per run.

## Cancel

```bash
curl -sS -X POST "https://api.sume.com/v1/format-runs/$RUN_ID/cancel" \
  -H "Authorization: Bearer $SUME_API_KEY"
```

Needs `formats:write`, and is idempotent: the current receipt comes back either way, and
`cancel_effect` says which happened. `canceled` means this call stopped a run in flight;
`no_op` means it had already finished. Generation the run completed before the cancel is
billed, and `usage` reports it.

A canceled run never delivers a webhook. If your integration is webhook-only, cancel is the
one path where nothing will arrive; use the receipt this call returns.

## List runs for a Format

```bash
curl -sS "https://api.sume.com/v1/formats/acme/live-commerce/runs?limit=20" \
  -H "Authorization: Bearer $SUME_API_KEY"
```

Newest first; `limit` is 1–100 and defaults to 20; `GET /v1/formats/{format_id}/runs` is the
opaque twin. A Format that has never been run over the API returns an empty list, not a
`404`. The response is a page: pass `next_cursor` back as `cursor` until `has_more` is
`false`. The cursor is opaque and keyset over `(created_at, id)`, so runs created while you
page do not shift rows; a cursor that is not ours is `400 invalid_request`.

### Routes that do not exist

Three paths callers guess at, and what to use instead:

| Guess | Use |
|---|---|
| `GET /v1/format-runs` | There is no cross-Format list. List per Format, or keep your own index keyed by the `data.id` you stored at create. |
| `GET /v1/formats/{handle}/{slug}/runs/{run_id}` | Runs are read at `/v1/format-runs/{run_id}`. The Format path only creates and lists. |
| `GET /v1/format-runs/{run_id}/messages` | The conversation is not published over the API. `events_url` gives the phase timeline; `output` and `artifacts[]` carry the result. |

## Reading `usage`

`usage.billable_amount_usd_micros` is the generation spend attributed to this run: the running
total the run's cap is enforced against, counting both reserved and captured amounts. It climbs
while the run is in flight and settles when it terminates. It excludes the agent's own LLM
turn, so it is not the run's total cost, and it is a receipt figure rather than an invoice:
[`GET /v1/usage`](/dashboard/usage) is the authoritative billing record. `usage` is `null`
when spend could not be read at all, which is different from `0`.

## Errors on the run endpoints

| Status | `error.code` | What to do |
|---|---|---|
| 401 | `unauthorized` | Missing, malformed, revoked or unknown key. |
| 403 | `insufficient_scope` | The key lacks `formats:read` (reads) or `formats:write` (cancel, redeliver). Mint a new key. |
| 404 | `format_run_not_found` | Unknown run id, or another owner's run. A run you cannot see reads the same as one that does not exist. |
| 409 | `run_not_completed` | `GET …/result` before the run is terminal. `details.status` carries the current status; poll `status_url` and retry. |
| 429 | `rate_limited` | Wait `retry-after`. Polling spends the read budget, which is separate from and far larger than the write one. |
| 503 | `studio_agent_upstream_unavailable` | A Sume-side outage, not your key. Retry later; the run is still executing. |

Every code, with the run-time failures (`error` and `output_error`) alongside, is on
[Errors and spend](/formats/errors).

## Next

- [Errors and spend](/formats/errors): every code, what a `failed` run carries, credits and rate limits
- [Structured output](/formats/structured-output): shaping `output`, and what to do when it is null
- [Cookbook](/formats/cookbook): a webhook receiver, a scene retry, a batch
- [Run webhooks](/agents/run-webhooks): the complete delivery contract
- [Waiting for runs](/sdk/runs): `subscribeFormatRun`, `waitForRun`, and the phase timeline from TypeScript
