Generación por lotes: cola, webhooks y clave de idempotencia
Cómo enviar un lote grande de solicitudes a la API de imágenes: una cola en tu lado que respeta los límites de la cuenta, webhooks firmados con reintentos y el encabezado X-Idempotency-Key para repetir un envío sin cobrar dos veces. Con ejemplos en curl, Python y JavaScript y tres imágenes de muestra.
Redacción de RendergridSobre la redacción
En resumen
Para enviar un lote grande, mantén en tu lado una cola que envíe una nueva cuando termine otra, sin pasar de 120 solicitudes por minuto ni de 100 tareas en cola o en proceso. Manda cada solicitud con su propia clave de idempotencia (X-Idempotency-Key) y guárdala junto al id de la tarea: así, repetir un envío dudoso no cobra una segunda imagen. Pasa webhook_url para que Rendergrid te avise de cada resultado y deja la consulta del estado como vía de reserva. El cargo de una tarea que termina en failed vuelve solo al saldo.
¿Cómo se envía un lote grande sin perder el rastro de ninguna solicitud?
La API no tiene un parámetro de lote: una solicitud pide una imagen y un n mayor que 1 recibe 400 antes del cargo. Un lote son solicitudes independientes, cada una con su id y su cargo. Para seguir el rastro de cada solicitud hacen falta cuatro piezas:
Una cola en tu lado que envía cuando hay un lugar libre.
Webhooks para recibir cada resultado.
Una clave de idempotencia por operación para repetir sin cobrar dos veces.
Un registro propio de claves e
id, cerrado solo concompletedofailed.
Los tres prompts de abajo piden lo mismo: un tubo blanco liso, de frente, con luz suave desde la izquierda y sombra a la derecha; solo cambia el fondo. Cada imagen es una solicitud, una tarea y un resultado, en un intento.
Prompt: tubo con panel en arco
Tubo blanco liso con tapa sobre una base durazno, frente a un panel del mismo color con forma de arco. Vista frontal, luz suave desde la izquierda y sombra a la derecha. El tubo completo ocupa cerca de la mitad de la altura; deja el resto del espacio despejado. Sin texto, logotipos, personas ni marcas de agua.
Nano Banana 2.1
nano-banana-2.1 · 1K · 1:1 · $0,02
Nano Banana 2.1, 1K, 1:1, un intento. Primer elemento del lote: un tubo blanco liso, de frente y centrado, de pie sobre su tapa, que lleva una muesca rectangular, encima de una plataforma redonda color melocotón. Detrás hay un panel en arco de un tono casi igual al de la pared. El tubo ocupa algo más de la mitad de la altura del encuadre. La luz llega desde la izquierda y la sombra cae hacia la derecha, sobre el suelo, como pedía el prompt. Se aparta del prompt en un punto: pedía luz suave y la sombra del panel sobre el suelo tiene el borde definido. No hay texto, logotipos, personas ni marcas de agua.
Prompt: tubo junto a unos peldaños
Tubo blanco sin adornos sobre una plataforma durazno junto a escalones bajos del mismo tono. Cámara frontal, luz suave de la izquierda y sombra hacia la derecha. Muestra el tubo entero al centro, a media altura del cuadro; los escalones quedan a un lado sin taparlo. Sin texto, logotipos, personas ni marcas de agua.
Nano Banana 2.1
nano-banana-2.1 · 1K · 1:1 · $0,02
Nano Banana 2.1, 1K, 1:1, un intento. Segundo elemento del lote: un tubo blanco liso, de frente y centrado, sobre un bloque melocotón, con unos peldaños bajos a la derecha que el borde del encuadre corta y que no tapan el tubo. La tapa es un cilindro liso, sin la muesca de los otros dos tubos. El tubo ocupa algo más de la mitad de la altura. La luz llega desde la izquierda y la sombra del tubo cae a su derecha, sobre el bloque; esa sombra tiene el borde definido, y el prompt pedía luz suave. Una sombra difusa oscurece la esquina superior derecha de la pared y una línea vertical fina separa el tramo izquierdo de la pared: el prompt no menciona ninguna de las dos. No hay texto, logotipos, personas ni marcas de agua.
Prompt: tubo delante de un disco
Sobre una base durazno, coloca un tubo blanco liso con tapa. Detrás aparece un disco vertical grande del mismo color, un poco desplazado hacia un lado. Encuadre frontal, luz suave de la izquierda y sombra a la derecha. Tubo completo al centro, de aproximadamente media altura. Sin texto, logotipos, personas ni marcas de agua.
Nano Banana 2.1
nano-banana-2.1 · 1K · 1:1 · $0,02
Nano Banana 2.1, 1K, 1:1, un intento. Tercer elemento del lote: un tubo blanco liso, de frente y centrado, de pie sobre su tapa en una plataforma redonda, con un disco grande de un melocotón más oscuro detrás. El tubo ocupa cerca de la mitad de la altura. Se aparta del prompt en un punto: pedía el disco «un poco desplazado hacia un lado» y en la imagen queda centrado detrás del tubo. El borde sellado de arriba muestra estrías finas y la tapa lleva una muesca rectangular, como en el primer elemento. La luz llega desde la izquierda y la sombra de la plataforma cae a la derecha. No hay texto, logotipos, personas ni marcas de agua.
¿Cómo armar la cola y cuántas solicitudes mantener a la vez?
Con miles de solicitudes no puedes enviarlo todo de golpe: los límites son de la cuenta y los comparten todas tus claves de API. Hay 120 solicitudes por minuto (429 ERR_RATE_LIMIT) y 100 tareas en cola o en proceso (429 ERR_QUOTA_EXCEEDED). Una tarea ocupa su lugar hasta que termina, así que la cola avanza por lugares libres: sale una solicitud nueva cuando otra llega a completed o failed. Deja margen por debajo de 100 y espacia los envíos para no pasar de 120 por minuto.
Si llega un 429, espera como mínimo los segundos de Retry-After, añade un retardo aleatorio pequeño y repite con la misma clave y el mismo cuerpo. Más en la guía del error 429.
La consulta del estado tiene su propia regla: como máximo una vez cada 5 segundos por clave y tarea. Del tiempo que tarda cada tarea no hay un plazo prometido: depende del modelo y de la carga. Tiempo promedio de espera de los últimos 7 días — 40 s.
El ejemplo mantiene 20 tareas a la vez, espacia los envíos y deja en el registro lo que no termina.
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']}"} IN_FLIGHT = 20 # tareas a la vez; la cuenta admite 100 en cola o en proceso SEND_EVERY = 0.6 # segundos entre envíos: 100 por minuto, bajo el límite de 120 POLL_EVERY = 5 # como máximo una consulta cada 5 segundos por tarea MAX_WAIT = 300 # ajuste del ejemplo, no un plazo del modelo PROMPTS = [ "Tubo blanco liso sobre una base durazno, frente a un panel en arco", "Tubo blanco liso sobre un bloque durazno, junto a unos peldaños bajos", "Tubo blanco liso sobre una base redonda, delante de un disco grande", ] jobs = [ { "key": f"catalog-8412-{n}", # una clave por operación, guardada con el lote "payload": { "model": "nano-banana-2.1", "prompt": prompt, "resolution": "1K", "aspect_ratio": "1:1", }, "id": None, "result": None, } for n, prompt in enumerate(PROMPTS, start=1) ] pace = threading.Lock() next_send = 0.0 def wait_turn(): global next_send with pace: now = time.monotonic() start = max(now, next_send) next_send = start + SEND_EVERY time.sleep(start - now) def submit(job): headers = {**HEADERS, "X-Idempotency-Key": job["key"]} for attempt in range(5): wait_turn() resp = requests.post( f"{API}/images/generate", headers=headers, json=job["payload"], timeout=(5, 30) ) if resp.status_code == 202: return resp.json()["id"] if resp.status_code != 429 or attempt == 4: raise RuntimeError(f"HTTP {resp.status_code}") # 429: esperar al menos Retry-After y repetir con la misma clave wait = resp.headers.get("Retry-After", "") time.sleep((int(wait) if wait.isdigit() else 2) + 1) def run(job): try: if job["id"] is None: job["id"] = submit(job) deadline = time.monotonic() + MAX_WAIT while time.monotonic() < deadline: time.sleep(POLL_EVERY) resp = requests.get( f"{API}/creations/{job['id']}", headers=HEADERS, timeout=(5, 20) ) if resp.status_code == 200 and resp.json()["status"] in ("completed", "failed"): job["result"] = resp.json() return except (requests.RequestException, RuntimeError): pass # la operación queda en el registro sin resultado with ThreadPoolExecutor(max_workers=IN_FLIGHT) as pool: list(pool.map(run, jobs)) pending = [job for job in jobs if job["result"] is None] print(f"{len(jobs) - len(pending)} terminadas, {len(pending)} por revisar")
// Node.js 22+, archivo .mjs const API = "https://api.rendergrid.io/ api/ public/ v1"; const HEADERS = { Authorization: `Bearer ${process.env.RENDERGRID_API_KEY}` }; const IN_FLIGHT = 20; // tareas a la vez; la cuenta admite 100 en cola o en proceso const SEND_EVERY = 600; // ms entre envíos: 100 por minuto, bajo el límite de 120 const POLL_EVERY = 5_000; // como máximo una consulta cada 5 segundos por tarea const MAX_WAIT = 300_000; // ajuste del ejemplo, no un plazo del modelo const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const PROMPTS = [ "Tubo blanco liso sobre una base durazno, frente a un panel en arco", "Tubo blanco liso sobre un bloque durazno, junto a unos peldaños bajos", "Tubo blanco liso sobre una base redonda, delante de un disco grande", ]; const jobs = PROMPTS.map((prompt, n) => ({ key: `catalog-8412-${n + 1}`, // una clave por operación, guardada con el lote payload: { model: "nano-banana-2.1", prompt, resolution: "1K", aspect_ratio: "1:1" }, id: null, result: null, })); let nextSend = 0; async function waitTurn() { const start = Math.max(Date.now(), nextSend); nextSend = start + SEND_EVERY; await sleep(start - Date.now()); } async function submit(job) { for (let attempt = 0; attempt < 5; attempt++) { await waitTurn(); const resp = await fetch(`${API}/images/generate`, { method: "POST", headers: { ...HEADERS, "Content-Type": "application/json", "X-Idempotency-Key": job.key }, body: JSON.stringify(job.payload), signal: AbortSignal.timeout(30_000), }); if (resp.status === 202) return (await resp.json()).id; if (resp.status !== 429 || attempt === 4) throw new Error(`HTTP ${resp.status}`); // 429: esperar al menos Retry-After y repetir con la misma clave const wait = Number(resp.headers.get("Retry-After")); await sleep((wait > 0 ? wait : 2) * 1_000 + 1_000); } } async function run(job) { try { job.id ??= await submit(job); const deadline = Date.now() + MAX_WAIT; while (Date.now() < deadline) { await sleep(POLL_EVERY); const resp = await fetch(`${API}/creations/${job.id}`, { headers: HEADERS, signal: AbortSignal.timeout(20_000), }); if (!resp.ok) continue; const data = await resp.json(); if (["completed", "failed"].includes(data.status)) { job.result = data; return; } } } catch { // la operación queda en el registro sin resultado } } const queue = [...jobs]; await Promise.all( Array.from({ length: IN_FLIGHT }, async () => { for (let job = queue.shift(); job; job = queue.shift()) await run(job); }), ); const pending = jobs.filter((job) => job.result === null); console.log(`${jobs.length - pending.length} terminadas, ${pending.length} por revisar`);
¿Cómo recibir cada resultado con webhooks?
Añade webhook_url a cada solicitud para la que quieras un aviso: una dirección HTTPS pública con certificado válido; una dirección incorrecta recibe 400 antes del cargo. Cuando la tarea llega a completed o failed, Rendergrid envía un POST con el evento creation.completed o creation.failed: un JSON con event, id, status, result_urls y cost, al que el fallo añade error y failure.
Comprueba la firma antes de actuar: es el HMAC-SHA256 de X-Timestamp, un punto y los bytes originales del cuerpo, con el secreto de firma completo (whsec_…) como clave, y llega en X-Webhook-Signature-256 como sha256= más 64 caracteres hexadecimales. Compárala en tiempo constante, rechaza marcas de tiempo fuera de la ventana (300 segundos en los ejemplos de la documentación) y no vuelvas a montar el JSON antes de comprobar.
Responde 2xx en cuanto guardes el evento o lo pongas en tu cola; el trabajo largo, después: Rendergrid espera la respuesta 10 segundos. Otra respuesta o un error de red cuenta como fallo y la entrega se repite: por defecto, hasta cinco intentos en total, a los 60, 120, 240 y 480 segundos de cada intento anterior, y la planificación puede añadir un retraso. Procesa cada pareja (id, event) una sola vez; X-Webhook-Delivery-ID no cambia en los reintentos de una misma entrega.
La entrega no está garantizada al cien por cien: las operaciones que sigan abiertas pasado tu tiempo de espera se consultan con GET /creations/{id}.
# Con webhook_url, Rendergrid avisa a esa dirección cuando la tarea termina 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-8412-1" \ -d '{ "model": "nano-banana-2.1", "prompt": "Tubo blanco liso sobre una base durazno, luz suave desde la izquierda", "resolution": "1K", "aspect_ratio": "1:1", "webhook_url": "https:// example.com/ webhooks/ rendergrid" }'
import hashlib import hmac import json import os import queue import time from http.server import BaseHTTPRequestHandler, HTTPServer SECRET = os.environ["WEBHOOK_SECRET"].encode("utf-8") # el secreto completo, con whsec_ TOLERANCE = 300 # segundos, como en los ejemplos de la documentación events = queue.Queue() # otro proceso descarga las imágenes seen = set() # en producción, un almacén persistente class Receiver(BaseHTTPRequestHandler): def do_POST(self): raw = self.rfile.read(int(self.headers.get("Content-Length", 0))) timestamp = self.headers.get("X-Timestamp", "") signature = self.headers.get("X-Webhook-Signature-256", "") signed = timestamp.encode("ascii", "ignore") + b"." + raw expected = "sha256=" + hmac.new(SECRET, signed, hashlib.sha256).hexdigest() fresh = ( timestamp.isascii() and timestamp.isdigit() and abs(time.time() - int(timestamp)) <= TOLERANCE ) if not (fresh and signature.isascii() and hmac.compare_digest(expected, signature)): return self.reply(401) try: event = json.loads(raw) except ValueError: return self.reply(400) marker = (event.get("id"), event.get("event")) if event.get("id") != "test" and marker not in seen: seen.add(marker) events.put(event) self.reply(200) def reply(self, status): self.send_response(status) self.send_header("Content-Length", "0") self.end_headers() HTTPServer(("", 8080), Receiver).serve_forever()
// Node.js 22+, archivo .mjs import { createHmac, timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; const SECRET = Buffer.from(process.env.WEBHOOK_SECRET, "utf8"); // el secreto completo, con whsec_ const TOLERANCE = 300; // segundos, como en los ejemplos de la documentación const events = []; // otro proceso descarga las imágenes const seen = new Set(); // en producción, un almacén persistente createServer(async (req, res) => { const chunks = []; for await (const chunk of req) chunks.push(chunk); const raw = Buffer.concat(chunks); const timestamp = req.headers["x-timestamp"] ?? ""; const signature = req.headers["x-webhook-signature-256"] ?? ""; const expected = "sha256=" + createHmac("sha256", SECRET).update(`${timestamp}.`, "ascii").update(raw).digest("hex"); const fresh = /^[0-9]{1,12}$/.test(timestamp) && Math.abs(Date.now() / 1000 - Number(timestamp)) <= TOLERANCE; const a = Buffer.from(expected, "ascii"); const b = Buffer.from(signature, "ascii"); if (!fresh || a.length !== b.length || !timingSafeEqual(a, b)) { res.writeHead(401).end(); return; } let event; try { event = JSON.parse(raw.toString("utf8")); } catch { res.writeHead(400).end(); return; } const marker = `${event.id}:${event.event}`; if (event.id !== "test" && !seen.has(marker)) { seen.add(marker); events.push(event); } res.writeHead(200).end(); }).listen(8080);
¿Cómo repetir un envío sin cobrar dos veces?
La conexión puede cortarse después de que la solicitud llegó y antes de que recibas la respuesta; si la repites sin clave, compras una segunda imagen. Con X-Idempotency-Key, repetirla con la misma clave devuelve la misma respuesta 202, con los mismos id, status, task_id y cost, sin imagen ni cargo nuevos.
- La clave es una cadena no vacía de hasta 128 caracteres: un UUID o el número de operación de tu sistema; si no,
400 ERR_INVALID_REQUEST. - Una imagen nueva, una clave nueva; repetir la misma solicitud, la clave anterior. Guarda la clave y el cuerpo exacto antes de enviar.
- La clave vale dentro de una misma clave de API, la API no compara el cuerpo (otro prompt con una clave usada devuelve la respuesta anterior) y las respuestas guardadas duran 30 días.
En un lote, construye la clave con tu número de operación: si el programa se detiene, recorre la lista de nuevo y, dentro de esos 30 días, las operaciones ya aceptadas (misma clave de API, mismo cuerpo) devuelven su respuesta guardada.
# Si no llega respuesta, repite este mismo comando: misma clave y mismo cuerpo 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-8412-1" \ -d '{ "model": "nano-banana-2.1", "prompt": "Tubo blanco liso sobre una base durazno, luz suave desde la izquierda", "resolution": "1K", "aspect_ratio": "1:1" }'
import os import requests API = "https://api.rendergrid.io/ api/ public/ v1" HEADERS = {"Authorization": f"Bearer {os.environ['RENDERGRID_API_KEY']}"} def send(payload, key): return requests.post( f"{API}/images/generate", headers={**HEADERS, "X-Idempotency-Key": key}, json=payload, timeout=(5, 30), ) payload = { "model": "nano-banana-2.1", "prompt": "Tubo blanco liso sobre una base durazno, luz suave desde la izquierda", "resolution": "1K", "aspect_ratio": "1:1", } key = "catalog-8412-1" # el número de operación de tu sistema, guardado antes de enviar try: resp = send(payload, key) except (requests.Timeout, requests.ConnectionError): # No sabes si la solicitud llegó: repite con la misma clave y el mismo cuerpo resp = send(payload, key) resp.raise_for_status() print(resp.json()["id"])
// Node.js 22+, archivo .mjs const API = "https://api.rendergrid.io/ api/ public/ v1"; const HEADERS = { Authorization: `Bearer ${process.env.RENDERGRID_API_KEY}`, "Content-Type": "application/json", }; async function send(payload, key) { return fetch(`${API}/images/generate`, { method: "POST", headers: { ...HEADERS, "X-Idempotency-Key": key }, body: JSON.stringify(payload), signal: AbortSignal.timeout(30_000), }); } const payload = { model: "nano-banana-2.1", prompt: "Tubo blanco liso sobre una base durazno, luz suave desde la izquierda", resolution: "1K", aspect_ratio: "1:1", }; const key = "catalog-8412-1"; // el número de operación de tu sistema, guardado antes de enviar let resp; try { resp = await send(payload, key); } catch { // no sabes si la solicitud llegó: repite con la misma clave y el mismo cuerpo resp = await send(payload, key); } if (resp.status !== 202) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`); console.log((await resp.json()).id);
Esa respuesta es la guardada del envío, no el estado actual: puede seguir diciendo queued con la imagen ya lista. El estado se lee en 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": "completed",
"result_urls": [
"https:// …"
]
}
Qué hacer según lo que recibas:
- Sin respuesta,
5xxo429conidempotency_in_flight: repite con la misma clave y el mismo cuerpo, con pausa y límite de intentos; en el429, esperaRetry-After. 400,402o404: la solicitud no se aceptó; corrige la causa y repite con la misma clave.202y luegofailed: la clave anterior no reinicia la tarea. Un intento nuevo lleva una clave nueva y es una operación nueva de pago; el cargo anterior vuelve solo.
¿Qué revisar antes de lanzar el lote?
- Saldo y límite mensual. Sin saldo suficiente o con el límite mensual agotado, la API responde
402 ERR_INSUFFICIENT_BALANCEy no acepta la solicitud: pausa la cola, corrige la causa y repite con la misma clave.GET /balancemuestra el saldo. - Receptor y consulta de reserva. Firma comprobada, respuesta rápida y un tiempo máximo de espera por tarea; si se agota, conserva el
idy sigue más tarde. - Descarga de resultados. Los archivos duran un día: guárdalos en tu almacenamiento en cuanto llegue el enlace.
- Fallos por tipo. Lee
failure.type: condeclined, cambia la solicitud; conerror, reintenta siretryableno esfalse, con una clave nueva.
¿Cuánto cuesta un lote?
El cargo se hace al aceptar la solicitud y llega en cost. Nano Banana 2.1 cuesta $0,02 por imagen en 1K, $0,03 en 2K y $0,04 en 4K; los precios son en dólares estadounidenses.
Cada solicitud de un lote lleva su propio cargo: una solicitud fallida es gratis y su cargo vuelve de inmediato al saldo (cómo funciona). Para acotar el gasto, fija un límite mensual en el portal, para la cuenta o para una clave: al agotarse, la siguiente solicitud recibe 402 ERR_INSUFFICIENT_BALANCE y no se cobra nada.
¿Qué no cubre este artículo?
- Tres imágenes no miden un lote. Muestran un trabajo por solicitud, no un ritmo, un tiempo ni una fiabilidad.
- Un intento por imagen. Otra generación del mismo prompt puede verse distinta.
- Un lote pequeño no necesita cola. Si cabe dentro de los límites de la cuenta, basta un bucle con claves de idempotencia.
- La clave no es una garantía incondicional. Si tras los reintentos sigue sin estar claro si se aceptó una solicitud, mira el historial de solicitudes del portal o escribe a soporte antes de enviarla con una clave nueva.
Una solicitud fallida es gratis
Ajusta el prompt antes de lanzar el lote
Cada elemento de un lote repite una plantilla de prompt: pruébala en la zona de pruebas de Nano Banana 2.1.
Probar Nano Banana 2.1Preguntas frecuentes
¿Cuántas solicitudes puedo tener en proceso a la vez?
429 ERR_QUOTA_EXCEEDED, y al pasar de 120 por minuto, 429 ERR_RATE_LIMIT.¿Necesito webhooks o basta con consultar el estado?
¿Cuánto tarda en terminar un lote?
Fuentes
- Documentación de Rendergrid: reintentos sin doble cargoClave de idempotencia.
- Documentación de Rendergrid: webhooksFirma y reintentos de entrega.
- Documentación de Rendergrid: límitesFrecuencia, tareas simultáneas y límites mensuales.
- Documentación de Rendergrid: tareas y estadosEstados, consulta y archivos.
- Documentación de Rendergrid: crear una imagenCampos de la solicitud.
- Precios de RendergridPrecio por imagen de cada modelo.
- Estadísticas de RendergridTiempo medio de espera, últimos 7 días.
- Imágenes de este artículoTres solicitudes a la API, un intento cada una.





