Batch product shots.
Many variations across seeds and aspect ratios, run concurrently, with a cost check up front and safe failure handling.

- Stack
- Python 3.10+, asyncio, httpx
- Needs
- API key that can read billing
- Uses
image.generate, estimate, idempotency keys, rate limits
A batch has to survive its own failures.
Twelve variations means twelve submits, and any of them can time out after the API has already accepted it. A plain retry then creates and bills a second job.
The script gives each variation an Idempotency-Key, writes it to a manifest before the first attempt, and reuses it on every retry. It also prices the whole batch up front and backs off on 429, so the batch can be re-run after a crash without paying twice.
How it fits together.
The recipe in order, from the first request to the saved file.
Build the grid
One prompt across three seeds and four aspect ratios (
1:1,4:5,9:16,16:9), each keyed inmanifest.json.Estimate the batch
One
POST /per aspect ratio, multiplied out and checked against your balance before anything is submitted.v1/ generation/ estimate Submit with a saved key
Each variation gets a UUID key, saved to the manifest before the
POST. Every retry of that variation reuses it.Run a few at once
A semaphore keeps jobs in flight at or below your tier's concurrency limit, which is 5 on tier 1.
Poll, save, record
Poll with backoff, save each PNG, and write state and credits back to the manifest. A second run skips what's done.
The core of it.
An excerpt from the recipe. The full version runs as-is.
async def submit(api, entry, manifest, stop) -> bool: if not entry.get("idempotency_key"): entry["idempotency_key"] = str(uuid.uuid4()) save(manifest) # persist the key before the first attempt body = {"prompt": PROMPT, "aspect_ratio": entry["aspect_ratio"], "resolution": RESOLUTION, "seed": entry["seed"]} headers = {"Idempotency-Key": entry["idempotency_key"]} for attempt in range(SUBMIT_TRIES): try: job = await call(api, "POST", "/generation/capabilities/image.generate", json=body, headers=headers) except httpx.TransportError: await asyncio.sleep(2 ** attempt) # the job may exist; the same key finds it continue except APIError as exc: if exc.status == 429 or exc.code == "idempotency.in_progress" or exc.status >= 500: await asyncio.sleep(exc.retry_after or 2 ** attempt) continue if exc.code == "billing.insufficient_credits": stop.set() # stop submitting the rest of the batch return False # other 4xx: nothing was created or billed entry.update(request_id=job["request_id"], state="queued") return True return False # still unknown: the next run retries with the same keyThe full script adds the manifest, the estimate step, polling, downloads and a one-time retry for jobs that end failed.
One product, many looks.
Real Z-Image Turbo outputs, each with the prompt and seed that made it: one prompt in three colours, then six looks.

sand beige

deep teal

coral

StudioStudio product photograph of a ceramic table lamp with a linen shade on a…

LifestyleThe same style ceramic table lamp glowing on a bedside table in a calm bedroom at…

Flat layFlat lay of summer travel essentials, sunglasses, passport, film camera, linen…

SplashA bottle of sparkling water frozen in a splash of water and lime slices, bright…

OutdoorA hiking backpack resting on a granite boulder above a misty valley at sunrise…

MacroMacro product photograph of a mechanical watch movement with gears and rubies…
What it does when things go wrong.
Every outcome of a submit or a job, and whether it costs anything.
| What happened | Billed? | What the script does |
|---|---|---|
Network error, timeout or 5xx on submit | Maybe | Retries with the same key. If the first attempt created a job, the retry returns it with Idempotent-Replayed: true. |
409 idempotency. | Held by the first attempt | Waits Retry-After (1 second) and retries with the same key. |
429 on submit | No | Waits Retry-After and retries with the same key. |
402 or 422 on submit | No | Records the error. On 402 it stops submitting the rest of the batch. |
Job ended failed | No | The hold is released. Retries once with a new key, then records the error. |
Job ended nsfw | No | Records it. Change the prompt if it keeps happening. |
| Script killed mid-batch | Only created jobs | The next run polls saved request ids and retries the rest with their saved keys. |
What a run costs.
Priced per output at the tier the recipe uses. The price is fixed when each job is submitted.
$0.048a run, or 0.96 credits
| Step | Model | Tier | Count | Credits | USD |
|---|---|---|---|---|---|
| Variations, 3 seeds × 4 aspect ratios | 1k | 12 × 0.08 | 0.96 | $0.048 | |
| Total | 0.96 | $0.048 |
Questions.
Why separate jobs instead of num_outputs?
You can use num_outputs: image.generate returns up to 4 images per job on z-image-turbo, priced per image. Separate jobs with separate seeds make each image reproducible on its own, which matters when you need to regenerate one specific shot.
What if the script crashes halfway?
Run it again within 24 hours. Saved request ids are polled rather than resubmitted, and unfinished submits retry with their saved idempotency keys, so nothing is billed twice. Idempotency keys expire after 24 hours.
How many jobs can run at once?
It depends on your rate-limit tier: 5 concurrent jobs and 60 submits per minute on tier 1, rising with lifetime credit purchases. A submit over the concurrency limit gets 429 and is not charged.