> For the complete documentation index, see [llms.txt](https://docs.auray.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.auray.ai/documentation/jobs.md).

# Jobs

Photo, music, video and 3D do not answer with a file. They take the request, charge for it, queue it, and hand back an id. Everything after that — the status, the failure reason, the finished files — is read back through that id.

Chat looks like an exception and is not quite one. The request that pays for a turn also carries the answer, but the turn is still a job row, still readable at `GET /v1/jobs/{id}`, and still listed with the rest. That is how a caller whose connection dropped mid-answer gets back what they were charged for.

{% hint style="danger" %}
**The API is switched off on this deployment.** `API_V1_ENABLED` is unset, so every `/v1` route answers `503 api_disabled` before your key is parsed — a real key and a typo are indistinguishable here. Everything below is what happens the day that variable is set, not what happens if you try it now.
{% endhint %}

## The id

A job id is `<product>_<idempotency_key>`, split on the first underscore. The five prefixes are `photo`, `music`, `video`, `threed` and `chat`.

```
photo_nightly-482
threed_fig-1
video_a3f1c0de-6b21-4f6c-9d7f-0f2c4a9e1b55
```

**The id exists before dispatch, and that is the whole reason for the scheme.** Returning the rendering service's own id would mean a submit that timed out while holding your charge has nothing to name — a public API that sometimes cannot tell you what it just billed you for is not usable. Here the id is fixed the moment the request is parsed, so even the `502 dispatch_unresolved` path hands back something you can poll.

**Ownership is enforced by construction rather than by a lookup.** The id is rebuilt into an internal credit key using *your* user id, so an id copied out of somebody else's logs resolves to a key that does not exist. You get `404 not_found`.

**A malformed id is also 404, not 400.** "No such job" and "not yours" are deliberately the same answer, and a 400 on a bad prefix would tell a stranger which product prefixes are real.

## Idempotency keys

Send `idempotency_key` on any submit. It must match `[A-Za-z0-9._-]{1,64}`. Omit it and one is minted for you — a UUID — which you then read out of the `id` in the response.

**Colons are refused specifically.** Credit keys are built by joining fields with them — `photo:<user_id>:<client_key>` — and the ledger keys on exactly that string, so a colon here could shape a key that reads as another account's. Anything outside the alphabet is `400 invalid_idempotency_key`.

**Sending the same key twice never renders twice.** What the second call answers depends on the product, and the difference is real rather than an inconsistency:

| Product  | First call | Replay                                        |
| -------- | ---------- | --------------------------------------------- |
| `photo`  | `202`      | `200`, `replayed: true`, `credits_charged: 0` |
| `music`  | `202`      | `200`, `replayed: true`, `credits_charged: 0` |
| `threed` | `202`      | `200`, `replayed: true`, `credits_charged: 0` |
| `video`  | `202`      | `202`, `replayed: true`, `credits_charged: 0` |

Video answers `202` both times because video *does* dispatch on a replay — its upstream dedupes on the idempotency key, so the replay is a real job that is really still running. A `200` there would read as a settled job to anything switching on the status code.

Two cases cannot be served as a replay, and both are `409`:

* `in_flight` — this key was charged already, but its job row is not readable yet, so an earlier request of yours is still mid-dispatch. Poll the job it names; do not send a fresh key unless you actually want a second render.
* `key_settled` — this key's debit has already been settled. A refund *marks* a debit rather than deleting it, so the key can never be charged again and a poller waiting on it would wait for ever. Send a new key.

{% hint style="info" %}
Reusing a key is how a retry finds the original render instead of starting a second one. That is what it is for. Generate a new key only when you want new output.
{% endhint %}

## The five statuses

Five words, whatever the product.

| Status      | `settled` | Means                                                      |
| ----------- | --------- | ---------------------------------------------------------- |
| `queued`    | `false`   | Accepted and charged; not started.                         |
| `running`   | `false`   | The service is working on it.                              |
| `succeeded` | `true`    | Finished. Assets, if the product makes any, are fetchable. |
| `failed`    | `true`    | Finished badly. `error` and `error_code` say how.          |
| `cancelled` | `true`    | You stopped it with `DELETE /v1/jobs/{id}`.                |

**The internals disagree with each other and you never see it.** Music writes `canceled` and `processing`; photo, video and 3D write `cancelled` and `running`; chat has its own set entirely — `pending`, `streaming`, `complete`, `stopped`. Three spellings of "cancelled" in one public API is not something a caller should have to remember.

**A status this build has never heard of maps to `running`, not to `failed`.** An unrecognised word comes from a newer writer, and the safe reading of "something is happening I do not recognise" is that the job is alive. Telling you a job failed when it did not is the one error that makes you submit it again and pay twice.

**`cancelled` costs what was burned, not nothing.** `DELETE /v1/jobs/{id}` stops a running job and settles it: photo refunds in full, music and video bill the fraction of the render already spent, 3D keeps the shares of the stages that completed and leaves those artefacts downloadable. Cancelling something that already finished is `200` with `cancelled: false` rather than an error — you wanted it not running, and it is not.

## Three ways to learn an outcome

They cost the same. Pick the one that fits the shape of your program.

| Product  | `GET /v1/jobs/{id}`  | `?wait=`                        | Webhook     |
| -------- | -------------------- | ------------------------------- | ----------- |
| `photo`  | polls the service    | yes                             | yes         |
| `music`  | polls the service    | yes                             | yes         |
| `threed` | polls the service    | yes                             | yes         |
| `video`  | reads the stored row | **no** — `400 wait_unsupported` | yes         |
| `chat`   | reads the stored row | no                              | never fires |

**Video and chat are not polled upstream, and that is correct rather than lazy.** Video sets a callback URL unconditionally, so its webhook is its settler everywhere and a second poller here would be a second writer of status for no gain. Chat is settled by the request that streams it. For both, a job read reports what is already stored.

Chat's "no" in that table is weaker than video's: `/v1/chat` never reads `wait` at all, so the parameter is ignored rather than refused. There is nothing to wait for — the answer is in the response to the request that paid for it.

**Registering a webhook for `chat` is accepted and will never deliver anything.** The registration route takes `chat` because it takes every product name, but nothing in the chat path enqueues an event. Read the turn instead.

### 1. Poll `GET /v1/jobs/{id}`

Costs one `read` token and needs the `jobs:read` scope. This is the mode that works from anywhere, including from behind a firewall we cannot reach.

```json
{
  "id": "photo_nightly-482",
  "object": "job",
  "product": "photo",
  "status": "running",
  "settled": false,
  "provider_job_id": "b41c…",
  "credits_charged": 4,
  "created_at": "2026-08-27T04:58:11.204Z",
  "error": null,
  "error_code": null,
  "assets": [],
  "refused": 0,
  "poll_after_seconds": 2,
  "request_id": "37d99de9-d507-4aca-bb5b-b4fa3af97b96"
}
```

**`poll_after_seconds` disappears once the job settles.** Loop on its presence, not on a status you have to interpret — that way a status word this build invents later does not break your client. The default gaps are the upstream's, not ours:

| Product  | Gap | Why that number                                                       |
| -------- | --- | --------------------------------------------------------------------- |
| `photo`  | 2s  | The service is ours and answers from memory.                          |
| `chat`   | 2s  | The checkpoint flushes every two seconds; nothing changes faster.     |
| `video`  | 5s  | Only how often it is worth re-reading a row somebody else writes.     |
| `threed` | 15s | Its own upstream gap. Geometry previews appear well inside a minute.  |
| `music`  | 20s | Its upstream allows twenty requests a minute for the entire platform. |

Respect it. Asking sooner cannot learn anything new, and when the shared cooldown is what is holding the answer back the response also carries `Retry-After`.

**The response that first says `succeeded` carries the assets, and the one that first says `failed` carries the reason.** That is worth stating because it was once untrue: the settler writes to the database, not to the row object the route had already read, so the transition response used to report `error: null` on a failure and an empty `assets` array on a success — and it was the *last* response a looping client ever saw. The row is now re-read on the transition, and only on the transition.

Two fields appear only when they apply. `upstream_lost: true` means the rendering service no longer has any record of the job — it keeps them for a few days, so an old id reads like one that never existed. `refused` is photo only, and counts the images the safety filter withheld.

{% tabs %}
{% tab title="curl" %}

```bash
id=$(curl -s https://api.auray.ai/v1/photo \
  -H "Authorization: Bearer $AURA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prompt":"a single ripe fig on a slate tile, north window light, 85mm macro",
       "aspect_ratio":"1:1","resolution":1024,"idempotency_key":"nightly-482"}' \
  | jq -r .id)

while :; do
  job=$(curl -s "https://api.auray.ai/v1/jobs/$id" -H "Authorization: Bearer $AURA_API_KEY")
  gap=$(echo "$job" | jq -r '.poll_after_seconds // empty')
  # The field is gone: the job has settled and this is the last read.
  [ -z "$gap" ] && break
  sleep "$gap"
done

echo "$job" | jq -r .status
```

{% endtab %}

{% tab title="Python" %}

```python
import os, time, requests

auth = {"Authorization": f"Bearer {os.environ['AURA_API_KEY']}"}

job = requests.post(
    "https://api.auray.ai/v1/photo",
    headers=auth,
    json={
        "prompt": "a single ripe fig on a slate tile, north window light, 85mm macro",
        "aspect_ratio": "1:1",
        "resolution": 1024,
        "idempotency_key": "nightly-482",
    },
).json()

while True:
    job = requests.get(f"https://api.auray.ai/v1/jobs/{job['id']}", headers=auth).json()
    gap = job.get("poll_after_seconds")
    if gap is None:          # settled
        break
    time.sleep(gap)

print(job["status"], job["assets"])
```

{% endtab %}

{% tab title="Node" %}

```javascript
const auth = { Authorization: `Bearer ${process.env.AURA_API_KEY}` };

let job = await fetch("https://api.auray.ai/v1/photo", {
  method: "POST",
  headers: { ...auth, "Content-Type": "application/json" },
  body: JSON.stringify({
    prompt: "a single ripe fig on a slate tile, north window light, 85mm macro",
    aspect_ratio: "1:1",
    resolution: 1024,
    idempotency_key: "nightly-482",
  }),
}).then((r) => r.json());

for (;;) {
  job = await fetch(`https://api.auray.ai/v1/jobs/${job.id}`, { headers: auth }).then((r) => r.json());
  const gap = job.poll_after_seconds;
  if (gap === undefined) break; // settled
  await new Promise((r) => setTimeout(r, gap * 1000));
}
```

{% endtab %}
{% endtabs %}

`GET /v1/jobs` is how a lost id is recovered — newest first, across every product, with `?product=`, `?limit=` up to 100, `?settled=true|false` and a `?before=` cursor. It does not poll anything; statuses in a list are as fresh as the last settle, and a job read is what reconciles one.

### 2. `?wait=<seconds>`

Add it to a `photo`, `music` or `threed` submit, 1 to 300 whole seconds. The request stays open and comes back with the finished job. It is not a different pipeline — it is the same job read in a loop on our side of the network instead of yours, sharing the same reconcilers and therefore the same upstream cooldowns.

```bash
curl "https://api.auray.ai/v1/photo?wait=120" \
  -H "Authorization: Bearer $AURA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prompt":"…","aspect_ratio":"1:1","resolution":1024}'
