# Asynchronous jobs (https://hypit.ai/api-reference/jobs/)

> How image, video and music generation actually works — submit, poll, collect, and what happens to your credits when it fails.

Images, video and music are **asynchronous**. The create endpoint does not wait for the artifact: it
validates your request, reserves credits, writes a durable job and answers `202 Accepted`
immediately. You then poll for the job's status and, once it succeeds, ask for signed download
links.

This is the part of the API most likely to surprise you, so it is worth reading before you write any
integration code.

```text
POST /v1/images/generations        ─┐
POST /v1/images/edits               │
POST /v1/videos                     ├─► 202 { "id": "job_…", "status": "queued" }
POST /v1/audio/music               ─┘
                                        │
                     GET /v1/jobs/{job_id}          ← poll until terminal
                                        │
                     GET /v1/jobs/{job_id}/assets   ← signed URLs, 15 minutes
```

Chat completions, speech synthesis and transcription are **not** jobs — they return their result in
the response body. Only the three generative-media surfaces above use this ladder.

## The job envelope [#the-job-envelope]

Every create endpoint and every status read returns the same object:

```json
{
  "id": "job_2f81c0a4d3b57e9016ab24cf",
  "status": "queued",
  "kind": "video",
  "model": "bytedance/seedance-2",
  "progress": 0,
  "created_at": 1787824589,
  "updated_at": 1787824589,
  "finished_at": 0,
  "dispatch_deadline_at": 1787825189,
  "error_code": "",
  "error": ""
}
```

| Field                  | Notes                                                           |
| ---------------------- | --------------------------------------------------------------- |
| `id`                   | opaque job id, prefixed `job_`. The only handle you need        |
| `status`               | see the state table below                                       |
| `kind`                 | `image`, `video` or `audio`                                     |
| `model`                | the model you asked for, not the upstream one we routed to      |
| `progress`             | `0`–`100`. Advisory: many upstreams only ever report 0 then 100 |
| `created_at`           | Unix seconds                                                    |
| `updated_at`           | omitted while unset                                             |
| `finished_at`          | Unix seconds, present once terminal                             |
| `dispatch_deadline_at` | the moment the job must have reached an upstream by             |
| `error_code`, `error`  | present on a non-success terminal, omitted otherwise            |

## States [#states]

```text
queued ──► running ──► succeeded
   │           └─────► failed
   └─► queue_expired
```

| Status          | Terminal | Meaning                                                                        |
| --------------- | -------- | ------------------------------------------------------------------------------ |
| `queued`        | no       | accepted, credits reserved, waiting for upstream capacity                      |
| `running`       | no       | an upstream has accepted the job and is generating                             |
| `succeeded`     | **yes**  | assets are stored and downloadable                                             |
| `failed`        | **yes**  | generation failed, or succeeded upstream but produced nothing we could deliver |
| `queue_expired` | **yes**  | we never got the job onto an upstream before `dispatch_deadline_at`            |

`canceled` exists in the schema for historical rows only. No new job can enter it.

<Callout type="info" title="There is no cancel endpoint">
  Once a job is created it runs to a terminal state. Closing your client, dropping the connection or
  stopping the poll only stops *you observing* — it does not stop the job, and it does not refund it.
  Any cancel-shaped URL you may find in older material returns a plain `404`.
</Callout>

## The dispatch deadline [#the-dispatch-deadline]

`dispatch_deadline_at` is ten minutes after creation. It bounds only the *queueing* phase: the time
we have to find an upstream with free capacity and hand the job over. Generation itself is not
bounded by it — a job that reaches an upstream at the 599th second is fine, and long clips routinely
run well past the deadline in `running`.

If the deadline passes while the job is still `queued`, it terminates as `queue_expired` and **every
reserved credit is returned**. That is the only timeout you can see from the outside.

## Polling [#polling]

There is no customer-facing webhook. Poll `GET /v1/jobs/{job_id}`:

