Async images and video: respond-async, polling and webhooks
A video takes a while, and so can an image. Hyperouter gives you three ways to wait for one without a long-held connection: send Prefer: respond-async for an image, poll the record, or let a webhook tell you when the job ends. This guide covers each.
Which models answer how
Each model in GET /v1/models lists response_modes:
sync: one held answer.async: sendPrefer: respond-async, get202, and poll.stream:"stream": true.
A model that lists only async needs the Prefer header; without it the answer is 400 with code async_required. Video is always asynchronous through the Videos API, with or without the header.
Images: Prefer: respond-async
On a model that lists async, add the header. The model id below is an example: check response_modes for yours. It is RFC 7240, so it may repeat and may carry other preferences, such as Prefer: wait=10, respond-async.
curl -i https://api.hyperouter.app/v1/images/generations \
-H "Authorization: Bearer $HYPEROUTER_API_KEY" \
-H "Content-Type: application/json" \
-H "Prefer: respond-async" \
-d '{"model": "x-ai/grok-imagine-image", "prompt": "A lighthouse at dusk", "n": 1}'
You get 202 at once, with the response header Preference-Applied: respond-async:
{ "id": "0194f2c4-7a1e-7b3c-9d02-5f6a1c0e8b21", "status": "queued" }
A model that answers sync and is sent without the header waits up to 90 seconds for the image. If the image is not ready by then, the answer is 503 with code timeout, the header x-should-retry: false, and the job's id as error.generation_id. The job goes on, and you are billed only if it succeeds. Poll that id as below.
Poll an image
GET /v1/generations/{id} returns the record: status is queued, submitting, running, done or failed. For an image, outputs holds [{"url", "expires_at"}], signed links valid for 24 hours. Results are available for 30 days.
import os
import time
import requests
headers = {"Authorization": f"Bearer {os.environ['HYPEROUTER_API_KEY']}"}
def wait_for_image(generation_id: str, every: float = 3.0, limit: float = 600.0) -> dict:
deadline = time.monotonic() + limit
while time.monotonic() < deadline:
r = requests.get(
f"https://api.hyperouter.app/v1/generations/{generation_id}",
headers=headers,
timeout=30,
)
r.raise_for_status()
rec = r.json()["data"]
if rec["status"] in ("done", "failed"):
return rec
time.sleep(every)
raise TimeoutError(generation_id)
On failed, the record's error holds a code and a message. A failed job costs nothing. Reading a result does not count toward your rate limits.
Videos: always a job
A call to POST /v1/videos answers 200 at once with the Video object in status queued. Prefer changes nothing here.
curl https://api.hyperouter.app/v1/videos \
-H "Authorization: Bearer $HYPEROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "x-ai/grok-imagine-video", "prompt": "A lighthouse at dusk", "seconds": 8}'
Poll GET /v1/videos/{id} until status is completed (it can also be queued, in_progress or failed). progress runs from 0 to 100. Then download the file:
async function waitForVideo(id: string): Promise<void> {
const headers = { Authorization: `Bearer ${process.env.HYPEROUTER_API_KEY}` };
for (;;) {
const res = await fetch(`https://api.hyperouter.app/v1/videos/${id}`, { headers });
const video = await res.json();
if (video.status === "completed") break;
if (video.status === "failed") throw new Error(video.error?.code ?? "failed");
await new Promise((r) => setTimeout(r, 5000));
}
const file = await fetch(`https://api.hyperouter.app/v1/videos/${id}/content`, { headers });
// file is video/mp4; ?variant=thumbnail returns the thumbnail
}
The video is kept until its expires_at, 30 days after creation. After that it answers 404 not_found, so download what you want to keep. The amount is held up front and released in full if the job fails.
Webhooks instead of polling
Add an endpoint in the dashboard and a request reaches it when a job ends:
| Event | Sent when |
|---|---|
video.completed, video.failed | A video job ends. |
image.completed, image.failed | An image job that you sent with Prefer: respond-async ends. |
The payload carries an id and nothing else of the result, so fetch the object with your API key: data.id is what GET /v1/videos/{id} and GET /v1/generations/{id} take. Each delivery is signed with the Standard Webhooks scheme, so the OpenAI SDKs verify it. Verify the raw body before you parse it:
const event = client.webhooks.unwrap(rawBody, req.headers, process.env.HYPEROUTER_WEBHOOK_SECRET);
if (event.type === "video.completed") {
const video = await client.videos.retrieve(event.data.id);
}
Answer with a 2xx within 10 seconds. Anything else is retried, up to eight more times, and webhook-id is the same on every retry, so use it to de-duplicate. The webhook reference has the endpoint rules, the headers and the full retry schedule.
An endpoint whose deliveries all fail for 3 days is disabled, and the dashboard says why. Until you enable it again, poll.
What next
- Retry safely with Idempotency-Key makes the create call itself safe to repeat.
- Images and Videos are the full references.