Async Media

Generate images, videos, and audio without blocking your request thread

TheRouter.ai supports async media generation for three modalities: image, video, and audio. Instead of waiting inline (which can take seconds to minutes), you submit a job, receive a job ID immediately (202 Accepted), and poll GET /v1/jobs/:id for the result when it is ready.

queued ──▶ in_progress ──▶ succeeded   (retrieve via content_url / unsigned_urls)
   │            │
   │            └──▶ failed            (balance refunded)
   │
   └──▶ expired   (only from queued/in_progress — never after succeeded)

queued ──▶ cancelled   (DELETE /v1/jobs/:id, queued-only — balance refunded)

Choosing sync vs. async

POST /v1/jobs is inherently async — it has no synchronous mode to opt out of. The OpenAI-shape image (images.generate / images.edit) and text-to-speech (audio.speech.create) endpoints are different: they default to synchronous and only switch to the async job flow when the request adds ?async=true. Video generation (POST /v1/videos) has no sync mode either — it always returns a job.

Submitting an image job

Add ?async=true to your image generation request to get a 202 Accepted with a job ID instead of waiting for the result.

bash
curl -X POST "https://api.therouter.ai/v1/images/generations?async=true" \
  -H "Authorization: Bearer $THEROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-image-1",
    "prompt": "a serene mountain lake at sunset, photorealistic"
  }'

The 202 response always includes id, status: "queued", and polling_url. The exact remaining fields vary slightly by which endpoint you submitted through — the OpenAI-shape image/TTS endpoints include created_at and expires_at; POST /v1/jobs also includes content_url up front. This example, submitted via POST /v1/images/generations?async=true, returns:

json
{
  "id": "img_01J9Z7QP8BT4EWHF6V3KDMSR99",
  "object": "image_job",
  "status": "queued",
  "model": "openai/gpt-image-1",
  "created_at": 1778637249,
  "polling_url": "https://api.therouter.ai/v1/jobs/img_01J9Z7QP8BT4EWHF6V3KDMSR99",
  "expires_at": 1779242049
}

Polling for completion

Poll GET /v1/jobs/:id (the polling_url value from the submission response) until the job reaches a terminal status. Stop polling on any of succeeded, failed, cancelled, or expired — polling past a terminal status returns the same terminal body again, it does not change.

bash
curl "https://api.therouter.ai/v1/jobs/img_01J9Z7QP8BT4EWHF6V3KDMSR99" \
  -H "Authorization: Bearer $THEROUTER_API_KEY"

While the job is still working, the response looks like this — note unsigned_urls is null and usage.cost_credits is 0 until settlement:

json
{
  "id": "img_01J9Z7QP8BT4EWHF6V3KDMSR99",
  "object": "image_job",
  "status": "in_progress",
  "model": "openai/gpt-image-1",
  "created_at": 1778637249,
  "completed_at": null,
  "expires_at": 1779242049,
  "polling_url": "https://api.therouter.ai/v1/jobs/img_01J9Z7QP8BT4EWHF6V3KDMSR99",
  "content_url": "https://api.therouter.ai/v1/jobs/img_01J9Z7QP8BT4EWHF6V3KDMSR99/content",
  "unsigned_urls": null,
  "image_count": 1,
  "usage": {
    "prompt_tokens": 0,
    "completion_tokens": 0,
    "cost_credits": 0
  },
  "error": null
}

A succeeded image job has no result or data wrapper — read unsigned_urls (an array of presigned S3 URLs, one per generated image) or the modality-common content_url redirect:

