# Async jobs

How to submit image and video jobs, poll or get them pushed, download the result, read every status and field, and how refunds work.

> https://nezhagate.com/en/docs/guide/async-jobs

## Which endpoints are async

Image and video generation run as async jobs: the call returns a job id at once and the work happens in the background. Chat is synchronous and never uses jobs.

| Method | Path | What it does |
| --- | --- | --- |
| POST | `/v1/images/generations` | Text-to-image. Returns a job id at once (HTTP 202). |
| POST | `/v1/images/edits` | Image-to-image. Passing `image` to the call above does the same. |
| GET | `/v1/images/jobs/{id}` | Get an image job: status and result. |
| POST | `/v1/videos/generations` | Generate a video. Returns a job id at once (HTTP 202). |
| GET | `/v1/videos/jobs/{id}` | Get a video job: status and result. |

## How it works

1. Submit: call a generation endpoint and get HTTP 202 with a job `id` right away (it looks like `img_...`; video jobs use the same prefix).

2. Wait: poll the job endpoint with that id, or add a `callback_url` when you submit and have the result pushed to you.

3. Done: `status` turns `succeeded` and the result link is in `data[0].url`; if it turns `failed`, the reason is in `error` and the credits held for the job are back in your balance.

4. Download: result links are kept for 60 days; copy anything you need to keep to your own storage.

```
curl https://nezhagate.com/v1/videos/generations \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "seedance-2.0", "prompt": "a paper boat drifting down a rainy street", "duration": 5, "size": "16:9"}'

# HTTP 202
{"id": "img_9c1e...7a", "object": "video.generation.job", "status": "queued", "created": 1791199400, "model": "seedance-2.0"}
```

```
curl https://nezhagate.com/v1/videos/jobs/img_9c1e...7a -H "Authorization: Bearer YOUR_API_KEY"
```

## Job statuses

| Status | Meaning |
| --- | --- |
| `queued` | Waiting in line. Carries `queue_position` (jobs ahead of yours) and `eta_seconds` (expected wait). |
| `processing` | Rendering. |
| `succeeded` | Done. The result is in `data[0].url`, the billing detail in `usage`. |
| `failed` | Failed. The reason is in `error`; the credits held for the job are refunded in full. |

`succeeded` and `failed` are final; a job never changes after that.

## Response fields

| Field | Meaning |
| --- | --- |
| `id` | The job id, used for polling and in webhooks. |
| `object` | `image.generation.job` for images, `video.generation.job` for video. |
| `status` | `queued` / `processing` / `succeeded` / `failed`. |
| `created` | When the job was submitted, in Unix seconds. |
| `model` | The model id it was submitted with. |
| `queue_position` | Only while `queued`: how many jobs are ahead of yours. |
| `eta_seconds` | Only while `queued`: about how many seconds until it starts. |
| `data[].url` | Only when `succeeded`: the link to the result (an image or an mp4), kept for 60 days. |
| `usage` | Only when `succeeded`: what was billed. Images report `images` (pictures delivered) and `resolution`; video reports `clips`. |
| `error` | Only when `failed`: `message`, `type`, `code` and `param`; branch on `code`. |

```
{
  "id": "img_9c1e...7a",
  "object": "video.generation.job",
  "status": "succeeded",
  "created": 1791199400,
  "model": "seedance-2.0",
  "data": [{"url": "https://img.nezhagate.com/i/5d41402abc....mp4"}],
  "usage": {"clips": 1, "model": "seedance-2.0"}
}
```

```
{
  "id": "img_9c1e...7a",
  "object": "video.generation.job",
  "status": "failed",
  "created": 1791199400,
  "model": "seedance-2.0",
  "error": {"message": "...", "type": "invalid_request_error", "code": "content_policy", "param": null}
}
```

## Polling tips

- Poll images every 2–3 seconds and video every 5–10 seconds. Polling is free.

- Stop as soon as you see `succeeded` or `failed`.

- Give your program an overall time limit: images usually take tens of seconds to a few minutes, video anything from a minute or two to over an hour (longer clips and queues take longer); each model doc lists typical times. When the limit passes, do not resubmit: the job is still there, keep polling later.

