# Jobs and polling

> Every create call returns a job. One endpoint tells you when any of them is done.

Source: https://developers.mnml.ai/docs/jobs

`GET https://api.mnml.ai/v2/jobs/{id}`

## Example request

**curl**

```bash
curl https://api.mnml.ai/v2/jobs/10482 \
  -H "Authorization: Bearer $MNML_API_KEY"
```

**TypeScript**

```ts
const res = await fetch("https://api.mnml.ai/v2/jobs/10482", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.MNML_API_KEY}`,
  },
});

const data = await res.json();
```

**Python**

```python
import os, requests

res = requests.get(
    "https://api.mnml.ai/v2/jobs/10482",
    headers={"Authorization": f"Bearer {os.environ['MNML_API_KEY']}"},
)

data = res.json()
```

## Example response (200)

```json
{
  "success": true,
  "data": {
    "id": "10482",
    "status": "succeeded",
    "kind": "render",
    "engine": "v4.6",
    "outputs": [
      {
        "url": "https://api.mnml.ai/v2/files/10482?exp=1759575600&sig=Q2h4…",
        "media": "image",
        "expires_at": "2026-10-04T11:00:00.000Z"
      }
    ],
    "charged_usd": 0.2475,
    "refunded_usd": 0,
    "credits_charged": 25,
    "credits_refunded": 0,
    "error": null,
    "created_at": "2026-10-04T09:00:00.000Z",
    "completed_at": "2026-10-04T09:00:41.000Z"
  }
}
```

## Headers

- `Authorization` (string, required): `Bearer mk_live_…`: your API key. See [Authentication](https://developers.mnml.ai/docs/authentication).

## Path parameters

- `id` (string, required): The job id a create call answered with.

## Query parameters

- `wait` (integer): Hold the answer up to this many seconds (1 to 90) until the job settles; it answers as soon as it does. Call again while status is queued or processing.

## Response

On success (`200`): The job. The JSON is `{ "success": true, "data": … }`, and inside `data`:

- `id` (string, required): The job’s id, as the create call answered it.
- `status` (string, required): Where the job is. `succeeded`, `failed` and `canceled` are final: stop polling. Values: `queued`, `processing`, `succeeded`, `failed`, `canceled`.
- `kind` (string, required): What the job makes: `render`, `edit`, `erase`, `upscale`, `enhance`, `bg-remove`, `extend` (an `outpaint`) or `video`.
- `engine` (string | null, required): The engine’s mnml name for a render or an edit; `null` for an enhancement, an erase or a video.
- `outputs` (object[], required): The results, once the job has `succeeded`; empty before that, and on failure.
  - `url` (string, required): A signed link to the file at [GET /v2/files/{id}](#files). It needs no key.
  - `media` (string, required): Whether the file is an image or a video. Values: `image`, `video`.
  - `expires_at` (string, required): When the link stops working, as an ISO 8601 time. Poll again for a fresh one, and download what you keep.
- `charged_usd` (number, required): What the job charged when it started, in US dollars.
- `refunded_usd` (number, required): What came back when the job failed or was cancelled, in US dollars; 0 otherwise.
- `error` (object | null, required): Why the job failed or stopped; `null` otherwise.
  - `code` (string, required): A stable code to branch on. See [When a job fails](#errors). Values: `UNSAFE_CONTENT`, `NO_CHANGE`, `CANCELED`, `RENDER_FAILED`.
  - `message` (string, required): The same in plain words, for a person.
- `created_at` (string, required): When the job was accepted, as an ISO 8601 time.
- `completed_at` (string | null, required): When it reached a final status, as an ISO 8601 time; `null` while it runs.

**Response headers (4)**

- `X-Request-Id` (string): This request, for support and the console log.
- `RateLimit-Limit` (integer): Requests this key may make per minute.
- `RateLimit-Remaining` (integer): Requests left in the current minute.
- `RateLimit-Reset` (integer): Seconds until the current minute resets.

## Status codes

| Status | Meaning |
| --- | --- |
| 200 | The job. |
| 401 | The API key is missing, malformed, unknown or revoked. |
| 429 | A limit was reached: requests per minute, or a daily or concurrency limit. |
| Other | Any other error, in the same envelope. |

Every error answers `{ "success": false, "error": { "code", "message" } }`. What each code means, and what to do, is on [Errors](https://developers.mnml.ai/docs/errors).

## Job statuses

| Status | Meaning |
| --- | --- |
| queued | Accepted and waiting for capacity. |
| processing | Rendering now. |
| succeeded | Done. `outputs` lists the results. |
| failed | Did not finish. `error` says why, and `credits_refunded` what came back. |
| canceled | Stopped before it delivered, and refunded. |

## Waiting for a job

Add `?wait=90` to the read: it answers as soon as the job is `succeeded`, `failed` or `canceled`, or with the job as it stands after 90 seconds. Call it again while the status is `queued` or `processing`. Reading a job is free. A render started with `?wait` often needs no read at all.

**Wait until it settles**

```
while :; do
  job=$(curl -s "https://api.mnml.ai/v2/jobs/10482?wait=90" -H "Authorization: Bearer $MNML_API_KEY")
  echo "$job" | grep -qE '"status":"(queued|processing)"' || break