json
{
  "id": "img_01J9Z7QP8BT4EWHF6V3KDMSR99",
  "object": "image_job",
  "status": "succeeded",
  "model": "openai/gpt-image-1",
  "created_at": 1778637249,
  "completed_at": 1778637444,
  "expires_at": 1779242049,
  "polling_url": "https://api.therouter.ai/v1/jobs/img_01J9Z7QP8BT4EWHF6V3KDMSR99",
  "content_url": "https://api.therouter.ai/v1/jobs/img_01J9Z7QP8BT4EWHF6V3KDMSR99/content",
  "unsigned_urls": [
    "https://therouter-media-prod.s3.us-east-2.amazonaws.com/...&X-Amz-Signature=..."
  ],
  "image_count": 1,
  "usage": {
    "prompt_tokens": 0,
    "completion_tokens": 0,
    "cost_credits": 9
  },
  "error": null
}

Video jobs carry video_url instead of unsigned_urls/image_count — but video_url can be null even on a succeeded job (new jobs retrieve exclusively through content_url). Audio jobs carry neither field; use content_url for every modality. expires_at stays on the response after success (it is never cleared), but it plays no part in retrieving a succeeded job's artifact — see the job-status table below for what actually governs that.

A failed job carries a non-null error. Its artifact fields (unsigned_urls/content_url/image_count) are still present on the response — one shared JobStatusResponse shape, not a separate one per status — but there is nothing usable behind them: unsigned_urls stays null, and content_url for a failed job returns 410, not an artifact:

json
{
  "id": "img_01J9Z7QP8BT4EWHF6V3KDMSR99",
  "object": "image_job",
  "status": "failed",
  "model": "openai/gpt-image-1",
  "created_at": 1778637249,
  "completed_at": 1778637310,
  "expires_at": 1779242049,
  "polling_url": "https://api.therouter.ai/v1/jobs/img_01J9Z7QP8BT4EWHF6V3KDMSR99",
  "content_url": "https://api.therouter.ai/v1/jobs/img_01J9Z7QP8BT4EWHF6V3KDMSR99/content",
  "unsigned_urls": null,
  "image_count": 1,
  "usage": {
    "prompt_tokens": 0,
    "completion_tokens": 0,
    "cost_credits": 0
  },
  "error": {
    "message": "OpenRouter response missing image data",
    "type": "upstream_error"
  }
}

cancelled and expired responses carry the same usage/error fields as every other terminal status — there is one JobStatusResponse shape, not a separate schema per status.

Retrieving the artifact

There are two ways to fetch a succeeded job's output:

  • Directly: GET the URL(s) in unsigned_urls (image jobs) — already presigned, no Authorization header needed. Good for browser display, client-side caching, or CDN origin pulls. These are the URLs safe to hand to something else — anyone holding one can fetch the artifact directly, with no credential of yours required or exposed.
  • Through the gateway: GET /v1/jobs/:id/content — an authenticated endpoint (same Authorization: Bearer key as every other call) that checks tenant ownership, then issues a fresh, freshly-signed redirect — every call gets a new URL. Call this from your own backend, not as a link you hand to an end user: reaching it requires your API key, so sharing the URL means sharing the request that carries your key, not a scoped, shareable download link. There is no separate per-download audit trail beyond the gateway's normal request logging.

GET /v1/jobs/:id/content's status-dependent behavior:

Job statusResponse
queued / in_progress202 { id, status }
succeeded302 redirect to a freshly-signed URL
failed / cancelled / expired410 { error: { message, type } }

The artifact is never returned inline in this response's body — it is always a redirect target for a succeeded job.

Job status reference

StatusMeaning
queuedJob accepted, waiting for a worker slot
in_progressWorker is actively processing the request
succeededComplete — artifact retrievable via content_url for up to 30 days; each fetch's signed URL is valid for 7 days
failedProcessing error — balance refunded automatically
cancelledCancelled via DELETE while still queued — balance refunded automatically
expiredNever reached succeeded before its submission deadline — only reachable from queued/in_progress
Three different durations govern a succeeded job

Per-fetch signature validity (7 days) — every GET /v1/jobs/:id/content call while the job is succeeded mints a fresh signed URL with a fresh 7-day window (PRESIGNED_TTL_SECONDS). It is not a one-time countdown from job completion.