```

Settled inside the budget: `200`, with `waited: true`, `settled: true`, the asset descriptors and `error`. Not settled: `202`, with `timed_out: true` and a `poll_after_seconds`. The job carries on either way and the charge stands.

{% hint style="warning" %}
**A wait that runs out is never `408` and never `504`.** Both of those read as *your request failed*, and a generic HTTP client that retries on `504` would resubmit — quite possibly with a fresh idempotency key — and you would pay twice for one image. It is `202`: the same code the same submit returns without `?wait=`, because the job really is still only accepted.
{% endhint %}

**`wait` bounds the polling loop, not the response time.** The loop never starts a sleep or a poll it cannot afford, but a poll already in flight is not abandoned — cancelling it would throw away the very answer being waited for, and that reconcile is what settles the job for everybody. So the ceiling is your wait plus one upstream gap: at most twenty seconds over, for the three products that allow waiting. Measured the other way too — `?wait=3` on a photo answered at about eight seconds, because the first poll found a finished render.

**The waiting room is small, and being turned away from it is not an error.** Twenty requests may be blocked platform-wide at once, and seven of those may be one account's — the sum of the four products' API lane caps, two of which belong to video and can therefore never be claimed by a wait. When there is no seat you get the ordinary `202` plus `wait_declined: "platform_busy"` or `"account_busy"`. The job was still submitted and still charged. Poll it.

A malformed `?wait=` is `400 invalid_wait`, and it is checked before the wallet is touched — finding a caller's typo after charging them would mean either refusing a request they had paid for, or charging them and then not waiting.

**Video refuses `?wait=` outright: `400 wait_unsupported`.** Its cheapest clip takes longer than the whole request budget, so there is no honest way to hold the connection open. Ignoring the parameter would be worse than refusing it — a caller who asked to wait and got an immediate `202` reads it as a finished job and goes looking for files that do not exist. This route accepted `?wait=30` and queued two real clips before the check existed.

### 3. Webhooks

Register an endpoint and it is told the moment a job settles, including jobs you started from the desktop rather than from code.

```json
{
  "id": "evt_photo_nightly-482_succeeded",
  "type": "job.succeeded",
  "created": 1787720029,
  "data": {
    "id": "photo_nightly-482",
    "object": "job",
    "product": "photo",
    "status": "succeeded",
    "credits_charged": 4
  }
}
```

The types are `job.succeeded`, `job.failed` and `job.cancelled` — the status, prefixed. **There are no signed URLs and no prompt text in an event.** A signed URL lives fifteen minutes and the retry schedule runs for a day, so one in an event that succeeds on the fourth attempt is a dead link you would report as a bug; the prompt is the most personal thing in the row and does not belong in a third party's log aggregator. Fetch what you need with your own key.

Answer `2xx`, and quickly. The delivery gives up after ten seconds total, keeps at most 2 KB of your response, and never follows a redirect. The first attempt goes out within seconds of the job settling, from the request that settled it; after that the ladder is roughly 5 minutes, 15 minutes, 1 hour, 3 hours, 8 hours and 24 hours, each with ±20% jitter. A `4xx` other than `408` or `429` is not retried at all — you understood us and said no. A `410 Gone` switches the endpoint off immediately. Ten consecutive failures disable it.

A delivery is created at most once per job per event, so a job settled by two different paths — an inbound service callback and a poll racing each other — still notifies you once.

## Assets are descriptors, never URLs

A job read describes what was produced. It never contains a link to it.

```json
"assets": [
  { "index": 0, "kind": "image", "key": "…/0.png", "width": 1024, "height": 1024, "bytes": 949364 }
]
```

**A URL in a polling response would be a live credential sitting in a log, and a dead link by the time a slow consumer read it.** Ask for links when you actually want the bytes:

```bash
curl https://api.auray.ai/v1/jobs/photo_nightly-482/assets \
  -H "Authorization: Bearer $AURA_API_KEY"
