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

# Async task API

> Submit a generation task, get a task id back in under a second, then poll or receive a callback when the images are ready.

The async task API is two endpoints and one request envelope. You submit a task,
you get an id immediately, and the generation happens on WideRouter's side.
Nothing about your request has to stay connected while the model works.

<Info>
  Use this API when you would otherwise be holding an HTTP connection open for
  20–50 seconds. If you already have a synchronous integration that works, see
  [Choosing sync or async](#choosing-sync-or-async) before migrating.
</Info>

## Endpoints

| Method | Path                 | Purpose                                                 |
| ------ | -------------------- | ------------------------------------------------------- |
| `POST` | `/v1/tasks/submit`   | Create a task. Returns a task id immediately.           |
| `GET`  | `/v1/task/{task_id}` | Read the task's status and, once finished, its outputs. |

Note the path is plural on create and **singular on read**. There is no list,
cancel, or delete endpoint.

## Create a task

The request body is always the same three-key envelope:
`model`, `input`, and an optional `callback_url`.

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api.widerouter.com/v1/tasks/submit \
    -H "Authorization: Bearer $WIDEROUTER_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "model": "gemini-3-pro-image",
      "input": {
        "prompt": "a small ceramic teapot on a light-grey studio backdrop",
        "aspect_ratio": "16:9",
        "image_size": "2K"
      }
    }'
  ```

  ```python Python theme={null}
  import os, requests

  resp = requests.post(
      "https://api.widerouter.com/v1/tasks/submit",
      headers={"Authorization": f"Bearer {os.environ['WIDEROUTER_API_KEY']}"},
      json={
          "model": "gemini-3-pro-image",
          "input": {
              "prompt": "a small ceramic teapot on a light-grey studio backdrop",
              "aspect_ratio": "16:9",
              "image_size": "2K",
          },
      },
      timeout=30,
  )
  task_id = resp.json()["id"]
  ```

  ```javascript Node theme={null}
  const resp = await fetch("https://api.widerouter.com/v1/tasks/submit", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.WIDEROUTER_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: "gemini-3-pro-image",
      input: {
        prompt: "a small ceramic teapot on a light-grey studio backdrop",
        aspect_ratio: "16:9",
        image_size: "2K",
      },
    }),
  });
  const { id } = await resp.json();
  ```
</CodeGroup>

The response is three fields and arrives in well under a second:

```json theme={null}
{
  "id": "task_dksUgWLYX4jIVueCACD8KwJzjR5JhRsI",
  "status": "queued",
  "created_at": 1788104112
}
```

### Envelope fields

| Field          | Type   | Required | Notes                                                    |
| -------------- | ------ | -------- | -------------------------------------------------------- |
| `model`        | string | yes      | See [supported models](#supported-models).               |
| `input`        | object | yes      | Must be a JSON object. A string or an array is rejected. |
| `callback_url` | string | no       | Must be an `https` URL. See [Callbacks](#callbacks).     |

Unknown keys at the envelope level are currently accepted and ignored — do not
rely on that, and do not put generation parameters there. They belong in `input`.

### The `input` object

`input` carries the generation parameters, and **which fields it accepts depends
on the model**. The envelope around it is fixed; the contents are not.

Validation inside `input` is strict — any key the model does not recognize is
rejected outright, which makes a typo loud instead of silent:

```json theme={null}
{ "error": { "code": "invalid_params", "message": "unknown field", "param": "input.negative_prompt" } }
```

<Card title="Nano Banana series" icon="layers" href="/models/nano-banana/overview">
  The full field list, resolution tiers, aspect ratios and image editing.
</Card>

## Poll for the result

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api.widerouter.com/v1/task/task_dksUgWLYX4jIVueCACD8KwJzjR5JhRsI \
    -H "Authorization: Bearer $WIDEROUTER_API_KEY"
  ```

  ```python Python theme={null}
  import os, time, requests

  def wait(task_id, timeout=600):
      headers = {"Authorization": f"Bearer {os.environ['WIDEROUTER_API_KEY']}"}
      deadline = time.time() + timeout
      while time.time() < deadline:
          task = requests.get(
              f"https://api.widerouter.com/v1/task/{task_id}",
              headers=headers, timeout=30,
          ).json()
          if task["status"] in ("completed", "failed"):
              return task
          time.sleep(3)
      raise TimeoutError(task_id)
  ```