Artifact retention (30 days) — the underlying S3 object is deleted 30 days after it was written, by the bucket's own lifecycle rule. This is the real answer to "how long can I keep calling content_url and get my artifact back" — distinct from, and roughly 4x longer than, the 7-day per-fetch signature window above. GET /v1/jobs/:id/content does not check whether the object still exists: past 30 days it still returns 302 to a validly-signed URL, but the target no longer resolves. There is no distinguishable error for this case.

Job expires_at (7 days from creation) — a submission-time deadline that only ever affects a queued/in_progress job. It governs when an unfinished job is swept to expired; it plays no part in retrieving an already-succeeded job's artifact, and a job that has already succeeded can never later become expired.

Video generation

Video is always async — there is no sync option. Submit to POST /v1/videos and poll using the returned vid_* job ID. For openai/sora-2, the vendor-documented dimension pair is seconds + size (not duration/aspect_ratio):

bash
curl -X POST https://api.therouter.ai/v1/videos \
  -H "Authorization: Bearer $THEROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/sora-2",
    "prompt": "a timelapse of clouds over a mountain range",
    "seconds": 8,
    "size": "1280x720"
  }'

Audio TTS (async)

For large TTS workloads or when you need non-blocking behavior, add ?async=true to the speech endpoint. Small TTS requests default to sync and are usually faster that way.

bash
curl -X POST "https://api.therouter.ai/v1/audio/speech?async=true" \
  -H "Authorization: Bearer $THEROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/tts-1",
    "input": "The quick brown fox jumps over the lazy dog.",
    "voice": "alloy"
  }'

Cancelling a job

DELETE /v1/jobs/:id only cancels a job that is still queued — the gateway applies an atomic UPDATE ... WHERE status = 'queued' guard, so a job already in_progress or terminal returns its current status unchanged instead of an error. A successful cancellation refunds the full reserved amount to your balance. This runnable example submits its own job, then cancels it immediately, and treats either outcome as a pass — the window between submit and cancel cannot guarantee the job is still queued when the DELETE lands:

bash
#!/usr/bin/env bash
set -euo pipefail

# 1) submit a job to cancel
RESP=$(curl -sS -X POST "https://api.therouter.ai/v1/jobs" \
  -H "Authorization: Bearer $THEROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-image-1",
    "prompt": "a single blue ceramic teacup on a wooden table"
  }')
JOB_ID=$(echo "$RESP" | python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
echo "submitted: $JOB_ID" >&2

# 2) cancel it immediately
CANCEL=$(curl -sS -X DELETE "https://api.therouter.ai/v1/jobs/$JOB_ID" \
  -H "Authorization: Bearer $THEROUTER_API_KEY")
echo "$CANCEL" >&2

STATUS=$(echo "$CANCEL" | python3 -c "import sys,json;print(json.load(sys.stdin)['status'])")
case "$STATUS" in
  cancelled)
    echo "cancelled — full reservation refunded" >&2
    printf '{"job_id":"%s","cancel_outcome":"cancelled"}\n' "$JOB_ID"
    exit 0
    ;;
  in_progress|succeeded|failed|expired)
    # Documented race: the job had already left 'queued' by the time DELETE
    # landed, so the atomic UPDATE ... WHERE status='queued' guard did not
    # match. Not a failure of this example — both outcomes are contractual.
    echo "not cancelled — job already left 'queued': status=$STATUS" >&2
    printf '{"job_id":"%s","cancel_outcome":"already_left_queued","status":"%s"}\n' "$JOB_ID" "$STATUS"
    exit 0
    ;;
  queued)
    # The DELETE's own atomic condition IS status='queued' — a response that
    # is STILL 'queued' is not the documented race (the race is the job
    # having already LEFT queued); it means cancellation genuinely did not
    # take effect, and the job is still running and still holding its
    # reserved credits. Not a pass.
    echo "cancel did not take effect — job is still queued: $CANCEL" >&2
    exit 1
    ;;
  *)
    echo "unexpected status: $STATUS" >&2
    exit 1
    ;;
