Генерация пачками: очередь, вебхуки и ключ идемпотентности
Как запускать картинки пачкой через API Rendergrid: очередь в пределах лимитов, вебхуки с проверкой подписи и безопасные повторы с ключом идемпотентности.
Редакция RendergridО редакции
Коротко
Массовая генерация в API Rendergrid — это много отдельных запросов: один запрос создаёт одну картинку. Чтобы не потерять ни одной, нужны три вещи. Очередь на вашей стороне запускает задания по мере того, как освобождаются места: на аккаунт — не больше 120 запросов в минуту и не больше 100 заданий в очереди или в работе. Вебхук присылает подписанное событие, когда задание закончилось, а опрос остаётся запасным путём. Ключ идемпотентности (в документации — ключ повтора, заголовок X-Idempotency-Key) делает повтор безопасным: тот же ключ вернёт прежний ответ, а не вторую картинку и не второе списание.
Эти приёмы не заменяют вашего учёта: храните для каждой операции ключ, id задания и итог.
Как устроена пачка запросов?
Отдельного метода для пачки нет: вы отправляете столько запросов, сколько нужно картинок. В ответ на каждый приходит 202 с id задания, то есть «запрос принят», а не «картинка готова».
Один запрос — одна картинка. Нужно больше — отправьте больше запросов, каждый со своим ключом идемпотентности.
Лимиты общие для аккаунта. Их делят все ключи API; новые ключи лимиты не увеличивают.
Результат приходит позже. Задание проходит
queuedиprocessingи заканчиваетсяcompletedилиfailed.Всё хранится недолго. Файлы живут сутки, ответы на ключи идемпотентности — 30 дней.
Три картинки ниже — пример пачки для каталога: один товар, три фона, по запросу на каждую. Все три сделаны в Nano Banana 2.1, в 1K, с пропорциями 1:1, за одну попытку; для каждой показан её промпт. Это иллюстрация, а не замер: по трём картинкам нельзя судить о скорости или надёжности пачки.
Промпт первого запроса
Белый тюбик с гладкой крышкой стоит на персиковом постаменте, позади арочная панель того же цвета. Фронтальная предметная съёмка, мягкий свет слева, тень направо. Тюбик целиком в центре и занимает примерно половину высоты кадра. Просторная однотонная композиция. Без надписей, логотипов, людей и водяных знаков.
Nano Banana 2.1
nano-banana-2.1 · 1K · 1:1 · $0,02
Nano Banana 2.1, 1K, 1:1, одна попытка. Белый тюбик стоит по центру на низком круглом постаменте, позади него персиковая арочная панель; свет слева, тень падает вправо. Тюбик занимает около половины высоты кадра, надписей и логотипов нет. Отличие от промпта: на крышке заметен небольшой вырез, а в промпте крышка гладкая.
Промпт второго запроса
Белый тюбик с гладкой крышкой стоит на персиковой площадке у невысоких ступеней того же цвета. Фронтальная камера, мягкий свет слева, тень направо. Тюбик целиком по центру, примерно в половину высоты кадра. Ступени видны сбоку и не перекрывают товар. Без надписей, логотипов, людей и водяных знаков.
Nano Banana 2.1
nano-banana-2.1 · 1K · 1:1 · $0,02
Nano Banana 2.1, 1K, 1:1, одна попытка. Белый тюбик с гладкой крышкой стоит по центру на персиковой площадке; справа поднимаются персиковые ступени, слева виден низкий блок; свет слева, тень падает вправо. Надписей и логотипов нет. Тюбик получился крупнее, чем просили: он занимает примерно две трети высоты кадра вместо половины.
Промпт третьего запроса
Белый тюбик с гладкой крышкой на персиковой площадке. За ним большой вертикальный круг того же персикового цвета, чуть смещённый в сторону. Фронтальный ракурс, мягкий свет слева и тень направо. Тюбик занимает около половины высоты кадра, целиком виден, края свободны. Без надписей, логотипов, людей и водяных знаков.
Nano Banana 2.1
nano-banana-2.1 · 1K · 1:1 · $0,02
Nano Banana 2.1, 1K, 1:1, одна попытка. Белый тюбик с гладкой крышкой стоит по центру на плоском прямоугольном постаменте, позади него большой персиковый круг; свет слева, тень падает вправо. Тюбик занимает около половины высоты кадра, надписей и логотипов нет. Отличие от промпта: круг стоит ровно за тюбиком, а не смещён в сторону.
У трёх промптов общая основа, меняются подставка и фон, и тюбик, палитра и свет совпали. Но кадры не во всём повторили промпты: во втором тюбик занимает около двух третей высоты вместо половины, в третьем круг не смещён, а на крышке в первом есть вырез. Поэтому нужен этап приёмки: очередь и вебхуки доставляют результат, но не проверяют, подходит ли он вам.
Как построить очередь, чтобы не упереться в лимиты?
Очередь живёт у вас: в ней лежат операции, а в API уходит столько, сколько он готов принять. У аккаунта два лимита:
- до 120 запросов в минуту на запуск генераций, загрузку файлов, чтение каталога моделей и баланса, иначе
429 ERR_RATE_LIMIT; - до 100 заданий в очереди или в работе, иначе
429 ERR_QUOTA_EXCEEDED.
Опрос в запросы в минуту не входит, у него своё правило: одно задание одним ключом API — не чаще раза в 5 секунд, иначе снова 429.
Очередь делает три вещи:
- Заводит для каждой операции ключ идемпотентности, например из её номера, и хранит его вместе с операцией.
- Запускает новое задание, только пока в работе заметно меньше 100, и не чаще 120 раз в минуту.
- На
429ждётRetry-Afterи добавляет небольшую случайную задержку, на402останавливается: баланс или месячный лимит исчерпан.
import os import time from collections import deque import requests API = "https://api.rendergrid.io/ api/ public/ v1" HEADERS = {"Authorization": f"Bearer {os.environ['RENDERGRID_API_KEY']}"} MAX_IN_FLIGHT = 80 # с запасом до лимита в 100 заданий на аккаунт SUBMIT_PAUSE = 1.0 # секунд между запусками: не больше 60 в минуту при лимите 120 POLL_PAUSE = 5.0 # одно задание — не чаще раза в 5 секунд prompts = ["…", "…"] # ваши промпты queue = deque((f"batch-7-{n:04d}", prompt) for n, prompt in enumerate(prompts)) running = {} # id задания -> [номер операции, когда спрашивали в последний раз] finished = {} # номер операции -> ответ GET /creations/ {id} next_launch = 0.0 while queue or running: now = time.monotonic() if queue and len(running) < MAX_IN_FLIGHT and now >= next_launch: operation, prompt = queue[0] next_launch = now + SUBMIT_PAUSE try: r = requests.post( f"{API}/images/generate", headers={**HEADERS, "X-Idempotency-Key": operation}, json={ "model": "nano-banana-2.1", "prompt": prompt, "resolution": "1K", "aspect_ratio": "1:1", }, timeout=(5, 30), ) except (requests.Timeout, requests.ConnectionError): r = None # ответа нет: операция остаётся в очереди и уйдёт с тем же ключом if r is not None and r.status_code == 202: running[r.json()["id"]] = [operation, now] queue.popleft() elif r is not None and r.status_code == 429: next_launch = now + max(SUBMIT_PAUSE, int(r.headers.get("Retry-After", "5"))) elif r is not None and r.status_code < 500: raise RuntimeError(f"HTTP {r.status_code}: {r.text}") # 400, 402: сначала причина for creation_id, state in list(running.items()): if now - state[1] < POLL_PAUSE: continue state[1] = now try: r = requests.get(f"{API}/creations/{creation_id}", headers=HEADERS, timeout=(5, 20)) except (requests.Timeout, requests.ConnectionError): continue if r.status_code == 429 or r.status_code >= 500: continue r.raise_for_status() creation = r.json() if creation["status"] in ("completed", "failed"): finished[state[0]] = creation del running[creation_id] time.sleep(0.2) failed = [name for name, c in finished.items() if c["status"] == "failed"] print(len(finished) - len(failed), "готово; не вышло:", failed)
// Node.js 22+, файл .mjs const API = "https://api.rendergrid.io/ api/ public/ v1"; const AUTH = { Authorization: `Bearer ${process.env.RENDERGRID_API_KEY}` }; const MAX_IN_FLIGHT = 80; // с запасом до лимита в 100 заданий на аккаунт const SUBMIT_PAUSE = 1000; // мс между запусками: не больше 60 в минуту при лимите 120 const POLL_PAUSE = 5000; // одно задание — не чаще раза в 5 секунд const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const noAnswer = (error) => error instanceof TypeError || error.name === "TimeoutError"; const prompts = ["…", "…"]; // ваши промпты const queue = prompts.map((prompt, n) => ({ operation: `batch-7-${String(n).padStart(4, "0")}`, prompt, })); const running = new Map(); // id задания -> { operation, polledAt } const finished = new Map(); // номер операции -> ответ GET /creations/ {id} let nextLaunch = 0; while (queue.length || running.size) { const now = Date.now(); if (queue.length && running.size < MAX_IN_FLIGHT && now >= nextLaunch) { const { operation, prompt } = queue[0]; nextLaunch = now + SUBMIT_PAUSE; let response = null; try { response = await fetch(`${API}/images/generate`, { method: "POST", headers: { ...AUTH, "Content-Type": "application/json", "X-Idempotency-Key": operation }, body: JSON.stringify({ model: "nano-banana-2.1", prompt, resolution: "1K", aspect_ratio: "1:1", }), signal: AbortSignal.timeout(30_000), }); } catch (error) { // ответа нет: операция остаётся в очереди и уйдёт с тем же ключом if (!noAnswer(error)) throw error; } if (response?.status === 202) { running.set((await response.json()).id, { operation, polledAt: now }); queue.shift(); } else if (response?.status === 429) { const wait = Number(response.headers.get("Retry-After") ?? 5); nextLaunch = now + Math.max(SUBMIT_PAUSE, wait * 1000); } else if (response && response.status < 500) { throw new Error(`HTTP ${response.status}: ${await response.text()}`); // 400, 402: сначала причина } } for (const [id, state] of [...running]) { if (now - state.polledAt < POLL_PAUSE) continue; state.polledAt = now; let response; try { response = await fetch(`${API}/creations/${id}`, { headers: AUTH, signal: AbortSignal.timeout(20_000), }); } catch (error) { if (!noAnswer(error)) throw error; continue; } if (response.status === 429 || response.status >= 500) continue; if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`); const creation = await response.json(); if (creation.status === "completed" || creation.status === "failed") { finished.set(state.operation, creation); running.delete(id); } } await sleep(200); } const failed = [...finished].filter(([, c]) => c.status === "failed").map(([name]) => name); console.log(finished.size - failed.length, "готово; не вышло:", failed);
Числа в примере — наш выбор с запасом, а не рекомендация сервиса. Для длинной пачки держите очередь и таблицу операций на диске или в базе, чтобы после перезапуска продолжить с теми же ключами. Если лимитов не хватает, напишите в поддержку. Об отказах 429 — в статье «Ошибка 429 и лимиты: как генерировать без отказов».
Как получать результаты вебхуком вместо опроса?
Вместо опроса каждые 5 секунд можно получать событие. Передайте в запросе webhook_url, и на этот адрес придёт подписанный POST, когда задание станет completed или failed. Адрес — публичный, начинается с HTTPS и не длиннее 2 048 знаков; неверный или запрещённый вернёт 400 до списания. Секрет подписи один на аккаунт, начинается с whsec_ и лежит в личном кабинете, в разделе «Вебхуки».
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: batch-7-0001" \
-d '{
"model": "nano-banana-2.1",
"prompt": "Белый тюбик на персиковом постаменте, мягкий свет слева",
"resolution": "1K",
"aspect_ratio": "1:1",
"webhook_url": "https://example.com/webhooks/rendergrid"
}'
В JSON — поля event, id, status, result_urls и cost; событие ошибки добавляет error и failure.
{
"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
}
}
Как проверить подпись
Подпись показывает, что событие пришло от нас и не изменено. Возьмите исходные байты тела до разбора JSON, склейте X-Timestamp, точку и тело, посчитайте HMAC-SHA256 с секретом целиком, вместе с whsec_, и сравните sha256= и результат с заголовком X-Webhook-Signature-256 за постоянное время. Метку времени вне окна в 300 секунд отклоните; JSON разбирайте только после проверки.
import hashlib import hmac import json import os import re import time from http.server import BaseHTTPRequestHandler, HTTPServer SECRET = os.environ["RENDERGRID_WEBHOOK_SECRET"] # секрет подписи из кабинета TOLERANCE_SECONDS = 300 def signature_ok(timestamp, raw_body, signature): if not re.fullmatch(r"[0-9]{1,12}", timestamp or ""): return False if not re.fullmatch(r"sha256=[0-9a-f]{64}", signature or ""): 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) class Hook(BaseHTTPRequestHandler): def do_POST(self): raw_body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) if not signature_ok( self.headers.get("X-Timestamp"), raw_body, self.headers.get("X-Webhook-Signature-256"), ): self.send_response(400) self.end_headers() return event = json.loads(raw_body) # сначала сохраняем, потом отвечаем; дубли убирает обработчик по паре (id, event) with open("events.jsonl", "a", encoding="utf-8") as out: out.write(json.dumps(event, ensure_ascii=False) + "\n") self.send_response(200) self.end_headers() HTTPServer(("", 8080), Hook).serve_forever()
// Node.js 22+, файл .mjs import { createHmac, timingSafeEqual } from "node:crypto"; import { appendFileSync } from "node:fs"; import { createServer } from "node:http"; const SECRET = process.env.RENDERGRID_WEBHOOK_SECRET; // секрет подписи из кабинета const TOLERANCE_SECONDS = 300; function signatureOk(timestamp, rawBody, signature) { if (typeof timestamp !== "string" || !/^[0-9]{1,12}$/.test(timestamp)) return false; if (typeof signature !== "string" || !/^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")); } createServer((req, res) => { const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => { const rawBody = Buffer.concat(chunks); const timestamp = req.headers["x-timestamp"]; const signature = req.headers["x-webhook-signature-256"]; if (!signatureOk(timestamp, rawBody, signature)) { res.writeHead(400).end(); return; } const event = JSON.parse(rawBody.toString("utf8")); // сначала сохраняем, потом отвечаем; дубли убирает обработчик по паре (id, event) appendFileSync("events.jsonl", JSON.stringify(event) + "\n"); res.writeHead(200).end(); }); }).listen(8080);
Перед пачкой отправьте из кабинета тестовое событие на свой адрес: в нём id равен test, есть test: true, а status, result_urls и cost нет. Проверьте подпись, ответьте 2xx и не обрабатывайте его как картинку.
Что делать, если вебхук не пришёл
Ответ 2xx — подтверждение; всё остальное и сетевые ошибки мы считаем неудачей и повторяем доставку: по умолчанию до пяти попыток всего, через 60, 120, 240 и 480 секунд после предыдущей. Ответа мы ждём 10 секунд, поэтому сначала надёжно сохраните событие или поставьте его в очередь и ответьте 2xx, а картинки скачивайте потом. Дубли убирайте по паре (id, event): помогает и X-Webhook-Delivery-ID. Ошибка доставки не переводит задание в failed, а доставка не гарантируется на сто процентов, поэтому опрос оставьте запасным путём.
Как повторять запросы без двойного списания?
Связь может оборваться после того, как запрос дошёл до нас, но до вашего ответа. Принят запрос или нет, вы не знаете, а повтор без ключа купит вторую картинку. Поэтому в каждом запросе генерации передавайте X-Idempotency-Key: без него защиты от повторного выполнения нет.
- Ключ — непустая строка не длиннее 128 знаков, иначе
400 ERR_INVALID_REQUEST; подойдёт UUID или номер операции. - Новая картинка — новый ключ, повтор того же запроса — прежний.
- Ключ действует в пределах одного ключа API: другой ключ API, даже того же аккаунта, считает операцию новой.
- Тело запроса ключ не сверяет: другой промпт с прежним ключом вернёт прежний ответ.
# Одна операция — один ключ; запрос ушёл дважды, например после обрыва связи for attempt in 1 2; do 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: batch-7-0001" \ -d '{ "model": "nano-banana-2.1", "prompt": "Белый тюбик на персиковом постаменте, мягкий свет слева", "resolution": "1K" }' echo done
import os import random import time import requests URL = "https://api.rendergrid.io/ api/ public/ v1/ images/ generate" HEADERS = {"Authorization": f"Bearer {os.environ['RENDERGRID_API_KEY']}"} def submit(payload, operation, attempts=3): for attempt in range(attempts): delay = 2 ** (attempt + 1) + random.random() try: r = requests.post( URL, headers={**HEADERS, "X-Idempotency-Key": operation}, json=payload, timeout=(5, 30), ) except (requests.Timeout, requests.ConnectionError): r = None # ответа нет, но запрос мог дойти: повторяем с тем же ключом if r is not None: if r.status_code == 202: return r.json() if r.status_code != 429 and r.status_code < 500: raise RuntimeError(f"HTTP {r.status_code}: {r.text}") # сначала причина wait = r.headers.get("Retry-After", "") if wait.isascii() and wait.isdigit(): delay = max(delay, int(wait)) if attempt + 1 < attempts: time.sleep(delay) raise RuntimeError("запрос не принят: проверьте историю запросов, а не берите новый ключ") creation = submit( { "model": "nano-banana-2.1", "prompt": "Белый тюбик на персиковом постаменте, мягкий свет слева", "resolution": "1K", }, operation="batch-7-0001", ) print(creation["id"])
// Node.js 22+, файл .mjs const ENDPOINT = "https://api.rendergrid.io/ api/ public/ v1/ images/ generate"; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function submit(payload, operation, attempts = 3) { for (let attempt = 0; attempt < attempts; attempt++) { let delay = (2 ** (attempt + 1) + Math.random()) * 1000; let response = null; try { response = await fetch(ENDPOINT, { method: "POST", headers: { Authorization: `Bearer ${process.env.RENDERGRID_API_KEY}`, "Content-Type": "application/json", "X-Idempotency-Key": operation, }, body: JSON.stringify(payload), signal: AbortSignal.timeout(30_000), }); } catch (error) { // ответа нет, но запрос мог дойти: повторяем с тем же ключом if (!(error instanceof TypeError) && error.name !== "TimeoutError") throw error; } if (response) { if (response.status === 202) return response.json(); if (response.status !== 429 && response.status < 500) { throw new Error(`HTTP ${response.status}: ${await response.text()}`); // сначала причина } const wait = response.headers.get("Retry-After"); if (wait && /^[0-9]+$/.test(wait)) delay = Math.max(delay, Number(wait) * 1000); } if (attempt + 1 < attempts) await sleep(delay); } throw new Error("запрос не принят: проверьте историю запросов, а не берите новый ключ"); } const creation = await submit( { model: "nano-banana-2.1", prompt: "Белый тюбик на персиковом постаменте, мягкий свет слева", resolution: "1K", }, "batch-7-0001", ); console.log(creation.id);
Повтор уже принятого запроса вернёт тот же 202: те же id, status, task_id и cost, без новой картинки и нового списания. Это сохранённый ответ на отправку: в нём может остаться queued, когда картинка уже готова, поэтому актуальный статус берите у 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
}
Если ответа нет или пришёл 500, повторите тот же запрос с тем же ключом, а не создавайте новое задание; на 429 с details.reason: idempotency_in_flight сначала подождите Retry-After. Если задание стало failed, тот же ключ его не перезапустит: для новой попытки нужен новый ключ, и это новая платная операция.
Сохранённые ответы живут 30 дней, потом тот же ключ запустит новую платную генерацию. Безусловной гарантии при сбоях сервиса ключ не даёт: ведите свой учёт, отправляйте запросы одной операции по очереди и ограничивайте число попыток.
Как собрать всё вместе?
Чтобы про каждую операцию знать, чем она кончилась, ведите таблицу: номер операции, ключ идемпотентности, id задания и итог. Дальше пачка идёт по кругу:
- Возьмите операцию из очереди и отправьте запрос с её ключом; пришёл
202— запишитеid. - Принимайте события на вебхуке: подпись, сохранение, ответ
2xx, и только потом обработка. - Скачивайте картинку сразу после
completed: ссылки вresult_urlsживут сутки. - Неудачное задание отправляйте заново только с новым ключом.
В конце сверьтесь с таблицей: операция без id уходит снова с прежним ключом, а у операции с id, но без итога спросите статус.
Сколько стоит пачка?
Деньги списываются при приёме запроса, а за неудачный запрос сумма сразу возвращается на баланс. Пачка стоит столько, сколько стоят её запросы: картинка Nano Banana 2.1 стоит $0,02 в 1K, $0,03 в 2K и $0,04 в 4K.
Чтобы ошибка в цикле не опустошила баланс, выпустите для пачки отдельный ключ API с месячным лимитом. Это жёсткий предел: когда он исчерпан, запрос на генерацию получит 402 ERR_INSUFFICIENT_BALANCE, и деньги не спишутся.
Подробно о возврате — в статье о неудачных запросах, о ценах остальных моделей — на странице цен.
Где такая пачка не подходит?
- Отдельного метода для пачки нет. Очередь, учёт и повторы пишете вы.
- Лимиты общие для аккаунта. Новые ключи API их не увеличивают.
- Вебхук не гарантирует доставку. Событие может задержаться или не дойти, поэтому нужен запасной опрос.
- Хранение короткое. Файлы живут сутки, ответы на ключи идемпотентности — 30 дней.
Один запрос — одна картинка
Запустите первое задание
Ключ API и первый запрос — в быстром старте документации и в статье о первом запросе к Nano Banana 2.1. Очередь, вебхук и ключ идемпотентности добавляйте по одному.
Попробовать модельВопросы и ответы
Можно ли отправить несколько картинок одним запросом?
n больше 1 API отклонит с 400 до списания. Отправляйте несколько запросов, каждый со своим ключом идемпотентности.Сколько запросов в минуту можно отправлять?
Нужен ли вебхук, если есть опрос?
completed или failed. Вебхук избавляет от лишних запросов, а опрос остаётся запасным путём.Что будет, если отправить запрос дважды с одним ключом?
202 с тем же id: новой картинки и нового списания не будет.Сколько займёт пачка?
Обещанного срока нет: он зависит от модели и нагрузки. Сохраните id и продолжите позже, если время ожидания вышло.
Источники
- Документация Rendergrid: лимитыЛимиты запросов и заданий, интервал опроса, месячные лимиты.
- Документация Rendergrid: вебхукиСобытия, проверка подписи, повторы доставки.
- Документация Rendergrid: повтор без двойного списанияПравила
X-Idempotency-Key. - Документация Rendergrid: задачи и статусыСтатусы, ответ задания, срок жизни файлов.
- Цены RendergridЦена за картинку.
- Три картинки этой статьиПромпт и настройки — рядом с каждой.
- Статистика RendergridСреднее время ответа модели за 7 дней.





