Permalink to Asynchronous jobsAsynchronous jobs
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.
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 minutesChat 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.
Permalink to the-job-envelopeThe job envelope
Every create endpoint and every status read returns the same object:
{
"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 |
Permalink to statesStates
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.
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.
Permalink to the-dispatch-deadlineThe 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.
Permalink to pollingPolling
There is no customer-facing webhook. Poll GET /v1/jobs/{job_id}:
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.
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)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);
}
}Permalink to collecting-the-resultCollecting 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.
curl https://hypit.ai/v1/jobs/job_2f81c0a4d3b57e9016ab24cf/assets \
-H "Authorization: Bearer $HYPIT_API_KEY"{
"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.
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)Permalink to modality-aliasesModality 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.
curl https://hypit.ai/v1/jobs/job_2f81c0a4d3b57e9016ab24cf/assets \
-H "Authorization: Bearer $HYPIT_API_KEY"Permalink to idempotencyIdempotency
Send an Idempotency-Key header on any create call and a retry becomes safe:
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.
Permalink to concurrency-limitsConcurrency 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:
{
"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.
Permalink to what-happens-to-your-creditsWhat 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 for the mechanism.
Permalink to job-read-errorsJob 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 |