esac

Discovering available models

GET /v1/images/models, GET /v1/videos/models, and GET /v1/audio/models each list the is_listed models that support that modality. Each entry's media field is the model's declared capability descriptor — its kinds (which media operations it supports, e.g. image_generation, image_edit) and domains (the request-parameter values each kind actually accepts: sizes, qualities, durations, and so on) — so a client can build a valid request for that model without guessing. endpoints lists the modality-specific submission endpoint(s) that media.kinds maps to, and each entry also carries a closed subset of its pricing.

bash
curl "https://api.therouter.ai/v1/images/models" \
  -H "Authorization: Bearer $THEROUTER_API_KEY"

Preflight pricing

POST /v1/media/estimate returns a price quote before you submit, for image and video models. Audio and OCR models are not supported by this endpoint — they return AUDIO_ESTIMATE_UNSUPPORTED and OCR_ESTIMATE_UNSUPPORTED respectively, by name. A 503 ESTIMATE_UNAVAILABLE means a platform-side pricing defect, never a guessed price — the endpoint never falls back to a floor or an approximation.

bash
curl -X POST "https://api.therouter.ai/v1/media/estimate" \
  -H "Authorization: Bearer $THEROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-image-1",
    "quality": "medium",
    "n": 1
  }'
json
{
  "estimated_cost_credits": 9,
  "tier_matched": "flat_fallback",
  "currency": "credits"
}

Billing and balance refunds

Your balance is reserved at submission time. If the job fails or is cancelled, the full reserved amount is automatically returned to your balance. You are only charged for a succeeded job, at its actual settled cost. expired is reachable only from queued/in_progress, never from succeeded — an expired job is neither refunded nor settled as a charge by any of those three paths; it is its own, unresolved billing state.

Polling best practices

  • First poll: ~5 seconds after submission.
  • Then every 5–10 seconds, widening to 15–30 seconds after the first minute — exponential backoff with a cap works well.
  • Stop on any terminal status (succeeded/failed/cancelled/expired) — never poll indefinitely.
  • Only retry a failed job when error.message suggests a transient condition (network, upstream 5xx). A failed job already refunded its full reservation, so retrying does not spend balance it did not already get back — but retrying a content-policy or prompt-parsing failure wastes time and a concurrency slot on an attempt that will fail the same way again, since the request itself, not a transient condition, is what failed.

A complete, runnable poll loop — submits its own job, then polls it, exiting non-zero if it does not reach a terminal status within its own stated ceiling (the ceiling is a safety bound, not an alternate success path):

bash
#!/usr/bin/env bash
set -euo pipefail

# 1) submit a job to poll
RESP=$(curl -sS -X POST "https://api.therouter.ai/v1/jobs" \
  -H "Authorization: Bearer $THEROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-image-1",
    "prompt": "a single red maple leaf on a white background"
  }')
JOB_ID=$(echo "$RESP" | python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
echo "submitted: $JOB_ID" >&2

# 2) poll until a terminal status, or exit non-zero at the ceiling — the
#    ceiling is a safety bound against an unbounded loop, never an
#    alternate success path.
MAX_POLLS=30
for i in $(seq 1 "$MAX_POLLS"); do
  JOB=$(curl -sS "https://api.therouter.ai/v1/jobs/$JOB_ID" \
    -H "Authorization: Bearer $THEROUTER_API_KEY")
  STATUS=$(echo "$JOB" | python3 -c "import sys,json;print(json.load(sys.stdin)['status'])")
  echo "[$i/$MAX_POLLS] status=$STATUS" >&2
  case "$STATUS" in
    succeeded|failed|cancelled|expired)
      echo "$JOB" >&2
      # Reaching ANY terminal status — including 'failed', whose own body
      # legitimately carries a populated `error` field — is this example's
      # own documented success criterion. stdout carries only this minimal
      # envelope, never the raw job body, so a generic body-verdict parser
      # judges the SCRIPT's outcome rather than the job's own terminal
      # `error` field.
      printf '{"job_id":"%s","terminal_status":"%s"}\n' "$JOB_ID" "$STATUS"
      exit 0
      ;;
  esac
  sleep 5
