Batch image generation API: queue, webhooks, safe retries
Send many image requests with a queue on your side, webhooks instead of polling, and an idempotency key for safe retries. Code in curl, Python, and Node.js.
Rendergrid editorsAbout the editors
In brief
Treat a batch as many separate requests. Keep a queue on your side and start a request only when a place frees up under your account’s limits: 120 requests a minute and 100 tasks queued or processing. Let webhooks announce results instead of polling, and send every request with an X-Idempotency-Key, so that a repeated request doesn’t buy a second image.
The three images below are one small batch made with Nano Banana 2.1, one attempt each: a pattern, not a speed or a volume.
How does batch generation work through the API?
One request makes one image, so a list of images is a list of requests, each with its own idempotency key. An accepted request gets a 202 response with id, status, task_id, and cost: the charge is taken then, and you follow the task by its id until it is completed or failed.
Our example batch has three jobs, each a plain white tube on a peach stage for a catalog, with a different backdrop. Each job is its own request to Nano Banana 2.1 at 1K, 1:1, and each block below shows its prompt.
Job 1: arched panel
A plain white tube with a smooth cap stands on a peach stage in front of a matching arched panel. Shoot straight on with soft light from the left and a shadow to the right. Center the complete tube at roughly half the frame height in a spacious monochrome set. No lettering, logos, people, or watermarks.
Nano Banana 2.1
nano-banana-2.1 · 1K · 1:1 · $0.02
Nano Banana 2.1, 1K, 1:1, one attempt. The tube stands upright and centered on a low round platform, in front of a tall arched panel that is also centered, all in one peach tone. Light comes from the left and the shadows fall to the right across the floor; their edges are fairly crisp, harder than the soft light the prompt asked for. The tube fills a little more than half of the frame’s height, close to the prompt’s “roughly half”, and the margins are wide. Its sealed top end is flat and its cap is smooth. There is no lettering, logo, or watermark, as the prompt asked, and the placement fits the prompt.
Job 2: shallow steps
A plain white tube with a smooth cap stands on a peach platform beside shallow steps of the same color. Use a front view, soft left lighting and a rightward shadow. The full tube sits centrally at about half the frame height; keep the steps to the side. No lettering, logos, people, or watermarks.
Nano Banana 2.1
nano-banana-2.1 · 1K · 1:1 · $0.02
Nano Banana 2.1, 1K, 1:1, one attempt. The tube stands upright and centered on a wide peach platform whose front face fills the bottom of the frame, with a short flight of steps at the right that the frame’s right edge cuts off. Light comes from the left and the shadow falls to the right, toward the steps. Three departures from the prompt: the tube is about three fifths of the frame’s height, taller than the prompt’s “about half”; its cap isn’t quite smooth, because a small rectangular notch shows on its front; and the shadow edges are fairly crisp, harder than the soft light the prompt asked for. The tube’s sealed top end looks ribbed. There is no lettering, logo, or watermark.
Job 3: upright disk
Place a plain white tube with a smooth cap on a peach stage. Behind it, offset a large upright disk in the same peach tone. Keep the front-facing camera, soft left light and rightward shadow. Show the complete centered tube at about half the frame height with clear margins. No lettering, logos, people, or watermarks.
Nano Banana 2.1
nano-banana-2.1 · 1K · 1:1 · $0.02
Nano Banana 2.1, 1K, 1:1, one attempt. The tube stands upright and centered on a low round platform, with a large round disk standing upright behind it, shifted to the right of the tube and in a similar peach tone; the disk’s left edge is faint against the wall. Light comes from the left and the shadows of the platform and the disk fall to the right. Three departures from the prompt: the tube is about three fifths of the frame’s height, taller than “about half”; its cap isn’t quite smooth, because a small rectangular notch shows on its front; and the shadow edges are fairly crisp, harder than the soft light the prompt asked for. The margins around the tube are clear. There is no lettering, logo, or watermark.
The three images share an idea, not a tube: the first cap is smooth and the other two are notched, and only the second tube has a ribbed top end. Each job is its own generation, so check the images you keep.
Keep one record per job
Before you send anything, write one row per job: your job ID, its idempotency key, the request body, and, once the 202 arrives, the task id. The pattern is the same for ten jobs or thousands: the limits below count the rate and the tasks in progress, not the length of your list.
How do you queue requests on your side?
An account can send 120 requests a minute and have 100 tasks queued or processing; beyond them the API responds with 429 and ERR_RATE_LIMIT or ERR_QUOTA_EXCEEDED. Both limits belong to the account, so extra keys don’t raise them. Keep a queue of your own and start new tasks as places free up, not all at once, with a cap below the account’s limit: other scripts use the same 100 places. For what each 429 means, read our guide to 429 rate limits.
The example runs jobs through a pool of workers, each following one task at a time, so the pool size is the number of tasks in flight. A pacer keeps the starts under the request rate, and a 429 is retried with the same key after Retry-After.
import os import threading import time from concurrent.futures import ThreadPoolExecutor import requests API = "https://api.rendergrid.io/ api/ public/ v1" HEADERS = {"Authorization": f"Bearer {os.environ['RENDERGRID_API_KEY']}"} MAX_IN_FLIGHT = 50 # your own cap, below the account's 100 queued or processing tasks SUBMIT_GAP = 0.6 # seconds between starts: at most 100 requests a minute, below 120 DEADLINE = 600 # seconds to follow one task; a setting of this example, not a model time pace_lock = threading.Lock() last_start = 0.0 def pace(): global last_start with pace_lock: pause = last_start + SUBMIT_GAP - time.monotonic() if pause > 0: time.sleep(pause) last_start = time.monotonic() def run_job(job): job_id, prompt = job headers = {**HEADERS, "X-Idempotency-Key": f"catalog-{job_id}-v1"} payload = {"model": "nano-banana-2.1", "prompt": prompt, "resolution": "1K"} for _ in range(5): pace() resp = requests.post( f"{API}/images/generate", headers=headers, json=payload, timeout=(5, 30) ) if resp.status_code != 429: break wait = resp.headers.get("Retry-After", "") time.sleep(int(wait) if wait.isascii() and wait.isdigit() else 2) if resp.status_code != 202: return job_id, {"id": None, "status": f"HTTP {resp.status_code}"} task_id = resp.json()["id"] stop = time.monotonic() + DEADLINE while time.monotonic() < stop: time.sleep(5) check = requests.get( f"{API}/creations/{task_id}", headers=HEADERS, timeout=(5, 20) ) if check.status_code == 200 and check.json()["status"] in ("completed", "failed"): return job_id, check.json() return job_id, {"id": task_id, "status": "unresolved"} # keep the id, look again later # jobs: (your job ID, prompt) pairs from your own list jobs = [("0001", "A plain white tube on a peach stage, soft light from the left")] with ThreadPoolExecutor(max_workers=MAX_IN_FLIGHT) as pool: for job_id, task in pool.map(run_job, jobs): print(job_id, task["id"], task["status"])
// Node.js 22+, .mjs file const API = "https://api.rendergrid.io/ api/ public/ v1"; const AUTH = { Authorization: `Bearer ${process.env.RENDERGRID_API_KEY}` }; const MAX_IN_FLIGHT = 50; // your own cap, below the account's 100 queued or processing tasks const SUBMIT_GAP_MS = 600; // between starts: at most 100 requests a minute, below 120 const DEADLINE_MS = 600_000; // a setting of this example, not a model time const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); let nextStart = 0; async function pace() { const now = Date.now(); const start = Math.max(now, nextStart); nextStart = start + SUBMIT_GAP_MS; await sleep(start - now); } async function runJob([jobId, prompt]) { const headers = { ...AUTH, "Content-Type": "application/json", "X-Idempotency-Key": `catalog-${jobId}-v1`, }; const body = JSON.stringify({ model: "nano-banana-2.1", prompt, resolution: "1K" }); let resp; for (let attempt = 0; attempt < 5; attempt++) { await pace(); resp = await fetch(`${API}/images/generate`, { method: "POST", headers, body, signal: AbortSignal.timeout(30_000), }); if (resp.status !== 429) break; const wait = resp.headers.get("Retry-After") ?? ""; await sleep((/^[0-9]+$/.test(wait) ? Number(wait) : 2) * 1_000); } if (resp.status !== 202) return [jobId, { id: null, status: `HTTP ${resp.status}` }]; const { id } = await resp.json(); const stop = Date.now() + DEADLINE_MS; while (Date.now() < stop) { await sleep(5_000); const check = await fetch(`${API}/creations/${id}`, { headers: AUTH, signal: AbortSignal.timeout(20_000), }); if (check.ok) { const task = await check.json(); if (task.status === "completed" || task.status === "failed") return [jobId, task]; } } return [jobId, { id, status: "unresolved" }]; // keep the id, look again later } // jobs: [your job ID, prompt] pairs from your own list const jobs = [["0001", "A plain white tube on a peach stage, soft light from the left"]]; const waiting = [...jobs]; const results = []; const workers = Array.from({ length: MAX_IN_FLIGHT }, async () => { for (let job = waiting.shift(); job; job = waiting.shift()) { results.push(await runJob(job)); } }); await Promise.all(workers); for (const [jobId, task] of results) console.log(jobId, task.id, task.status);
Poll one task no more often than every 5 seconds and bound the total wait; if it runs out, keep the id and look again later. The deadline in the example is a setting, not a model time: there is no promised time, because it depends on the model and the load. Average wait time over the last 7 days: 40 s. It is the model’s average, not a promise for your requests.
How do webhooks replace polling?
Add webhook_url to a request, and when the task becomes completed or failed, Rendergrid sends a signed POST to that address. It must be public HTTPS and at most 2,048 characters; an invalid one gets 400 before any charge.
curl --fail-with-body --max-time 30 \
-X POST https://api.rendergrid.io/api/public/v1/images/generate \
-H "Authorization: Bearer $RENDERGRID_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: catalog-0001-v1" \
-d '{
"model": "nano-banana-2.1",
"prompt": "A plain white tube on a peach stage, soft light from the left",
"resolution": "1K",
"webhook_url": "https://example.com/webhooks/rendergrid"
}'
The body is JSON; a failure event adds error and failure. Its cost is the original charge, not a confirmation of the refund, which happens automatically.
{
"event": "creation.completed",
"id": "5b0214ae-9e39-460a-820a-f6ae3e24400b",
"status": "completed",
"result_urls": [
"https:// …"
],
"cost": 0.02
}
{
"event": "creation.failed",
"id": "5b0214ae-9e39-460a-820a-f6ae3e24400b",
"status": "failed",
"result_urls": [],
"cost": 0.02,
"error": "Generation failed.",
"failure": {
"type": "error",
"code": "internal",
"retryable": true
}
}
Verify every delivery before you act on it, with the account’s signing secret (it starts with whsec_, comes from the portal, and is not an API key). The signature is HMAC-SHA256 over the X-Timestamp value, a dot, and the raw body, sent as sha256= in X-Webhook-Signature-256; compare it in constant time and reject an old timestamp.
Respond with 2xx within 10 seconds, after you have saved or queued the event. Any other response counts as a failure, and by default the delivery is retried up to five attempts in all: at once, then 60, 120, 240, and 480 seconds after the previous one. Attempts of one delivery share an X-Webhook-Delivery-ID, so act once per task id and event. The portal can also send a signed test delivery whose id is test: check its signature and don’t process it as a result.
import hashlib import hmac import os import re import time from flask import Flask, request app = Flask(__name__) SECRET = os.environ["WEBHOOK_SECRET"] # the signing secret from the portal, whsec_… TOLERANCE_SECONDS = 300 handled = set() inbox = [] # use a durable queue or a database in real use def signature_ok(timestamp: str, raw_body: bytes, signature: str) -> bool: if not re.fullmatch(r"[0-9]{1,12}", timestamp): return False if not re.fullmatch(r"sha256=[0-9a-f]{64}", signature): return False if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS: return False expected = "sha256=" + hmac.new( SECRET.encode("utf-8"), timestamp.encode("ascii") + b"." + raw_body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, signature) @app.post("/webhooks/rendergrid") def receive(): raw = request.get_data() timestamp = request.headers.get("X-Timestamp", "") signature = request.headers.get("X-Webhook-Signature-256", "") if not signature_ok(timestamp, raw, signature): return "", 401 event = request.get_json(force=True) if event.get("test") is True: return "", 200 # the portal's test delivery: nothing to process once = (event["id"], event["event"]) if once not in handled: inbox.append(event) # save first, respond after handled.add(once) return "", 200
// Node.js 22+, .mjs file; npm install express import { createHmac, timingSafeEqual } from "node:crypto"; import express from "express"; const SECRET = process.env.WEBHOOK_SECRET; // the signing secret from the portal, whsec_… const TOLERANCE_SECONDS = 300; const handled = new Set(); const inbox = []; // use a durable queue or a database in real use function signatureOk(timestamp, rawBody, signature) { if (!/^[0-9]{1,12}$/.test(timestamp ?? "")) return false; if (!/^sha256=[0-9a-f]{64}$/.test(signature ?? "")) return false; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false; const expected = "sha256=" + createHmac("sha256", Buffer.from(SECRET, "utf8")) .update(`${timestamp}.`, "ascii") .update(rawBody) .digest("hex"); return timingSafeEqual(Buffer.from(expected, "ascii"), Buffer.from(signature, "ascii")); } const app = express(); app.post("/webhooks/rendergrid", express.raw({ type: "application/json" }), (req, res) => { const raw = req.body; const timestamp = req.get("X-Timestamp"); const signature = req.get("X-Webhook-Signature-256"); if (!Buffer.isBuffer(raw) || !signatureOk(timestamp, raw, signature)) { return res.sendStatus(401); } const event = JSON.parse(raw.toString("utf8")); if (event.test === true) return res.sendStatus(200); // the portal's test delivery const once = `${event.id}:${event.event}`; if (!handled.has(once)) { inbox.push(event); // save first, respond after handled.add(once); } return res.sendStatus(200); }); app.listen(8080);
A failed delivery never changes the task. If one doesn’t arrive, ask GET /creations/{id}: polling stays as the fallback.
How do idempotency keys make retries safe?
A connection can drop after your request reached us, and then you can’t tell whether it was accepted; sending it again without a key would buy a second image. Send an X-Idempotency-Key with every generation request: a new key for a new image, the same key for a repeat of the same request. It is a non-empty string of at most 128 characters, such as a UUID or an operation number from your system; otherwise the API responds with 400 ERR_INVALID_REQUEST.
If the first request was accepted, the repeat returns the same 202 response: the same id, status, task_id, and cost, with no new image and no new charge. That is the saved reply to the submission, so it may still read queued when the image is ready. Take the current state from GET /creations/{id}.
{
"id": "5b0214ae-9e39-460a-820a-f6ae3e24400b",
"status": "queued",
"task_id": "5b0214ae-9e39-460a-820a-f6ae3e24400b",
"cost": 0.02
}
{
"id": "5b0214ae-9e39-460a-820a-f6ae3e24400b",
"status": "queued",
"task_id": "5b0214ae-9e39-460a-820a-f6ae3e24400b",
"cost": 0.02
}
Build the key from your job ID
catalog-0412-v1 is the same on every attempt at job 412, and catalog-0412-v2 is a deliberate new image. A key doesn’t compare bodies, so the same key with a new prompt returns the first response. It belongs to one API key, and saved responses are kept for 30 days; after that the record may be deleted, and the same key will start a new paid generation.
A repeat made while the first request is still being accepted can get 429 with details.reason set to idempotency_in_flight: wait for Retry-After and repeat with the same key. A 202 is not a promise of an image: if the task later becomes failed, the same key doesn’t restart it. A new attempt is a new request with a new key and a new paid operation, while the earlier charge returns automatically.
# The key stays the same for every repeat of this job. If you get no response, run this exact command again. curl --fail-with-body --max-time 30 \ -X POST https://api.rendergrid.io/ api/ public/ v1/ images/ generate \ -H "Authorization: Bearer $RENDERGRID_API_KEY" \ -H "Content-Type: application/json" \ -H "X-Idempotency-Key: catalog-0412-v1" \ -d '{ "model": "nano-banana-2.1", "prompt": "A plain white tube on a peach stage, soft light from the left", "resolution": "1K" }'
import os import random import time import requests API = "https://api.rendergrid.io/ api/ public/ v1" HEADERS = {"Authorization": f"Bearer {os.environ['RENDERGRID_API_KEY']}"} def submit(job_id: str, prompt: str, max_attempts: int = 3) -> dict: # one key per job: the same on every attempt, new only for a new image headers = {**HEADERS, "X-Idempotency-Key": f"catalog-{job_id}-v1"} payload = {"model": "nano-banana-2.1", "prompt": prompt, "resolution": "1K"} for attempt in range(max_attempts): last = attempt + 1 == max_attempts delay = 2 ** (attempt + 1) + random.random() try: resp = requests.post( f"{API}/images/generate", headers=headers, json=payload, timeout=(5, 30) ) except (requests.Timeout, requests.ConnectionError): if last: raise else: if resp.status_code == 202: return resp.json() try: reason = resp.json()["error"]["details"]["reason"] except (ValueError, KeyError, TypeError): reason = "" retryable = resp.status_code in (429, 500, 503) and reason != "burst_block" if not retryable or last: raise RuntimeError(resp.status_code, resp.text) wait = resp.headers.get("Retry-After", "") if wait.isascii() and wait.isdigit(): delay = max(delay, int(wait)) time.sleep(delay) creation = submit("0412", "A plain white tube on a peach stage, soft light from the left") print(creation["id"], creation["status"], creation["cost"])
// Node.js 22+, .mjs file const API = "https://api.rendergrid.io/ api/ public/ v1"; const KEY = process.env.RENDERGRID_API_KEY; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function submit(jobId, prompt, maxAttempts = 3) { // one key per job: the same on every attempt, new only for a new image const headers = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", "X-Idempotency-Key": `catalog-${jobId}-v1`, }; const body = JSON.stringify({ model: "nano-banana-2.1", prompt, resolution: "1K" }); for (let attempt = 0; attempt < maxAttempts; attempt++) { const last = attempt + 1 === maxAttempts; let delay = (2 ** (attempt + 1) + Math.random()) * 1_000; let resp; try { resp = await fetch(`${API}/images/generate`, { method: "POST", headers, body, signal: AbortSignal.timeout(30_000), }); } catch (error) { const network = error instanceof TypeError; if (!network && error.name !== "TimeoutError") throw error; if (last) throw error; } if (resp) { if (resp.status === 202) return resp.json(); const text = await resp.text(); let reason; try { reason = JSON.parse(text)?.error?.details?.reason; } catch { reason = undefined; } const retryable = [429, 500, 503].includes(resp.status) && reason !== "burst_block"; if (!retryable || last) throw new Error(`HTTP ${resp.status}: ${text}`); const wait = resp.headers.get("Retry-After"); if (wait && /^[0-9]+$/.test(wait)) delay = Math.max(delay, Number(wait) * 1_000); } await sleep(delay); } } const creation = await submit("0412", "A plain white tube on a peach stage, soft light from the left"); console.log(creation.id, creation.status, creation.cost);
What should you check before you run a batch?
Balance. A request the balance can’t cover gets
402 ERR_INSUFFICIENT_BALANCE, so readGET /balancefirst. An optional monthly spending limit stops new generations once it is used up.Queue. Keep your cap below 100 places and your starts below 120 a minute.
Results. Files live one day, so download each image once its task is
completed.Failures. Retry a
failedtask only whenfailure.retryableisn’tfalse, as a new request with a new key.
What does a batch cost?
A batch costs the number of images times the price of their resolution. A Nano Banana 2.1 image costs $0.02 at 1K, $0.03 at 2K, and $0.04 at 4K.
A failed request is free: the charge returns to your balance automatically. In a batch, a failed task costs nothing, a repeat with the same key isn’t charged again, and a new attempt is a new request, charged when it’s accepted. See how a failed request is refunded. All models and resolutions are on the pricing page.
Where does this approach not fit?
- Three jobs are a sample, not a benchmark. The images show a pattern, one attempt each. Nothing here measures how fast, how many, or how reliably a batch runs.
- Webhooks can fail to arrive. A delivery is attempted up to five times by default, and a failed one never changes the task. Keep polling as the fallback.
- A key is not an unconditional guarantee. During service failures, limit the attempts and keep your own records. If it’s unclear whether a request was accepted, check your request history in the portal or contact support before you send it with a new key.
A failed request is free
Try one prompt before you queue the list
Run one job of your list in the playground and look at the image before you send the rest.
Try Nano Banana 2.1Questions and answers
How many requests can I send at once?
Do I need webhooks if I already poll?
What happens if I send the same request twice with the same key?
202 response and no second image or charge. It may still read queued: take the current state from GET /creations/{id}.What should I do when a task fails?
Sources
- Rendergrid docs: idempotencyKeys, repeats, and the 30-day window.
- Rendergrid docs: webhooksSignature, retries, and the test delivery.
- Rendergrid docs: limitsRequest rate, tasks in progress, and polling.
- Rendergrid docs: tasks and statusesStatuses, polling, and result files.
- Rendergrid docs: generation requestThe request fields, the
202response, and the charge. - Rendergrid docs: errorsWhich responses to retry.





