---
title: Jobs and results
description: How to inspect Sume jobs, poll status, fetch results, read events, and request cancellation.
---

Sume generation endpoints create durable jobs. Store the job id from submit
responses so your integration can recover work after process restarts.

For paid generation, `queued` is a normal accepted state. Workspace concurrency
limits apply when workers move jobs into `processing`, not when the API accepts
valid jobs. See [Generation admission](/workflows/generation-admission) for
queue capacity, tier limits, and queue-full errors.

## Statuses

| Status | Meaning | Terminal |
|---|---|---|
| `queued` | Sume accepted the request and the job is waiting to run. | No |
| `processing` | The job is running or being finalized. | No |
| `completed` | The result is ready. | Yes |
| `failed` | The job reached a terminal failure with a public error. | Yes |
| `canceled` | Cancellation was requested and the job is terminal. | Yes |

## Poll status

```bash
curl https://api.sume.com/v1/jobs/job_123/status \
  -H "Authorization: Bearer $SUME_API_KEY"
```

Use exponential backoff and stop polling on `completed`, `failed`, or
`canceled`. Do not resubmit the original paid request just because a local
process timed out.

## MCP `jobs_wait` (single or batch)

Remote MCP `jobs_wait` accepts either:

- `job_id` — single-job wait; response shape is unchanged (`object: "job_wait"`)
- `job_ids` — 1–400 ids with optional `wait_for: "all" | "any"` (default `all`);
  response is `object: "job_wait_batch"` with a status snapshot for every
  requested id

Prefer one batch wait after parallel fan-outs instead of N single waits.
Every remote HTTP `POST /mcp` caller, Studio Agent threads included, holds at
most 55s per call (omitted default 50); on `wait_slice_expired`, retry
`jobs_wait` with the same ids — never resubmit the paid create. There is no
longer a thread-header 600s server-side hold: an HTTP request held that long
dies at the edge (`502` / `Transport send error`) before it can answer. `wait_for: "any"` still reports every id; remaining jobs continue
and still bill. Unknown or foreign-workspace ids fail the whole call.

### Slices are bounded, and the server enforces it

`timeout_seconds` defaults to **100** and is capped at **120**. Stills are the
one class worth shortening to **50** — not for speed, since a wait returns the
moment its job is terminal, but because an image still running at 50s is
usually stuck rather than slow.

A wait is one HTTP request held open for the whole slice with nothing
transferring, and every edge closes such a request eventually — the caller then
gets no tool result at all while the job keeps running and keeps billing. A
larger `timeout_seconds` is clamped rather than rejected, and the response says
so in `wait_slice_clamped`.

Wait for a ten-minute render by repeating the wait, not by asking for a longer
one. A `524` (or `522` / `523` / `525`) on `jobs_wait` is a transport failure,
never a job outcome: re-issue `jobs_wait` on the same ids, or read `jobs_status`
once. Do not resubmit the paid create and do not report the job blocked.

## Fetch a result

```bash
curl https://api.sume.com/v1/jobs/job_123/result \
  -H "Authorization: Bearer $SUME_API_KEY"
```

Results are available only after completion. If the job is not complete yet,
the API returns a conflict response instead of pretending the result is empty.

### Reading a whole wave (MCP)

Over MCP, `jobs_result` also takes `job_ids` — the same 1–400 ceiling as
`jobs_wait`, so a wave you waited on in one call reads back in one call
instead of N:

```json
{ "job_ids": ["job_a", "job_b", "job_c"] }
```

The response is a `job_result_batch`: `results[]` in request order, one entry
per id, each with `ok` plus either `value` or a typed `error`. Partial success
is normal and deliberate — an id that is still running comes back as
`job_not_completed` while every finished id still returns its result, and
`partial_failure.failed_job_ids` names exactly the ids worth re-reading. Read
`ok` per entry; a failure on one id says nothing about the others.

## Read events

```bash
curl https://api.sume.com/v1/jobs/job_123/events \
  -H "Authorization: Bearer $SUME_API_KEY"
```

Events provide a public timeline for debugging and recovery:

- `job.created`
- `job.started`
- `provider.submitted`
- `job.completed`
- `job.failed`
- `job.canceled`
- `webhook.delivery`

Public events do not expose raw provider task ids or raw provider URLs.

## Request cancellation

```bash
curl -X POST https://api.sume.com/v1/jobs/job_123/cancel \
  -H "Authorization: Bearer $SUME_API_KEY"
```

