> ## Documentation Index
> Fetch the complete documentation index at: https://docs.voyager.so/llms.txt
> Use this file to discover all available pages before exploring further.

# Jobs and polling

> The job object, statuses, waiting and polling, how credits are held and charged, cancelling, uploads, output retention and idempotency.

A **job** is one generation: an image, a video, speech, music, a sound effect or a 3D model. You create it with `POST /v1/jobs`, it runs on Voyager's side, and you read the result with `GET /v1/jobs/{id}` or receive it by [webhook](https://docs.voyager.so/api/webhooks.md).

## Create a job

```bash theme={"system"}
curl https://api.voyager.so/v1/jobs \
  -H "Authorization: Bearer $VOYAGER_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 9b2f6c1e-2f43-4d0e-9a51-5d1f8f0a7c11" \
  -d '{
    "model": "nano-banana-2.1",
    "operation": "text-to-image",
    "input": { "prompt": "a red fox in snow", "aspect_ratio": "16:9" },
    "webhook_url": "https://example.com/hooks/voyager",
    "metadata": { "shot": "12" }
  }'
```

| Field | Type | Description |
| - | - | - |
| `model` | `string` · required | A model id from GET /v1/models. Up to 200 characters. |
| `operation` | `string` or `null` | One of text-to-image, edit, text-to-video, image-to-video, reference-to-video, tts, music, sound-effects, text-to-3d, image-to-3d, image-to-3d-body, align-3d, segment-image, segment-image-data, segment-video, segment-video-data, segment-everything, image-embedding (GET /v1/models/\{id} lists a model's). Required unless the model runs only one. |
| `input` | `JobInput` | The model's canonical inputs: its input\_schema on GET /v1/models/\{id} (file\_ ids or https URLs for media), plus optional cast (up to 4 cst\_ ids) on image and video operations. |
| `webhook_url` | `string` or `null` | Public https URL that receives job.succeeded / job.failed / job.canceled (Standard Webhooks). Up to 2048 characters. |
| `metadata` | `object` or `null` | Up to 16 string pairs echoed on the job and its webhooks (values ≤ 500 chars). |

`input.cast` takes up to 4 `cst_…` IDs from [Cast](https://docs.voyager.so/api/cast.md) on image and video operations; other models refuse it with [`cast_not_supported`](https://docs.voyager.so/api/errors#cast_not_supported). The fields `input` takes are on [Models](https://docs.voyager.so/api/models.md#input-fields).

The response is `201 Created` with the job, usually `queued`. Unknown top-level fields are refused with `400` [`invalid_request`](https://docs.voyager.so/api/errors#invalid_request).

### Wait on create

Add `Prefer: wait=N` (N up to 60) to hold the create request for up to N seconds. If the job finishes in time, the response is `200 OK` with the finished job; otherwise it's `201 Created` with the queued or running job and you continue with `GET`. The response echoes `Preference-Applied: wait=N`. Use it for fast models; for video and 3D, create without waiting.

## The job object

```json theme={"system"}
{
  "id": "job_01JC4Q8ZK3V0B7M2X9T5R6N4PA",
  "object": "job",
  "type": "image",
  "model": "nano-banana-2.1",
  "operation": "text-to-image",
  "status": "succeeded",
  "input": { "prompt": "a red fox in snow", "aspect_ratio": "16:9" },
  "outputs": [
    {
      "id": "file_01JC4QB3N8D5H1W6Y2K7M0F9EC",
      "object": "file",
      "type": "image",
      "mime_type": "image/png",
      "file_name": "image-1.png",
      "size_bytes": 1843120,
      "url": "https://api.voyager.so/v1/files/file_01JC4QB3N8D5H1W6Y2K7M0F9EC/content?exp=…&sig=…",
      "url_expires_at": "2026-11-05T22:10:00Z",
      "expires_at": "2026-12-05T21:09:55Z",
      "width": 1344,
      "height": 768,
      "duration_seconds": null
    }
  ],
  "credits": {
    "estimated": …,
    "held": …,
    "charged": …,
    "released": …,
    "state": "settled",
    "charge_ref": "chg_01JC4QB9…"
  },
  "error": null,
  "metadata": { "shot": "12" },
  "created_at": "2026-11-05T21:09:41Z",
  "started_at": "2026-11-05T21:09:42Z",
  "completed_at": "2026-11-05T21:09:55Z",
  "poll_after_ms": null,
  "links": {
    "self": "/v1/jobs/job_01JC4Q8ZK3V0B7M2X9T5R6N4PA",
    "cancel": "/v1/jobs/job_01JC4Q8ZK3V0B7M2X9T5R6N4PA/cancel"
  }
}
```

| Field | Type | Description |
| - | - | - |
| `id` | `string` | job\_… |
| `object` | `string` | Always "job". Always `job`. |
| `type` | `string` | image, video, speech, music, sound\_effect or 3d. |
| `model` | `string` or `null` | The model that ran (or will run) the job. |
| `operation` | `string` or `null` | e.g. text-to-image, edit, image-to-video, tts. |
| `status` | `string` | queued, running, succeeded, failed or canceled. One of `queued`, `running`, `succeeded`, `failed`, `canceled`. |
| `input` | `object` | The job's input, as sent. |
| `outputs` | array of `JobOutput` | The files the job made, once it succeeded. |
| `credits` | `JobCredits` | What the job is expected to cost and what it was charged. |
| `error` | `JobError` or `null` | Why the job failed or was canceled. |
| `metadata` | `object` | The metadata sent with the job, echoed as is. |
| `created_at` | `string` (date-time) or `null` | When it was created (ISO 8601). |
| `started_at` | `string` (date-time) or `null` | When the job started running (ISO 8601); null while queued. |
| `completed_at` | `string` (date-time) or `null` | When the job reached a terminal status (ISO 8601); null until then. |
| `poll_after_ms` | `integer` or `null` | While active: when to poll again. |
| `links` | `JobLinks` | Links to read and cancel the job. |
| `cast_usage` | `object` or `null` | Jobs created with X-Voyager-Cast-Version: 1 and input.cast: what the Elements compiled to (strategy, card versions, sent and omitted photos, warnings). Absent on every other job. |

Each entry in `outputs`. A signed `url` is valid until `url_expires_at` (1 hour); `expires_at` is when the file itself is deleted. 3D outputs add `formats` (one downloadable file per format) and a still `preview_url`.

| Field | Type | Description |
| - | - | - |
| `id` | `string` | file\_… id; GET /v1/files/\{id} for a fresh link. |
| `object` | `string` | Always "file". Always `file`. |
| `type` | `string` | image, video, audio, model3d or other. |
| `mime_type` | `string` | The file's media type, e.g. image/png or video/mp4. |
| `file_name` | `string` | The file's name. |
| `size_bytes` | `integer` | Size in bytes. |
| `url` | `string` | Signed download link, valid one hour. |
| `url_expires_at` | `string` (date-time) | When `url` stops working (ISO 8601); read the object again for a fresh link. |
| `expires_at` | `string` (date-time) or `null` | When the file itself is deleted (outputs are kept 30 days). |
| `width` | `integer` or `null` | Width in pixels, when known. |
| `height` | `integer` or `null` | Height in pixels, when known. |
| `duration_seconds` | `number` or `null` | Length in seconds, when known. |
| `formats` | array of `JobOutputFormat` or `null` | 3D jobs: every downloadable format. |
| `preview_url` | `string` or `null` | 3D jobs: a still preview. |
| `role` | `string` or `null` | What the file is beside the main output: mask, cutout, mask\_video, data or preview (segmentation). |
| `metadata` | `object` or `null` | Per-file facts: a mask's object\_id, score and box (source pixels), a mask video's frames. |

`error`, when the job `failed` or was `canceled` by Voyager (the codes are on [Errors](https://docs.voyager.so/api/errors.md)):

| Field | Type | Description |
| - | - | - |
| `code` | `string` | A stable machine code (see x-voyager-errors). |
| `message` | `string` | For developers. Not localized. |
| `details` | `object` or `null` | Code-specific detail. |

## Statuses

```text theme={"system"}
queued ──▶ running ──▶ succeeded
   │          ├──────▶ failed
   └──────────┴──────▶ canceled
```

| Status | Meaning |
| - | - |
| `queued` | Accepted and waiting to start, for example because you're at your [parallel generation limit](https://docs.voyager.so/api/rate-limits.md#concurrency). |
| `running` | Generating. |
| `succeeded` | Finished; `outputs` holds the results. |
| `failed` | Finished without a result. `error.code` says why, for example [`content_policy`](https://docs.voyager.so/api/errors#content_policy) for a refused prompt, or [`outcome_unknown`](#unresolved-outcomes). |
| `canceled` | Stopped by you, or by Voyager after waiting too long in the queue (`error.code` [`queue_timeout`](https://docs.voyager.so/api/errors#queue_timeout)). |

`succeeded`, `failed` and `canceled` are terminal: a job in one of them never changes status again. Its `credits` can still change until `credits.state` is `settled` or `unresolved`.

## Wait for a result

### Long-poll

`GET /v1/jobs/{id}?wait=N` (N up to 25) holds the request until the job reaches a terminal status or N seconds pass, then returns the job either way. Loop while the status is `queued` or `running`:

```bash theme={"system"}
curl "https://api.voyager.so/v1/jobs/job_01JC4Q8ZK3V0B7M2X9T5R6N4PA?wait=25" \
  -H "Authorization: Bearer $VOYAGER_API_KEY"
```

### Plain polling

Without `wait`, `GET /v1/jobs/{id}` returns at once. While the job is active the response has a `Retry-After` header (seconds) and `poll_after_ms` in the body: wait that long before the next request. Polling faster doesn't make the job finish sooner and counts against your [rate limit](https://docs.voyager.so/api/rate-limits.md).

### Webhooks

Pass `webhook_url` on create and Voyager calls you when the job finishes. See [Webhooks](https://docs.voyager.so/api/webhooks.md). `GET /v1/jobs/{id}` stays the source of truth.

## List jobs

`GET /v1/jobs` lists your jobs in this workspace, newest first.

| Parameter | Type | Description |
| - | - | - |
| `limit` | `integer` | 1–100. Default `20`. |
| `cursor` | `string` or `null` | next\_cursor from the previous page. Up to 400 characters. |
| `type` | `string` or `null` | One of `image`, `video`, `speech`, `music`, `sound_effect`, `3d`, `segment`. |
| `status` | `string` or `null` | One of `queued`, `running`, `succeeded`, `failed`, `canceled`. |
| `created_after` | `string` (date-time) or `null` | Only jobs created after this instant. |

```json theme={"system"}
{ "object": "list", "data": [ { "id": "job_…", "object": "job", "status": "succeeded" } ], "has_more": true, "next_cursor": "eyJ2Ijox…" }
```

## Credits

Every job is paid from your workspace's Voyager credits. The `credits` object keeps the job's result apart from its bill:

| Field | Type | Description |
| - | - | - |
| `estimated` | `number` or `null` | Estimated charge when the job is accepted. Not a cap. |
| `held` | `number` or `null` | Credits held for this job now, where proven; null when unknown. |
| `charged` | `number` or `null` | Final charge once settled; null until then (never a stand-in zero). |
| `released` | `number` or `null` | Held credits returned, where proven; null when unknown. |
| `state` | `string` | pending: the final charge may still arrive; settled: final; unresolved: this API will not state it (see your usage), or the job's outcome could not be proven. One of `pending`, `settled`, `unresolved`. |
| `charge_ref` | `string` or `null` | The charge reference in your usage records; quote it to support. |

Any amount can be `null`, which means **not known yet**, never zero. A job finishing (and its webhook firing) doesn't mean its charge is final: read `credits.state`.

To see a cost before you run, call `POST /v1/estimate` with the same body you'd send to `POST /v1/jobs`:

```json theme={"system"}
{ "object": "estimate", "model": "nano-banana-2.1", "operation": "text-to-image", "estimated_charge": …, "required_hold": …, "available_credits": …, "can_run": true, "reason": null, "upgrade_url": null }
```

| Field | Type | Description |
| - | - | - |
| `object` | `string` | Always "estimate". Always `estimate`. |
| `model` | `string` | The model estimated. |
| `operation` | `string` | The operation estimated. |
| `estimated_charge` | `number` or `null` | Expected credits for this request; null when unknown. |
| `required_hold` | `number` or `null` | Credits held when the job is accepted, at today's prices; null when unknown. |
| `available_credits` | `number` or `null` | The workspace's credit balance, as its Voyager dashboard shows it; null when unknown. |
| `can_run` | `boolean` | False when the plan, access or balance refuses it, or it is unknown. |
| `reason` | `string` or `null` | Why not, as the error code a create would get: model\_not\_in\_plan, model\_access\_required, insufficient\_credits, credits\_reloading (retry shortly), free\_allowance\_exhausted, free\_window\_limit, free\_media\_limit, paid\_plan\_required, no\_voyager\_seat, model\_unavailable; estimate\_unavailable when it could not be read. |
| `upgrade_url` | `string` or `null` | Where to upgrade the plan or add credits, when that is the reason. |
| `estimate_basis` | `string` or `null` | "fallback" when nothing prices this request and estimated\_charge is its model's fallback rate (show it as an estimate); absent otherwise. A fallback figure never refuses the job. |
| `estimate_source` | `string` or `null` | With estimate\_basis fallback, the rate's evidence: a its own settlements, b its provider's published price, c its own other prices scaled, d a peer model's, e its media type's default. One of `a`, `b`, `c`, `d`, `e`. |
| `cast_usage` | `object` or `null` | With X-Voyager-Cast-Version: 1 and input.cast: what the Elements compile to. Pass its plan as input.cast\_plan to create the job only if the Elements still compile to it. |

An estimate holds no credits and isn't a cap: the charge is the real cost. When `can_run` is `false`, `reason` is the error code creating the same job would get; for plan and credit refusals, `upgrade_url` opens the dashboard. An estimate also needs a paid plan. It never imports `https` media inputs, except a source recording for a speech, voice or audio model whose price depends on its length: that one is imported into your workspace to measure it and kept as a reference file for 7 days. Send a `file_…` ID from `POST /v1/files` to avoid the import.

### How each type is charged

| Type | Rule |
| - | - |
| `image`, `video` | Credits are held when the job is accepted. After the job succeeds, `state` stays `pending` and `charged` stays `null` until the real cost is confirmed, which can take up to 7 days; if it's never confirmed, the estimate is charged. Whatever isn't charged is released. |
| `speech` | Charged when the job finishes; `state` is `settled` at completion. |
| `music`, `sound_effect` | Charged when the job finishes; `state` is `settled` at completion. |
| `3d` | Charged when the job finishes. If texturing fails after the untextured preview is ready, the job succeeds with the preview as its output, and you're charged for the preview only. |

A `failed` job releases its hold and charges nothing, except under the 3D rule above or an [unresolved outcome](#unresolved-outcomes). A job canceled while still `queued` charges nothing.

### Unresolved outcomes

Rarely, Voyager can't confirm how a generation went, for example when a model stops answering mid-job. You then get [`outcome_unknown`](https://docs.voyager.so/api/errors#outcome_unknown) (retryable): Voyager couldn't confirm the generation yet. **Ask for its status again** with `GET /v1/jobs/{id}`, with backoff. `credits.state` can be `unresolved`: the API won't state that charge, or the outcome couldn't be proven; your usage on the dashboard shows what was charged.

> [!WARNING]
>
> **Never submit the same request again** as a new job (a new `Idempotency-Key`): it may be charged twice. If it stays unresolved, contact [support@voyager.so](mailto:support@voyager.so) with `error.details.support_ref` and `credits.charge_ref`.

## Cancel a job

`POST /v1/jobs/{id}/cancel` returns the job as it stands after the request.

| When | What happens |
| - | - |
| `queued`, not yet sent to the model | Canceled, any type. Nothing is charged and the hold is released. |
| `running` image, video or speech | Best effort. The model may finish first, so check the returned `status`: it can be `succeeded`. If it stops, work already done may still be charged; read `credits` once `state` is `settled`. |
| `running` music, sound effect or 3D | Can't be stopped: `409` [`cancel_unsupported`](https://docs.voyager.so/api/errors#cancel_unsupported). The job finishes and is charged normally. |
| Already terminal | No change; the job is returned as is. |

## Outputs and retention

* Outputs are files in your workspace, kept for **30 days** after the job finishes. Then they're deleted.
* `outputs[].url` is a signed link valid for **1 hour** that needs no Authorization header. Each `GET /v1/jobs/{id}` or `GET /v1/files/{id}` returns a fresh link. Download results you want to keep.
* An output's `file_…` ID can be used as an input to another job, for example to edit an image or animate it into a video.
* After its outputs are deleted, a job's results are gone: you get [`expired`](https://docs.voyager.so/api/errors#expired) (`410`), and `outputs` is empty.
* `DELETE /v1/files/{id}` deletes a file now (`204 No Content`). A file a running job still uses, or one saved in a Cast card, returns `409` [`asset_in_use`](https://docs.voyager.so/api/errors#asset_in_use).

## Uploads

Reference images, audio and video go in through `/v1/files`. Use the returned `file_…` ID in a job's `input`. A media input can also be a public `https` URL: when you create the job, Voyager imports it into your workspace, like `POST /v1/files` with a `url`. The import happens after the plan and credit checks, so a refused request leaves no file behind. An unusable URL is [`invalid_url`](https://docs.voyager.so/api/errors#invalid_url); one that can't be downloaded is [`fetch_failed`](https://docs.voyager.so/api/errors#fetch_failed).

**1. Create the file**

```bash theme={"system"}
curl https://api.voyager.so/v1/files \
  -H "Authorization: Bearer $VOYAGER_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"file_name": "hero.jpg", "content_type": "image/jpeg", "size_bytes": 482113}'
```

```json theme={"system"}
{
  "id": "file_01JC4R2M7K9T3V5B8N1X6D0QWE",
  "object": "file",
  "status": "pending",
  "file_name": "hero.jpg",
  "content_type": "image/jpeg",
  "size_bytes": 482113,
  "retention": "pending",
  "expires_at": "2026-11-06T21:10:00Z",
  "upload_url": "https://…",
  "method": "PUT",
  "headers": { "Content-Type": "image/jpeg" },
  "upload_expires_at": "2026-11-05T21:40:00Z"
}
```

**2. Upload the bytes**

Send the file to `upload_url` with `method` and exactly the `headers` returned, before `upload_expires_at`. `size_bytes` must be the exact size you'll send. Don't send your API key to this URL.

```bash theme={"system"}
curl -X PUT --upload-file hero.jpg -H "Content-Type: image/jpeg" "<upload_url>"
```

**3. Complete the upload**

```bash theme={"system"}
curl -X POST https://api.voyager.so/v1/files/file_01JC4R2M7K9T3V5B8N1X6D0QWE/complete \
  -H "Authorization: Bearer $VOYAGER_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

Voyager checks the bytes, type and size. The file comes back `ready`, or `failed` with an `error` (wrong type, a size other than declared, or over your plan's limit). Only `ready` files can be used in jobs. Completing is safe to repeat; calling it before the bytes are uploaded returns `409` [`upload_incomplete`](https://docs.voyager.so/api/errors#upload_incomplete).

To import from a public URL instead, send `{"url": "https://…"}` to `POST /v1/files`. Voyager downloads it and the file comes back `ready` at once.

The `POST /v1/files` body:

One of:

**FileUpload**

| Field | Type | Description |
| - | - | - |
| `file_name` | `string` · required | Up to 200 characters. |
| `content_type` | `string` · required | image/png, image/jpeg, video/mp4, audio/mpeg… Up to 100 characters. |
| `size_bytes` | `integer` · required | Exact size of the file you will PUT. |

**FileImport**

| Field | Type | Description |
| - | - | - |
| `url` | `string` · required | Import a public https file instead. Up to 4096 characters. |
| `file_name` | `string` | Up to 200 characters. |

The file object:

| Field | Type | Description |
| - | - | - |
| `id` | `string` | file\_…; pass it in a job's input (image, images, video, audio…). |
| `object` | `string` | Always "file". Always `file`. |
| `status` | `string` | pending until the upload completes, then ready (or failed). One of `pending`, `ready`, `failed`. |
| `type` | `string` or `null` | image, video, audio, model3d or other (ready files). |
| `file_name` | `string` | The file's name. |
| `content_type` | `string` | The file's media type, e.g. image/png. |
| `size_bytes` | `integer` | Size in bytes. |
| `url` | `string` or `null` | Ready files: signed download link, valid one hour. |
| `url_expires_at` | `string` (date-time) or `null` | When `url` stops working (ISO 8601); read the object again for a fresh link. |
| `expires_at` | `string` (date-time) or `null` | When the file is deleted: references 7 days, outputs 30 days, Cast never; pending uploads 24 h. |
| `retention` | `string` or `null` | How long the file is kept. One of `reference`, `output`, `cast`, `pending`. |
| `created_at` | `string` (date-time) or `null` | When it was created (ISO 8601). |
| `error` | `JobError` or `null` | Why the upload failed, when status is failed. |
| `width` | `integer` or `null` | Width in pixels, when known. |
| `height` | `integer` or `null` | Height in pixels, when known. |
| `duration_seconds` | `number` or `null` | Length in seconds, when known. |
| `upload_url` | `string` or `null` | PUT the bytes here with `headers`, then complete. |
| `method` | `string` or `null` | The HTTP method to upload the bytes with, while pending. |
| `headers` | `object` or `null` | Headers to send with the upload, while pending. |
| `upload_expires_at` | `string` (date-time) or `null` | When upload\_url stops accepting the bytes (ISO 8601). |

| Limit | Value |
| - | - |
| File size | Up to 250 MiB per file on paid plans |
| File types | Images, video and audio (for example `image/png`, `image/jpeg`, `video/mp4`, `audio/mpeg`). Others return `415` [`unsupported_file`](https://docs.voyager.so/api/errors#unsupported_file). |
| Uploads never completed | Deleted after **24 hours** |
| Uploaded references | Kept **7 days**, then deleted. Upload again, or save them to a [Cast](https://docs.voyager.so/api/cast.md) card to keep them. |
| Outputs | Kept **30 days** |
| Cast references | Kept until you remove them |

`GET /v1/files/{id}` returns the file with a fresh signed `url` to `GET /v1/files/{id}/content` (valid 1 hour; returns the bytes, answers `HEAD`, and a single `Range` request gets `206`). `expires_at` and `retention` say when it will be deleted.

## Idempotency

Every `POST` accepts an `Idempotency-Key` header. On the calls that create something (jobs, files and their `complete`, Cast cards and references) Voyager stores the result and replays it as below. Estimates and cancel accept the key too, but don't replay: repeating them never starts work or spends credits. (An estimate for a speech, voice or audio model priced by a source recording's length imports an `https` recording each time it's called; send a `file_…` ID from `POST /v1/files` instead to avoid that.) Send a new UUID per logical request and reuse it on every retry of that request (1–200 printable ASCII characters; anything else is `400` [`invalid_idempotency_key`](https://docs.voyager.so/api/errors#invalid_idempotency_key)). It matters most on `POST /v1/jobs`, where a lost response could otherwise buy twice.

* **Same key, same body:** you get the original result back (for a job, `200 OK` with an `Idempotent-Replayed: true` header), however long ago it was made. Voyager never buys twice for one key.
* **Same key, different body:** `409` [`idempotency_conflict`](https://docs.voyager.so/api/errors#idempotency_conflict). Use a new key for a new request.
* **Same key while the first request is still being processed:** `409` [`idempotency_in_flight`](https://docs.voyager.so/api/errors#idempotency_in_flight). Retry shortly with the same key and body.
* **No key:** every request is new. A retry after a lost response can create, and charge for, a second job.

"Same body" compares `model`, `operation`, `input`, `webhook_url` and `metadata` (key order and whitespace don't matter). Keys belong to you and the workspace, not to one API key, so a retry from a rotated key still finds the original.

A request refused before it was accepted (`400`, `401`, `402`, `403`, `404`, `422` or `429`, including a full queue) doesn't use up its key: fix the problem and retry with the same key. Once a request is accepted, its key stays bound to its result.

## Related

* [Webhooks](https://docs.voyager.so/api/webhooks.md)
* [Errors](https://docs.voyager.so/api/errors.md)
* [Rate limits and usage](https://docs.voyager.so/api/rate-limits.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.