done

echo "reached the poll ceiling without a terminal status — failed run, not a pass" >&2
exit 1
typescript
async function pollJob(id: string, apiKey: string) {
  let delay = 2_000;
  const maxDelay = 30_000;
  const maxWait = 10 * 60 * 1_000; // 10 minutes
  const deadline = Date.now() + maxWait;

  while (Date.now() < deadline) {
    const res = await fetch(`https://api.therouter.ai/v1/jobs/${id}`, {
      headers: { Authorization: `Bearer ${apiKey}` },
    });

    if (!res.ok) throw new Error(`Poll failed: ${res.status}`);
    const job = await res.json();

    if (['succeeded', 'failed', 'cancelled', 'expired'].includes(job.status)) {
      if (job.status !== 'succeeded') {
        throw new Error(job.error?.message ?? `Job ${job.status}`);
      }
      // job.unsigned_urls (image) / job.video_url (video, may be null —
      // use job.content_url) / job.content_url (every modality)
      return job;
    }

    await new Promise((r) => setTimeout(r, delay));
    delay = Math.min(delay * 1.5, maxDelay);
  }

  throw new Error('Job polling timeout');
}

Error quick reference

SymptomHTTPCause
Submit fails immediately402insufficient_credits — top up your balance
Submit fails immediately503balance_verification_unavailable — reservation system temporarily down; client should back off and retry
Submit fails immediately429Concurrency or storage-quota limit hit — wait for an in-flight job to finish, or upgrade tier
Terminal status is failed200 (on the poll)error.message names the upstream/parsing failure — retry only if it looks transient
GET .../content returns 410410Job is failed, cancelled, or expired — there is nothing to retrieve
GET .../content 302s but the target 403s/404s302 → target errorPast the 30-day artifact retention window — the object was deleted; there is no distinguishable API-level error for this

Python example (end-to-end)

python
import time
import httpx

API_KEY = "your-api-key"
BASE_URL = "https://api.therouter.ai"
HEADERS = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}

def submit_image_job(prompt: str) -> str:
    r = httpx.post(
        f"{BASE_URL}/v1/images/generations?async=true",
        headers=HEADERS,
        json={"model": "openai/gpt-image-2", "prompt": prompt},
    )
    r.raise_for_status()
    return r.json()["id"]

def poll_job(job_id: str, timeout: int = 300) -> dict:
    deadline = time.time() + timeout
    delay = 2.0
    while time.time() < deadline:
        r = httpx.get(f"{BASE_URL}/v1/jobs/{job_id}", headers=HEADERS)
        r.raise_for_status()
        job = r.json()
        if job["status"] == "succeeded":
            return job
        if job["status"] in {"failed", "cancelled", "expired"}:
            # error is non-null only for "failed" -- "cancelled"/"expired" jobs
            # carry error: null (see the job-status examples above), and
            # job.get("error", {}) only substitutes {} when the KEY is
            # missing, not when it is present with value None. Without the
            # "or {}", None.get("message") raises AttributeError instead of
            # the intended RuntimeError for those two terminal statuses.
            error_message = (job.get("error") or {}).get("message")
            raise RuntimeError(f"Job {job['status']}: {error_message}")
        time.sleep(delay)
        delay = min(delay * 1.5, 30)
    raise TimeoutError("Job timed out")

# Usage
job_id = submit_image_job("a cat wearing sunglasses on a beach")
job = poll_job(job_id)
image_url = job["unsigned_urls"][0]
print(f"Image ready: {image_url}")
Help & contact