Cancellation is available for queued or processing jobs. A job may still finish
if provider execution is already past the point where cancellation can stop it.

## Communication modes

Every submit endpoint accepts a `mode`. The mode decides **how you learn the
outcome**. It never changes whether a job is created, what it costs, or how long
the job takes to run.

| Mode | HTTP returns | Job id in the first response | Server blocks | What the client does next |
|---|---|---|---|---|
| `async` (default) | `202` with the job envelope and polling URLs | Yes | No | Poll `status_url` until `terminal` is true, then `GET result_url` once `result_ready` is true. |
| `sync` | The same envelope, after waiting up to `wait_timeout_seconds` (max **30**) for a terminal transition | Yes | Yes, at most 30s — less when waiter capacity is unavailable | Terminal? Read the job off the response. Not terminal? **Poll. Do not resubmit.** |
| `subscribe` | Identical to `sync` — the same bounded wait | Yes | Same as `sync` | Same as `sync`. For a fal-style long wait, use the client subscribe recipe below with `async`. |
| `webhook` | `202` with the job envelope and polling URLs; the callback is stored | Yes | No | Wait for the terminal callback, verify its signature, and keep polling as a backup. |

Omit `mode` and you get `async`. Send `webhook_url` (or its alias
`callback_url`) without a `mode` and you get `webhook`.

A submit is accepted the moment Sume has a durable job id, so **every mode
returns the job id in its first response.** A `2xx` means the job exists and
paid work is in flight — it does not mean the job finished. Read `terminal` and
`result_ready` off the envelope to tell those apart.

### `sync` and `subscribe` are aliases

They run the same bounded waiter and return the same envelope. `subscribe` is
kept because clients ported from other queue APIs reach for it; it is not a
long-lived subscription, an event stream, or a longer wait. There is no SSE or
WebSocket transport on the Developer API today — `GET /v1/jobs/:id/events` is a
pull snapshot, not a stream.

### "Subscribe" means three different things

The word appears on three unrelated surfaces with three different waits. None of
them is a push stream. Read the table before wiring a timeout.

| Where you see it | What it is | How long it waits |
|---|---|---|
| Job `mode: "subscribe"` (this page) | An alias of `sync`. One bounded HTTP wait on the submit call. | At most `wait_timeout_seconds`, capped at **30s**. |
| SDK `subscribeFormatRun()` ([TypeScript SDK](/sdk)) | Create the run, then poll it client-side to a terminal receipt. | Minutes — the SDK's own timeout, not an HTTP hold. |
| Format / Action / Agent `communication.mode` | Delivery selection for a **run**, not a job. The values are `async` and `webhook`; `subscribe` is not one of them. | Nothing blocks. |

Two consequences worth spelling out:

- Sending `mode: "subscribe"` does **not** get you progress events. It gets you
  the same 30-second wait `sync` gets. For progress, submit `async` and read
  `GET /v1/jobs/:id/events`, or take a [webhook](/workflows/webhooks).
- `communication.mode` has no `subscribe` value at all, and its two values
  behave identically — supplying `webhook_url` is what actually arms delivery.

New integrations should reach for **`async`** (poll, or read events) or
**`webhook`** (be told). `sync` and `subscribe` remain supported and are not
going anywhere; they are just the wrong tool for anything that can outlast 30
seconds, which is most video work.

### 30 seconds is a wait budget, not a job duration

`wait_timeout_seconds` is clamped to `0..30`. It bounds how long the **HTTP
request** blocks, not how long the **job** may take. Image jobs often finish
inside it. Video, avatar-video, and face-swap jobs routinely do not.

When the budget runs out — or when the API process has no waiter capacity left
and skips the blocking wait entirely:

1. The response is still `2xx` and still carries the job id. Wait exhaustion is
   not an admission failure.
2. The envelope carries `status_url`, `result_url`, `events_url`, `cancel_url`,
   and a `sync` object: `sync.timed_out` is true when the wait returned before a
   terminal state, and `sync.capacity_exhausted` is true when Sume skipped the
   wait because the per-process waiter budget was full.
3. You **must** continue with `GET status_url`, honoring
   `next_poll_after_seconds` when it is present and backing off otherwise.
4. You **must not** submit a new paid job for the same intent. Retrying the
   submit itself is fine — reuse the **same** `Idempotency-Key` so the retry
   returns the original job instead of billing a second one.

`sync` is null on `async` and `webhook` responses.

### Client subscribe: poll a job to a terminal state