```bash
curl https://hypit.ai/v1/jobs/job_2f81c0a4d3b57e9016ab24cf \
  -H "Authorization: Bearer $HYPIT_API_KEY"
```

Reasonable cadence: first check after \~5 seconds, then every 3–10 seconds, backing off for long
video. Images typically settle in tens of seconds, video in minutes.

```python
import os, time, httpx

BASE = os.environ.get("HYPIT_BASE_URL", "https://hypit.ai/v1")
AUTH = {"Authorization": f"Bearer {os.environ['HYPIT_API_KEY']}"}
TERMINAL = {"succeeded", "failed", "queue_expired"}

def wait_for(job_id: str, timeout: float = 900.0) -> dict:
    deadline = time.monotonic() + timeout
    delay = 3.0
    while True:
        r = httpx.get(f"{BASE}/jobs/{job_id}", headers=AUTH, timeout=30)
        r.raise_for_status()
        job = r.json()
        if job["status"] in TERMINAL:
            return job
        if time.monotonic() > deadline:
            raise TimeoutError(f"{job_id} still {job['status']}")
        time.sleep(delay)
        delay = min(delay * 1.4, 15.0)
```

```js
const BASE = process.env.HYPIT_BASE_URL ?? "https://hypit.ai/v1";
const AUTH = { Authorization: `Bearer ${process.env.HYPIT_API_KEY}` };
const TERMINAL = new Set(["succeeded", "failed", "queue_expired"]);

export async function waitFor(jobId, timeoutMs = 900_000) {
  const deadline = Date.now() + timeoutMs;
  let delay = 3_000;
  for (;;) {
    const r = await fetch(`${BASE}/jobs/${jobId}`, { headers: AUTH });
    if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
    const job = await r.json();
    if (TERMINAL.has(job.status)) return job;
    if (Date.now() > deadline) throw new Error(`${jobId} still ${job.status}`);
    await new Promise((res) => setTimeout(res, delay));
    delay = Math.min(delay * 1.4, 15_000);
  }
}
```

## Collecting the result [#collecting-the-result]

A `succeeded` job's artifacts are fetched from the assets route. Nothing is public: we sign a
short-lived URL for you, on demand, against the exact stored object.

```bash
curl https://hypit.ai/v1/jobs/job_2f81c0a4d3b57e9016ab24cf/assets \
  -H "Authorization: Bearer $HYPIT_API_KEY"
```

```json
{
  "items": [
    {
      "id": "ast_9c3d1e7a44b0",
      "job_id": "job_2f81c0a4d3b57e9016ab24cf",
      "ordinal": 0,
      "kind": "video",
      "storage": "s3",
      "mime_type": "video/mp4",
      "bytes": 4718592,
      "width": 1920,
      "height": 1080,
      "seconds": 6,
      "url": "https://…?X-Amz-Signature=…",
      "expires_at": 1787825489,
      "created_at": 1787825189
    }
  ]
}
```

| Field                        | Notes                                                                   |
| ---------------------------- | ----------------------------------------------------------------------- |
| `id`                         | stable asset id                                                         |
| `ordinal`                    | position within the job; `n: 3` on an image job yields ordinals 0, 1, 2 |
| `kind`                       | `image`, `video`, `audio` or `thumbnail`                                |
| `storage`                    | always `s3`                                                             |
| `width`, `height`, `seconds` | present when known for the medium                                       |
| `url`                        | a freshly signed link, **valid for 15 minutes**                         |
| `expires_at`                 | Unix seconds when `url` stops working                                   |

The response carries `Cache-Control: private, no-store`. Do not persist a signed `url` — persist the
`job_id` and re-sign by calling this route again. Signed links are minted at read time from the
stored bucket and object key, so calling it again always works while the artifact is retained.

Successful outputs are retained for **30 days** from the moment the job reaches its terminal state.
Download or copy anything you need to keep.