done
echo "$job"
```

Without `wait`, poll every two to five seconds. A video takes minutes: for video, or for many jobs at once, a [webhook](https://developers.mnml.ai/docs/webhooks) beats waiting.

## Following a job as a stream

`GET /v2/jobs/{id}/events` holds one connection open for as long as the job runs, up to 10 minutes, so a render that takes two or three minutes needs no loop at all. It is a Server-Sent Events stream: a `job` event with the job as it stands, another each time it changes, then one last event and the stream closes. A job that has already settled when the stream opens gets the `done` event alone. Every event carries JSON, and a `job` or `done` event is exactly what `GET /v2/jobs/{id}` answers.

| Event | Meaning |
| --- | --- |
| job | The job while it is `queued` or `processing`: first as it stands, then on each change. |
| done | The last event: the job settled, `succeeded`, `failed` or `canceled`. `outputs` holds the results. |
| timeout | The last event after 10 minutes: the job as it stands. Open the stream again. |
| error | The last event when the job could not be read: `{ code, message }`, with `code` `READ_FAILED`. The job still runs; read it with `GET /v2/jobs/{id}`. |

**Follow a job**

```
curl -N "https://api.mnml.ai/v2/jobs/10482/events" -H "Authorization: Bearer $MNML_API_KEY"

event: job
data: {"id":"10482","status":"processing","outputs":[],…}

event: done
data: {"id":"10482","status":"succeeded","outputs":[{"url":"…","media":"image",…}],…}
```

While nothing changes, a `: ping` comment arrives every 15 seconds to keep the connection alive; SSE clients skip it. An unknown job is a plain `404`, before any stream starts. Hanging up closes the stream, never the job. An account can hold 20 streams and `?wait` calls open at once, counted together across our servers; a stream past that is a `429`. For many jobs, a [webhook](https://developers.mnml.ai/docs/webhooks) is the better fit. When we restart a server for a deploy, an open stream ends with `timeout`: open it again.

## Outputs

Each output has a `url`, its `media` (`image` or `video`) and an `expires_at`. The link is signed and needs no key, so you can hand it to a browser; it lasts one to two hours, and a fresh poll gives a fresh one. A job and its file are kept for 7 days, then deleted, and never appear in your mnml library. Download what you want to keep.

Images come without a watermark once you have added funds to your API balance. Before that, they carry the mnml watermark, as Studio downloads do on an account that has never paid.

## When a job fails

| error.code | Meaning |
| --- | --- |
| RENDER_FAILED | The render did not complete. It is refunded; try again. |
| UNSAFE_CONTENT | The request cannot be rendered under the content policy. |
| NO_CHANGE | The engine returned the image unchanged. |
| CANCELED | The job was cancelled before it delivered. |

## Download an output

`GET /v2/files/{id}` is the link each output’s `url` already holds: fetch it as it is. It takes no key, since the signature in the link is the credential, so a browser or an `<img>` tag can load it until `expires_at`. An expired or altered link answers `404`; poll the job again for a fresh one.

### Path parameters

- `id` (string, required): The job id a create call answered with.

### Query parameters

- `exp` (string, required): When the link expires (part of the link).
- `sig` (string, required): The signature (part of the link).

### Response

On success (`200`): The file: an image, watermarked on plans that mark downloads, or a video. The body is the file itself, as `image/jpeg`, `image/png`, `image/webp`, `video/mp4`.

**Response headers (1)**

- `X-Request-Id` (string): This request, for support and the console log.

### Status codes

| Status | Meaning |
| --- | --- |
| 200 | The file: an image, watermarked on plans that mark downloads, or a video. |
| Other | Any other error, in the same envelope. |

Every error answers `{ "success": false, "error": { "code", "message" } }`. What each code means, and what to do, is on [Errors](https://developers.mnml.ai/docs/errors).