This is the official equivalent of a client-side `subscribe()`, and the right
answer for anything that can outlast 30 seconds. Submit with `async`, poll,
then read the result. The wait lives in **your** client, so its timeout can be
minutes without holding an HTTP request open.

```text
job = POST /v1/{product}/generate { mode: "async", ... } with Idempotency-Key
loop:
  s = GET /v1/jobs/{job.id}/status
  onStatus(s)                     # optional progress callback
  if s.terminal: break
  sleep(s.next_poll_after_seconds or exponential backoff)
if s.sume_status == "completed":
  return GET /v1/jobs/{job.id}/result
else:                             # failed or canceled
  raise from (GET /v1/jobs/{job.id}).job.error
```

Poll on the booleans (`terminal`, `result_ready`) or on `sume_status`. The
status endpoint also returns a queue-shaped `status` field
(`IN_QUEUE` / `IN_PROGRESS` / `COMPLETED` / `FAILED` / `CANCELED`) that maps
one-to-one onto `sume_status` for clients ported from other queue APIs. The two
never disagree, but do not mix them.

`GET /v1/jobs/:id/result` is only for completed jobs — it answers
`409 job_not_completed` otherwise, so read the failure off the job record
instead.

Submit (step 1):

```bash
curl -X POST https://api.sume.com/v1/image-1.0/generate \
  -H "Authorization: Bearer $SUME_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: hero-shot-2026-08-03-001" \
  -d '{"prompt":"Product hero shot of a matte black bottle on marble","mode":"async"}'
```

The response carries `request_id` (the job id), `status_url`, `result_url`, and
`next_poll_after_seconds`.

Poll (step 2), until `"terminal": true`:

```bash
curl https://api.sume.com/v1/jobs/job_123/status \
  -H "Authorization: Bearer $SUME_API_KEY"
```

Fetch (step 3), once `"result_ready": true`:

```bash
curl https://api.sume.com/v1/jobs/job_123/result \
  -H "Authorization: Bearer $SUME_API_KEY"
```

Any HTTP client can run this loop. In Kotlin with OkHttp or Ktor the shape is:

```kotlin
// Illustrative sketch — Sume does not publish a Kotlin package.
// 1. POST the generate route with mode = "async" and an Idempotency-Key.
// 2. GET /v1/jobs/{id}/status on a loop; honor next_poll_after_seconds when
//    present, exponential backoff otherwise.
// 3. Stop on terminal = true; GET /v1/jobs/{id}/result when result_ready = true.
// The overall deadline is CLIENT-side — 20 minutes for video is reasonable.
// It is not wait_timeout_seconds, which never exceeds 30 seconds.
```

A client-side timeout does not cancel the job. The job keeps running and still
bills; you have only stopped watching. Store the job id and pick it back up from
`status_url`, or cancel it explicitly.

In TypeScript, [`waitForJob`](/sdk/runs) from `@sume-com/sdk` is this loop.

### Job webhooks are terminal-only

`mode: "webhook"` delivers exactly three events — `job.completed`,
`job.failed`, and `job.canceled` — to a public HTTPS `webhook_url`. There are no
progress or partial webhooks. Sume signs the raw body with HMAC SHA-256 over
`{timestamp}.{raw_body}` and sends `x-sume-webhook-timestamp` plus
`x-sume-webhook-signature: sume-v1=…`. See [Webhooks](/workflows/webhooks) for
the payload and a verifier.

A webhook is a delivery optimization, not your only recovery path — keep
`status_url` polling available for missed or retried deliveries.

Action, Format, and Agent Completion **runs** are a different surface with their
own `*.run.terminal` events; see [Run webhooks](/agents/run-webhooks).

## Idempotency

Send `Idempotency-Key` on submit requests when retrying after client-side
timeouts or network failures.

```bash
curl -X POST https://api.sume.com/v1/avatar-1.0/generate \
  -H "Authorization: Bearer $SUME_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: avatar-request-2026-06-29-001" \
  -d '{"avatar_handle":"studio_presenter","input":{"type":"prompt","prompt":"Friendly studio presenter"}}'
```

Reuse the same key only for the same operation and payload.

## Result shape

Completed jobs can include artifacts:

```json
{
  "id": "job_...",
  "status": "completed",
  "result": {
    "artifacts": [
      {
        "id": "artifact_...",
        "url": "https://media.sume.com/artifacts/...",
        "media_type": "image",
        "content_type": "image/png"
      }
    ]
  }
}
```

Use Sume media URLs from the result. Raw provider URLs are not public API
outputs.
