# Job lifecycle and state

> How a job moves from creation to a terminal state, what each stage reports, how to poll, and every field of the Job object.

Source: https://www.framequery.com/docs/job-lifecycle (updated 2026-10-13)

## States

Every Job object has a `state`. It is derived from the job's stages by one rule on the server, and it is the only field you should branch on.

| `state` | Meaning | Terminal |
|---|---|---|
| `awaiting_upload` | Created with `POST /jobs`; waiting for **you** to upload the file | no |
| `queued` | A URL or batch job waiting for its fetch to start | no |
| `processing` | At least one stage is running or still to run | no |
| `completed` | Every stage the job needs has completed (or was skipped) | yes |
| `failed` | A stage the job needs failed. `error` says which and why | yes |
| `cancelled` | You cancelled it | yes |
| `expired` | It was never uploaded, or did not finish in time | yes |

```text
POST /jobs ──────────► awaiting_upload ──(upload lands)──┐
POST /jobs/from-url ─► queued ───────────(fetch starts)──┴─► processing ─┬─► completed
                                                                         ├─► failed
any non-terminal state ──(POST /jobs/{jobId}/cancel)──► cancelled        └─► expired
```

Ignore the `status` field. It is the legacy internal pipeline status, kept for older clients; its values are not part of the contract and change without notice. The `GET /jobs?state=` filter takes the same values as `state`.

A job left in `awaiting_upload` expires about an hour after creation, when its upload URLs stop working. Jobs that stall in the pipeline also end as `expired` (or `failed`) rather than hanging forever.

## Stages

`stages` always has three entries, `ingest`, `vision` and `transcript`, each with a `status`:

| Stage `status` | Meaning |
|---|---|
| `pending` | Not started |
| `running` | In progress |
| `completed` | Done; its results are readable |
| `failed` | Did not complete; `error` holds `{code, message}`. Never charged |
| `skipped` | Will not run: excluded by `processingMode`, no video stream (vision) or no audio stream (transcript). Never charged |

Stages also carry `startedAt` and `completedAt` (RFC 3339) once known. `ingest` probes and prepares the media; it is free. `vision` and `transcript` are the billable stages and run in parallel after ingest.

The derived rule:

- The job needs `ingest`, plus `vision` unless the mode is `transcript` or the input has no video, plus `transcript` unless the mode is `vision` or the input has no audio.
- Cancelled → `cancelled`. Expired → `expired`.
- Any needed stage `failed` → `failed`, with `error.stage` naming it.
- Every needed stage `completed` or `skipped` → `completed`.
- Otherwise `awaiting_upload`, `queued` or `processing`.

### Partial results

Each stage's output is readable as soon as that stage completes, even while the other is still running and even if the job ends `failed`. If vision fails but the transcript completes, the job is `failed`, `GET /jobs/{jobId}/transcript` still works, and you pay for the transcript only. Use the stage statuses to decide what to fetch:

```python
job = fq.jobs.get(job_id)
if job.stages.transcript.status == "completed":
    transcript = fq.jobs.transcript(job_id)
if job.stages.vision.status == "completed":
    scenes = fq.jobs.scenes(job_id)
```

## Polling

```bash
curl "https://api.framequery.com/v1/api/jobs/$JOB_ID?include=none" \
  -H "Authorization: Bearer $FRAMEQUERY_API_KEY"
```

- Use `include=none`: without it the response embeds every scene and transcript segment.
- Poll every 5 to 15 seconds, backing off for long jobs. When `estimatedCompletionTimeSeconds` is present, polling at about a third of it is plenty.
- GET requests are limited to 600 per minute per account (shared by all your keys). On `429`, wait for `Retry-After`.
- Better still, set `callbackUrl` at creation and let a [webhook](https://www.framequery.com/docs/webhooks) tell you when the job is done. Keep a slow poll as a fallback.

## The Job object

`GET /jobs/{jobId}` (and every create endpoint) returns this shape. Optional fields are omitted when unknown.

```json
{
  "jobId": "0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10",
  "state": "completed",
  "status": "VISION_COMPLETED",
  "processingMode": "all",
  "format": { "tier": "standard", "container": "mov", "extension": "mov" },
  "stages": {
    "ingest": { "status": "completed", "startedAt": "2026-10-13T09:00:04Z", "completedAt": "2026-10-13T09:01:12Z" },
    "vision": { "status": "completed", "startedAt": "2026-10-13T09:01:13Z", "completedAt": "2026-10-13T09:03:40Z" },
    "transcript": { "status": "completed", "startedAt": "2026-10-13T09:01:13Z", "completedAt": "2026-10-13T09:04:02Z" }
  },
  "metadata": { "assetId": "A003_C008" },
  "links": {
    "self": "https://api.framequery.com/v1/api/jobs/0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10",
    "transcript": "https://api.framequery.com/v1/api/jobs/0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10/transcript",
    "scenes": "https://api.framequery.com/v1/api/jobs/0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10/scenes"
  },
  "originalFilename": "A003_C008.mov",
  "source": "api",
  "durationSeconds": 1847.5,
  "billing": { "tier": "standard", "currency": "USD", "billableSeconds": 1848, "estimatedCents": 77, "chargedCents": 77 },
  "createdAt": "2026-10-13T08:59:41Z",
  "updatedAt": "2026-10-13T09:04:03Z",
  "completedAt": "2026-10-13T09:04:03Z",
  "statusHistory": [{ "status": "PENDING_UPLOAD", "timestamp": "2026-10-13T08:59:41Z" }]
}
```

| Field | Notes |
|---|---|
| `jobId` | UUID |
| `state` | See [States](#states) |
| `status` | Legacy, informational only |
| `processingMode` | `all`, `transcript` or `vision` |
| `format` | `tier` (`standard` or `raw`), and `container` / `extension` when detected |
| `stages` | `ingest`, `vision`, `transcript`, each `{status, startedAt?, completedAt?, error?}` |
| `error` | Present when `state` is `failed`: `{code, message, stage}`. Codes are listed under [job error codes](https://www.framequery.com/docs/errors#job-error-codes) |
| `metadata` | Your create-time metadata; always an object |
| `links` | Absolute URLs: `self`, and `transcript`, `scenes`, `cancel` when they apply (`cancel` disappears once the job is terminal; a skipped stage has no link) |
| `originalFilename`, `source` | `source` is `api` (upload), `url` or `batch` |
| `durationSeconds` | Media duration, once probed |
| `billing` | `tier`, `currency` (`USD`), `billableSeconds` (per second, 30 s minimum), `estimatedCents` (the current hold for this job), `chargedCents` (charged so far, completed stages only) |
| `createdAt`, `updatedAt`, `completedAt` | RFC 3339 |
| `estimatedCompletionTimeSeconds` | A hint while the job runs |
| `audioTrackCount`, `audioTracksCompleted`, `audioTrackNames`, `hasMultiTrackAudio` | Multi-track audio jobs |
| `batchId`, `clipCount`, `continuousShoot` | Batch jobs |
| `processedData` | Embedded results (`scenes`, `transcript`, `speakers`, `language`, `length`) unless `include=none`. Prefer the [results endpoints](https://www.framequery.com/docs/results) |
| `errorMessage` | Legacy copy of `error.message` |

`GET /jobs` list items carry the common fields (`jobId`, `state`, `status`, `processingMode`, `format`, `stages` with `status` only, `error`, `metadata`, `links`, `originalFilename`, `source`, `durationSeconds`, `batchId` and the timestamps). Get the job for `billing`, `processedData`, `statusHistory` and the audio-track fields.
