Product · 2026-05-13

Async Image Generation & Edit, On TheRouter

Generate and edit images with GPT-Image, DALL·E, Imagen, and Grok-Image through a single OpenAI-compatible API. Submit a job, poll for status, download from S3. No 30-second proxy timeouts. No re-architecting your frontend.

Why async, why now

Frontier image models — openai/gpt-image-2, openai/gpt-image-1.5, google/imagen-4, xai/grok-image-1 — routinely take 30 to 180 seconds per image. That is past every reverse proxy you have configured. CloudFront caps you at 60 seconds. Cloudflare's default free-tier limit is 100 seconds. AWS ALB idle timeout is 60. Most browser fetch defaults to no timeout but real users abandon the tab.

For text models, this is fine — even the slowest reasoning model returns the first token in well under a minute. For image generation, you cannot expect a single long-lived HTTP request to land reliably. You need a job system.

We shipped one. It is OpenAI-compatible (no custom SDK), works across every image model on the router, and treats success as "the bytes are in S3 and you can pre-sign a URL." This post is the launch announcement and the complete tutorial.

What you get

Quickstart

1) Submit

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-2",
    "prompt": "a minimal red apple on a white marble table, soft natural light",
    "size": "1024x1024",
    "quality": "high"
  }'

Response (HTTP 202):

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

Persist job.id as soon as you get this back. If your client process dies, you can resume polling from anywhere — the job is durable on the server side.

2) Poll

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

Once the worker has finished, you get:

{
  "id": "img_uxRZK4KwxkWaaHXRfwP0fWUDST",
  "object": "image_job",
  "status": "succeeded",
  "model": "openai/gpt-image-2",
  "created_at": 1778637249,
  "completed_at": 1778637444,
  "polling_url": "https://api.therouter.ai/v1/jobs/...",
  "content_url": "https://api.therouter.ai/v1/jobs/.../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
}

3) Download

unsigned_urls[0] is a pre-signed S3 URL valid for 7 days. No Authorization header needed.

curl -L -o apple.png "<unsigned_urls[0]>"

Or hit GET /v1/jobs/:id/content with a Bearer token for a fresh signed redirect on every request — useful if you need an audit trail.

Full client tutorials

Python

import os, time, requests

API = "https://api.therouter.ai"
KEY = os.environ["THEROUTER_API_KEY"]
H = {"Authorization": f"Bearer {KEY}"}

# 1) submit
r = requests.post(
    f"{API}/v1/images/generations?async=true",
    headers={**H, "Content-Type": "application/json"},
    json={
        "model": "openai/gpt-image-2",
        "prompt": "a minimal red apple on a white marble table",
    },
    timeout=30,
)
r.raise_for_status()
job = r.json()
print("submitted:", job["id"])

# 2) poll
deadline = time.time() + 300
while time.time() < deadline:
    s = requests.get(job["polling_url"], headers=H, timeout=15).json()
    print("status:", s["status"])
    if s["status"] == "succeeded":
        with open("out.png", "wb") as f:
            f.write(requests.get(s["unsigned_urls"][0], timeout=60).content)
        print("saved out.png")
        break
    if s["status"] in ("failed", "cancelled", "expired"):
        raise SystemExit(s.get("error") or s["status"])
    time.sleep(5)
else:
    raise SystemExit("polling timeout")

TypeScript (Bun / Node 20+)

const API = "https://api.therouter.ai";
const KEY = process.env.THEROUTER_API_KEY!;
const H = { Authorization: `Bearer ${KEY}` };

const submit = await fetch(`${API}/v1/images/generations?async=true`, {
  method: "POST",
  headers: { ...H, "Content-Type": "application/json" },
  body: JSON.stringify({
    model: "openai/gpt-image-2",
    prompt: "a minimal red apple on a white marble table",
  }),
});
if (!submit.ok) throw new Error(`submit failed: ${submit.status}`);
const job = await submit.json();
console.log("submitted:", job.id);

const deadline = Date.now() + 300_000;
while (Date.now() < deadline) {
  const s = await (await fetch(job.polling_url, { headers: H })).json();
  console.log("status:", s.status);
  if (s.status === "succeeded") {
    const buf = await (await fetch(s.unsigned_urls[0])).arrayBuffer();
    await Bun.write("out.png", buf);
    break;
  }
  if (["failed", "cancelled", "expired"].includes(s.status)) {
    throw new Error(s.error?.message ?? s.status);
  }
  await new Promise((r) => setTimeout(r, 5_000));
}

Image edit — same shape

Edits use multipart, but the rest is identical: ?async=true, 202 with a polling_url, poll, download.

curl -X POST "https://api.therouter.ai/v1/images/edits?async=true" \
  -H "Authorization: Bearer $THEROUTER_API_KEY" \
  -F "model=openai/gpt-image-2" \
  -F "prompt=Turn this scene into a watercolor painting" \
  -F "size=1024x1024" \
  -F "image=@input.png"

The same GET /v1/jobs/:id polling and unsigned_urls[0] / content_url download paths apply.

Models you can use today

Every one of them works with the exact same submit → poll → download pattern. Switch models by changing one string. We do not surface upstream provider names in the alias — the response model field always echoes the alias you sent.

Polling, retries, and what to expect

What changed under the hood

Most callers will not need to know this, but for the curious: the gateway now writes image jobs to MySQL with a queued row, enqueues a BullMQ task on Redis, and a co-located worker pool inside each gateway pod drains it. The worker dispatches to the per-provider service (one container per upstream), parses the response, uploads bytes to S3, and flips the row to succeeded with the upload key. The S3 prefix is keyed by tenant so artifacts are isolated by design. Credit reservation / consumption / refund is one atomic transaction per state flip — there is no window where we have your money but not the bytes.

We also retrofitted the same shape to audio and video: aud_-prefixed jobs and vid_-prefixed jobs share the same /v1/jobs/:id polling endpoint, the same cancellation semantics, and the same billing invariants. One mental model, three modalities.

The full guide

For the complete reference — every parameter, every status, error cheat sheet, best practices — see the dedicated guide:

→ Image Generation & Edit (Async) Guide

Try it

Grab an API key on dashboard.therouter.ai, pick any image model from the catalog, and paste one of the snippets above. New accounts get free credits on signup — enough to do a few full-quality test generations end-to-end.

Feedback and bug reports go to hello@therouter.ai.

Customer Support