</CodeGroup>

A finished task looks like this:

```json theme={null}
{
  "id": "task_dksUgWLYX4jIVueCACD8KwJzjR5JhRsI",
  "model": "gemini-3-pro-image",
  "status": "completed",
  "created_at": 1788104112,
  "started_at": 1788104113,
  "completed_at": 1788104131,
  "expires_at": 1788190531,
  "outputs": [
    "https://r2cdn.agisuitepro.com/o/2026/08/30/task_dksUgWLYX4jIVueCACD8KwJzjR5JhRsI_0.jpg"
  ],
  "counts": { "requested": 1, "succeeded": 1, "failed": 0 }
}
```

### Task fields

| Field          | Present when       | Notes                                           |
| -------------- | ------------------ | ----------------------------------------------- |
| `id`           | always             | The task id.                                    |
| `model`        | always on read     | Echoed back; absent from the create response.   |
| `status`       | always             | `queued`, `in_progress`, `completed`, `failed`. |
| `created_at`   | always             | Unix seconds, UTC.                              |
| `started_at`   | from `in_progress` | When a worker picked the task up.               |
| `completed_at` | terminal states    | Includes `failed`.                              |
| `expires_at`   | `completed`        | `created_at` plus 24 hours.                     |
| `outputs`      | `completed`        | Array of image URLs, one per requested image.   |
| `counts`       | terminal states    | `requested`, `succeeded`, `failed`.             |
| `error`        | `failed`           | Object with `code` and `message`.               |

The response grows as the task advances — `outputs` and `expires_at` simply are
not there while the task is still running. Read fields defensively rather than
assuming a fixed shape, and branch on `status` first.

### Status flow

```
queued ──► in_progress ──► completed
                       └─► failed
```

Both terminal states are final and the read endpoint is idempotent: repeated
reads of a finished task return byte-identical JSON.

### How long to wait

Submitting is sub-second and stays that way under load: measured p50 0.88 s,
p95 0.92 s across 30 tasks at concurrency 12. The generation itself is where the
time goes — roughly 12–50 seconds depending on model and resolution, with 0–11
seconds of that spent queued. Per-model figures live on the model's own page.

<Warning>
  Poll every 2–3 seconds, not in a tight loop. A read costs about 0.9 s of
  round-trip on its own, so polling faster than that buys you nothing and only
  spends rate limit.
</Warning>

## Downloading outputs

`outputs` holds plain `https` URLs with no signature or query string, served
from WideRouter's delivery CDN — a different hostname from the API. Two things
follow from that:

<Steps>
  <Step title="They are unauthenticated">
    Do not send your API key to them, and treat the URL itself as the secret.
    Anyone holding the link can fetch the image for as long as it lives.
  </Step>

  <Step title="They expire after 24 hours">
    `expires_at` is always `created_at` plus 86400. Copy anything you need to
    keep into your own storage — do not store output URLs as permanent references.
  </Step>
</Steps>

<Warning>
  **Send a `User-Agent` header when you download.** The CDN sits behind a WAF
  that answers a bare `403` to requests with no `User-Agent` and to the default
  `Python-urllib/3.x` one. It looks exactly like an expired link but is not.
  Tested and fine: browsers, `curl`, `requests`, `axios`, `okhttp`, Java, Go,
  Postman. Not fine: a raw `urllib.request.urlopen(url)` with no headers.
</Warning>

```python theme={null}
import urllib.request

req = urllib.request.Request(url, headers={"User-Agent": "my-app/1.0"})
with urllib.request.urlopen(req, timeout=120) as r:
    data = r.read()
```

Images come back as JPEG carrying a C2PA content-credentials manifest. Measured
file sizes: about 0.4–0.7 MB at `1K`, 2.4–3.0 MB at `2K`, and 7.5–8.2 MB at `4K`.
Read the `Content-Type` from the response instead of assuming a file extension.

## Callbacks

Set `callback_url` on create and WideRouter posts the finished task to it, so
you can skip polling entirely.

```json theme={null}
{
  "model": "gemini-3-pro-image",
  "input": { "prompt": "a paper crane" },
  "callback_url": "https://your-app.example/hooks/widerouter"
}
```

The URL must be `https`. Anything else — `http`, a bare hostname, a non-string —
is rejected at submit time with `invalid_callback_url`, so a typo fails fast
instead of silently never delivering.