```

```json
{
  "id": "photo_nightly-482",
  "object": "asset_list",
  "product": "photo",
  "expires_at": "2026-08-27T05:23:59.102Z",
  "megabytes_charged": 1,
  "assets": [{
    "index": 0, "kind": "image", "filename": "0.png",
    "content_type": "image/png", "bytes": 949364,
    "url": "https://…"
  }]
}
```

This call needs the `assets:read` scope and is admitted **twice**. The first admission identifies you and costs one `read` token, because until the key is checked there is no row to look at and no size to charge for. The second charges the `egress` bucket in whole megabytes, rounded up, minimum one — the cost of a download is its size, not its count, and a limiter counting requests would let one key take fifty 40 MB models for the price of fifty PNGs.

The URLs are signed and live fifteen minutes. Fetch them directly; the bytes are never proxied back through us, so a 34 MB model comes to you at full speed rather than through a serverless function with a response limit that has nothing to do with the file. Fifteen minutes is also what makes deletion real — a permanent URL would mean a job you removed stays readable by anyone who kept the link.

Four refusals are worth knowing before you write the fetch:

| Error                   | Status | Means                                                                    |
| ----------------------- | ------ | ------------------------------------------------------------------------ |
| `not_ready`             | `409`  | The job has not settled. Poll it and try again.                          |
| `refunded`              | `402`  | The job's credits were given back, so its output is not yours to fetch.  |
| `expired`               | `410`  | The job existed; the stored object is gone. Output is not kept for ever. |
| `no_signing_credential` | `503`  | This deployment holds no credential for the bucket those files are in.   |

Chat answers with an empty `assets` list and `expires_at: null` rather than a 404. Its answer is text on the job itself, and returning that as a presigned download would make you fetch a second URL to read a sentence.

## The whole lifecycle, once

{% stepper %}
{% step %}

### Submit with a key you chose

`POST /v1/photo` with `idempotency_key: "nightly-482"` answers `202` and `Location: /v1/jobs/photo_nightly-482`. The credits are already spent. Write the id down before you do anything else — if your process dies here, `GET /v1/jobs` is the only way back to it.
{% endstep %}

{% step %}

### Learn the outcome

Loop on `GET /v1/jobs/photo_nightly-482` while `poll_after_seconds` is present, or ask for `?wait=` on the submit, or let a webhook tell you. If the retry that follows a crash reuses `nightly-482`, it finds this job rather than starting another.
{% endstep %}

{% step %}

### Ask for URLs

`GET /v1/jobs/photo_nightly-482/assets` once `settled` is true. This is the call that spends the `egress` bucket, so make it when you are ready to download and not before.
{% endstep %}

{% step %}

### Fetch inside fifteen minutes

Pull the bytes straight from the signed URLs and store them yourself. Re-asking for links is another `egress` charge; re-rendering is a real one.
{% endstep %}
{% endstepper %}

## What does not work today

**The whole surface is off.** `API_V1_ENABLED` is unset, so every route here is `503 api_disabled`. There is no key that gets past it.

**A free key cannot start a job at all.** The free plan's `generate` bucket has a capacity of zero, which is not the same as being out of tokens, so the refusal is `403 plan_has_no_api_generate` rather than a `429`. Retrying never helps — a bucket of zero never refills into one. A free key can still read jobs and fetch assets from work created earlier.

**3D runs one API job at a time, worldwide.** There are four GPU containers for the entire platform and the desktop keeps three, with no floating seat between the two sides. That is a slow API rather than a broken one, and it is better learned here than from a queue that never moves.

**Some old jobs have no public id, and `GET /v1/jobs` cannot show them.** The id is derived from the credit key on the row, and rows written before the per-product key namespaces existed do not have a key of that shape. They are skipped by the listing rather than given an id that would resolve to nothing. If you made something in the desktop long ago and cannot find it over HTTP, that is why — it is still in your library.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.auray.ai/documentation/jobs.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