```python
job = wait_for(job_id)
if job["status"] != "succeeded":
    raise RuntimeError(f"{job['status']}: {job.get('error_code')} {job.get('error')}")

assets = httpx.get(f"{BASE}/jobs/{job_id}/assets", headers=AUTH, timeout=30).json()["items"]
for a in assets:
    with httpx.stream("GET", a["url"], timeout=None, follow_redirects=True) as src:
        src.raise_for_status()
        with open(f"out-{a['ordinal']}.{a['mime_type'].split('/')[-1]}", "wb") as f:
            for chunk in src.iter_bytes():
                f.write(chunk)
```

### Modality aliases [#modality-aliases]

For readability, three pairs of aliases point at exactly the same handlers:

| Alias                                  | Same as                 |
| -------------------------------------- | ----------------------- |
| `GET /v1/videos/{job_id}`              | `GET /v1/jobs/{job_id}` |
| `GET /v1/audio/music/{job_id}`         | `GET /v1/jobs/{job_id}` |
| `GET /v1/audio/music/{job_id}/content` | the same                |

The audio `/content` route is a convenience for `curl -L`. Video downloads use the
owner-scoped assets API, which returns fresh signed URLs and answers `409`
`job_asset_not_ready` when the job has no deliverable asset yet.

```bash
curl https://hypit.ai/v1/jobs/job_2f81c0a4d3b57e9016ab24cf/assets \
  -H "Authorization: Bearer $HYPIT_API_KEY"
```

## Idempotency [#idempotency]

Send an `Idempotency-Key` header on any create call and a retry becomes safe:

```bash
curl https://hypit.ai/v1/videos \
  -H "Authorization: Bearer $HYPIT_API_KEY" \
  -H "Idempotency-Key: order-4471-clip-1" \
  -H "Content-Type: application/json" \
  -d '{"model": "bytedance/seedance-2", "prompt": "a paper boat in a gutter", "seconds": 5}'
```

The key is scoped to your account and credential, and is at most 512 bytes.

| Situation                               | Result                                                     |
| --------------------------------------- | ---------------------------------------------------------- |
| same key, same request                  | the **original** job is returned; nothing is charged twice |
| same key, different request             | `409` `idempotency_conflict`                               |
| same key, first request still preparing | `425` `admission_in_progress` — retry shortly              |
| no key                                  | every call creates a new job, even for identical bodies    |

Replay is decided from a durable fingerprint of the request, so a model that is later withdrawn does
not change the answer you get back for a key you already used.

## Concurrency limits [#concurrency-limits]

One account may hold at most **64** `queued` or `running` jobs at a time, counted across every API
key, OAuth grant and console session it owns. Exceeding it is a `429`:

```json
{
  "error": {
    "message": "too many queued or running media Jobs for this account; retry after one finishes",
    "type": "rate_limit_error",
    "code": "media_open_job_capacity",
    "param": null
  }
}
```

The response carries `Retry-After: 30`. This is separate from the per-key request rate limit
described in [Errors and limits](/api-reference/errors).

## What happens to your credits [#what-happens-to-your-credits]

Submitting a job **reserves** credits from an estimate. When the job reaches a terminal state:

* `succeeded` — the reservation is settled against the real usage. Under the estimate, the
  difference is refunded; over it, the difference is charged.
* `failed` — everything that was actually taken is refunded, in full, to the same grants it came
  from. The job's cost columns go to zero.
* `queue_expired` — the same full refund.

That includes the case where an upstream generated something but we could not deliver it: the job
fails, and you are not charged for an artifact you cannot download. See
[Credits and billing](/api-reference/billing) for the mechanism.

## Job read errors [#job-read-errors]

| Status | `code`                    | Meaning                                                                                            |
| ------ | ------------------------- | -------------------------------------------------------------------------------------------------- |
| `404`  | `job_not_found`           | unknown id — **or** a job belonging to another account. The two are deliberately indistinguishable |
| `409`  | `job_asset_not_ready`     | on `/content` only: no deliverable asset yet                                                       |
| `503`  | `job_service_unavailable` | storage or signing is briefly unavailable; retry                                                   |