WideRouter posts the task object as `application/json`, with headers you can
route on before parsing the body:

| Header           | Value              |
| ---------------- | ------------------ |
| `X-Wide-Event`   | `task.completed`   |
| `X-Wide-Task-Id` | the task id        |
| `Content-Type`   | `application/json` |

The body is byte-identical to what `GET /v1/task/{task_id}` returns at that
moment — same fields, same values — so one handler can serve both paths.

Delivery was immediate in testing: callbacks for three tasks all arrived within
a second of the task reaching `completed`. If your endpoint answers with a `5xx`,
WideRouter retries; observed attempts were at roughly 0, 10 and 70 seconds.

<Warning>
  **Callbacks are not signed.** There is no HMAC header, and the URL is the only
  thing proving the request came from WideRouter. Treat the payload as a hint,
  not as authority: use a long unguessable path in your `callback_url`, and have
  the handler re-read `GET /v1/task/{task_id}` before acting on anything that
  matters.
</Warning>

<Info>
  A callback is a latency optimization, not a delivery guarantee. Keep a polling
  fallback for tasks whose callback never arrives, and make your handler
  idempotent — key it on the task `id`, since retries mean the same task can
  arrive more than once.
</Info>

## When a task fails

A `failed` task is still a `200` on the read endpoint. The failure is in the
body, not the HTTP status:

```json theme={null}
{
  "id": "task_jodb26qdNoG5CuwzA23OukOgjeH5ktnh",
  "model": "gemini-3-pro-image",
  "status": "failed",
  "created_at": 1788104830,
  "started_at": 1788104831,
  "completed_at": 1788104831,
  "counts": { "requested": 1, "succeeded": 0, "failed": 1 },
  "error": { "code": "input_fetch_error", "message": "fetch input image failed: ..." }
}
```

Note there is no `outputs` and no `expires_at`. Input-fetch failures are fast —
under a second — because they happen before any model work.

## Errors on submit

Validation happens before anything is queued, so a `400` here costs nothing.

| HTTP | `code`                 | Meaning                                                         |
| ---- | ---------------------- | --------------------------------------------------------------- |
| 400  | `invalid_params`       | Bad field. `param` names it exactly, e.g. `input.aspect_ratio`. |
| 400  | `invalid_callback_url` | `callback_url` is not an `https` URL.                           |
| 400  | `model_not_supported`  | Real model, but not available on the async API.                 |
| 401  | —                      | Missing or invalid `Authorization` header.                      |
| 404  | `task_not_found`       | No such task id, or it does not belong to your key.             |
| 503  | `model_not_found`      | The model name does not exist on the platform.                  |

Errors point at one field at a time, and `param` uses full paths including array
indices (`input.images[0]`), so you can map a failure straight onto your request.

## Which models work here

Model ids are matched **exactly**. There are no aliases, and `-preview` suffixed
names are not accepted. Sending an id the async API does not serve returns
`model_not_supported` at submit time, before anything is queued.

<Card title="Nano Banana series" icon="layers" href="/models/nano-banana/overview">
  Google's image models — availability on each surface, parameters, and measured latency.
</Card>

## Choosing sync or async

Both surfaces exist and neither is deprecated.

|                               | Async task API       | Synchronous image APIs                                            |
| ----------------------------- | -------------------- | ----------------------------------------------------------------- |
| Client holds the connection   | no, \~0.9 s          | yes, 12–50 s                                                      |
| Result delivery               | CDN URL, 24 h        | base64 in the response body                                       |
| Client disconnects mid-flight | task still completes | result is lost, request still billed                              |
| Multiple images per call      | `n` up to 4          | not available — `n` is accepted and ignored, one image comes back |
| Callback                      | yes                  | no                                                                |

Async is the better default for anything running behind a serverless function,
a reverse proxy, or a mobile client, because none of those reliably survive a
50-second request. Synchronous calls remain simpler for a script that just wants
bytes back.

## Next steps

<CardGroup cols={2}>
  <Card title="Nano Banana series" icon="layers" href="/models/nano-banana/overview">
    Parameter matrix, resolution tiers, and what differs between the two models.
  </Card>

  <Card title="Quickstart" icon="rocket" href="/quickstart">
    The same flow end to end in about five minutes.
  </Card>
</CardGroup>