- A job belongs to the account that submitted it: any key of that account can read it, and any other account gets 404 `job_not_found`. Job records are kept for 3 days; after that the job id also returns 404, while the result link keeps working for its 60 days.

- If a submit times out on your side before you get a job id, check the call log in the console to see whether the job was created before you submit again, so you do not pay twice.

## Webhooks instead of polling

Add `callback_url` when you submit and the moment the job ends, success or failure, the gateway pushes exactly what the poll endpoint returns to that URL, signed and retried on failure. You can also set an account-wide address under Settings → Webhook in the console.

```
{"model": "seedance-2.0", "prompt": "...", "duration": 5, "callback_url": "https://example.com/webhooks/nezhagate"}
```

[Webhook docs →](https://nezhagate.com/en/docs/webhooks)

## Billing

- Submitting holds the job's price from your balance; without enough balance you get 402 and no job is created.

- Success settles what was delivered: images by the number of pictures delivered, video by the duration or clip you ordered (the same as the hold); anything held beyond that comes back automatically. Failure returns the full hold.

- Polling and webhook deliveries are free.

## How long results are kept

`data[0].url` points to our storage and is deleted automatically after 60 days. To keep using a result, download it to your own storage or CDN.

## Why a job fails

A failed job is always refunded in full. These are the codes you will see most often in a failed job:

| code | When you see it | What to do |
| --- | --- | --- |
| `content_policy` | Video: the prompt, a reference, or the generated picture or sound did not pass content review (`param` is `audio` when the sound was flagged). | Rewrite the prompt or replace the reference before you submit again; the same input is refused again. |
| `moderation_blocked` | Image: the model provider's safety system refused the prompt or a reference image (the code may also be `content_policy_violation`). | Rewrite the prompt or replace the reference; resubmitting it unchanged is usually refused again. |
| `render_failed` | Video: no clip came out this time, usually a one-off. | Submitting the same request again usually works. |
| `invalid_material` | Video: the upstream found a problem with the input. It is a catch-all error and often a one-off. | Resubmit as is first; if it keeps happening, check the references and the prompt length. |
| `render_timeout` | Video: queueing or rendering took too long and the upstream gave the job up. | Submit again. |
| `wait_timeout` | Video: no clip arrived within the longest wait the line allows. | Submit again. |
| `upstream_unstable` | Video: the connection broke during rendering. | Submit again. |
| `result_fetch_failed` | The video was rendered, but downloading or storing it kept failing on our side. | Submit again. |
| `invalid_request` | Video: the line does not accept this combination of parameters (duration, ratio and resolution, for example). | Adjust the parameters as `message` says and submit again. |
| `upstream_error` | Any other failure: every line was tried, the job waited 15 minutes without starting, the upstream refused without a reason, and so on; `message` says which. | Submitting again usually works. |

[Full list of error codes →](https://nezhagate.com/en/docs/guide/errors)

## Complete example

Submit, poll and download in one go, with an overall time limit and failure handling. Replace YOUR_API_KEY and run it.

```
import time
import requests

BASE = "https://nezhagate.com/v1"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}

def run_job(kind, payload, every=5, give_up=5400):
    """kind is "images" or "videos". Returns the finished job (succeeded or failed)."""
    job = requests.post(f"{BASE}/{kind}/generations", headers=HEADERS, json=payload, timeout=120)
    job.raise_for_status()                       # 4xx here: fix the request; nothing was billed
    job_id = job.json()["id"]
    deadline = time.time() + give_up
    while time.time() < deadline:
        time.sleep(every)
        r = requests.get(f"{BASE}/{kind}/jobs/{job_id}", headers=HEADERS, timeout=30).json()
        if r["status"] in ("succeeded", "failed"):
            return r
    raise TimeoutError(f"{job_id} still running; poll it again later, it is not lost")

job = run_job("videos", {"model": "seedance-2.0", "prompt": "a paper boat drifting down a rainy street",
                         "duration": 5, "size": "16:9"})
if job["status"] == "succeeded":
    url = job["data"][0]["url"]
    with open("clip.mp4", "wb") as f:            # keep your own copy: links expire after 60 days
        f.write(requests.get(url, timeout=300).content)
else:
    print(job["error"]["code"], job["error"]["message"])
```
