Errors & Rate Limits
Error handling and rate limiting for the Kolbo Developer API.
Error Format
There is no single error envelope. Three shapes cover almost everything; authentication failures and rate limits each add their own. Never branch on the body shape — branch on the HTTP status.
1. Errors raised by the SDK layer itself — validation, credit gates, project/session checks, and generation failures relayed from the underlying controller:
{
"success": false,
"error": "Human-readable error message",
"code": "ERROR_CODE"
}2. Errors that fall through to the API error handler — unhandled exceptions, Mongoose validation and cast errors, multipart upload limits:
{
"status": false,
"message": "Human-readable error message",
"code": "INTERNAL_SERVER_ERROR"
}An error that carries its own upper-case code and a 4xx status (for example INVALID_FONT_IDS, CUSTOM_FONTS_UNSUPPORTED, INVALID_DRAFT_MODE, UNSUPPORTED_DRAFT_MODE, UNSUPPORTED_VIDEO_RESOLUTION, DRAFT_CREDITS_ONLY) keeps that status, code and message; on /api/v1 routes its body also carries success: false and error. Otherwise the handler picks the status and code from the error type — and, for several branches, from words in the error message:
| Code | Status | Trigger |
|---|---|---|
VALIDATION_ERROR | 400 | Mongoose or upstream validation failure. The only shape-2 code that also carries an errors array. |
INVALID_ID_FORMAT | 400 | A malformed ObjectId reached a database lookup. |
DUPLICATE_ENTRY | 400 | Unique-index collision. |
FILE_TOO_LARGE | 400 | A multipart file exceeded the route's upload size limit. |
UNEXPECTED_FILE_FIELD | 400 | A multipart file arrived under a field name the route does not accept, or more files than the field allows. |
IMAGE_UNREADABLE | 400 | An uploaded image could not be decoded. |
LIMIT_FILE_COUNT, LIMIT_FIELD_KEY, LIMIT_FIELD_VALUE, LIMIT_FIELD_COUNT, LIMIT_PART_COUNT | 400 | Any other multipart upload limit; the code is the upload library's own. |
INVALID_URL_FORMAT | 400 | Any error whose message mentions "URL" or "URI" — including some unrelated internal failures, so treat it as a generic 400. |
ACCESS_DENIED | 403 | Error message mentions "permission" or "access". |
RESOURCE_NOT_FOUND | 404 | Error message mentions "not found". |
REQUEST_TIMEOUT | 408 | Error message mentions "timeout". |
RATE_LIMIT_EXCEEDED | 429 | A thrown error that carried status 429. |
DATABASE_CONNECTION_ERROR / SOCKET_CONNECTION_ERROR / SERVICE_UNAVAILABLE | 503 | Database, socket or network failure. Safe to retry with backoff. |
INTERNAL_SERVER_ERROR | 500 | Everything else. |
3. Errors raised before the request reaches the API router — malformed JSON, or a JSON body over the 1 MB limit:
{
"status": false,
"message": "Human-readable error message",
"errors": ["Human-readable error message"]
}Shape 3 has no code field. The status is the one the body parser set (400 for malformed JSON, 413 for an oversized body), and any 5xx reports the message Internal server error. Send media by URL (upload it first with POST /v1/media/upload) rather than inline in a JSON body.
Never key your error handling on success alone, and never assume code is present. Branch on the HTTP status first, then read error ?? message, then treat code as optional. A client that only checks data.success === false will silently treat every shape-2 and shape-3 error as a success.
Even within shape 1, code is not guaranteed — several SDK validation errors return only { "success": false, "error": "..." } (for example Provide video_url or upload a video file on video-to-video, image_url is required on image-to-video, Generation not found on status and cancel, or source and id are required on the stock routes).
Rate-limit responses use their own shapes again — see Rate Limits below.
Do not test for an exact success status either. 200, 201 and 202 are all in use on the submit call depending on the endpoint (see the table below), so status === 200 rejects working jobs. The check that holds across every route is response.ok and neither failure flag set:
if (!res.ok || body.success === false || body.status === false) {
throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`);
}Both flags are needed: TRACKING_ERROR can return a 2xx with success: false, and shapes 2 and 3 use status: false / message rather than success: false / error.
HTTP Status Codes
| Status | Meaning |
|---|---|
| 200 | Success — including the submit call on POST /v1/generate/image, /generate/image-edit, /generate/music, /generate/3d, /generate/video-from-video, /transcribe and /chat |
| 201 | Created — POST /api/api-keys, POST /v1/visual-dna, POST /v1/media/folders, POST /v1/docs, POST /v1/review/assets, POST /v1/projects/{id}/transfer |
| 202 | Accepted — an async job was queued. Used by POST /v1/generate/video, /generate/video/from-image, /generate/speech, /generate/sound, /generate/lipsync, /generate/elements, /generate/first-last-frame, /generate/creative-director, /edit/image, /edit/video, and POST /v1/video/trim |
| 400 | Bad request — missing or invalid parameters (also several shape-2 handler errors, see above) |
| 401 | Unauthorized — invalid, expired, revoked or wrongly-formatted API key; also a restricted account, an account pending permanent deletion, or a developer-console (USD) key used outside the developer console API |
| 402 | Payment required — MUSIC_LIBRARY_INSUFFICIENT_CREDITS on the music library |
| 403 | Forbidden — insufficient credits, read-only key, unverified email, or no permission on the target project / resource |
| 404 | Not found |
| 408 | Request timeout (shape 2, REQUEST_TIMEOUT) |
| 409 | Conflict — the generation is not in a cancellable state (CANNOT_CANCEL, NOT_IN_PROGRESS), or a duplicate submit (DUPLICATE_REQUEST) |
| 413 | JSON request body larger than 1 MB (shape 3) |
| 422 | Unprocessable — well-formed request, but the script analysis produced no usable searches, or the underlying generation controller rejected the inputs after validation |
| 429 | Rate limited |
| 500 | Server error, and the default for anything the error handler cannot classify |
| 502 / 503 / 504 | Upstream music / stock vendor failed, is unconfigured, or timed out. 503 is also used for database / network failures and for CANCELLATION_SETTLEMENT_PENDING on cancel |
Common Error Codes
Codes returned by the /api/v1 SDK layer (shape 1):
| Code | Status | Description |
|---|---|---|
INSUFFICIENT_CREDITS | 403 | Balance is below the per-type pre-flight minimum. The message states your balance and the minimum required, and the body also carries balance, required and pricing_url so you can report the shortfall without parsing the message. |
API_KEY_READ_ONLY | 403 | The key lacks write permission for a mutating request. |
INVALID_PROMPT | 400 | prompt is missing, empty, or not a string on an endpoint that requires it — or, on an endpoint where prompt is optional, present but not a string. |
MISSING_PROMPT | 400 | POST /generate/music/composition-plan was called without a non-empty string prompt. |
INVALID_MODEL | 400 | model was sent as something other than a string. Omit it to auto-select, or pass an identifier from GET /api/v1/models. |
MODEL_REQUIRED | 400 | The endpoint does not support auto-select and needs an explicit model (video-to-video). |
MODEL_NOT_FOUND | 400 | An explicit model string is not in the catalog. The message reads Model not available: <id>. Did you mean: …? Call GET /v1/models?type=… for the exact identifiers. Pass an identifier from GET /api/v1/models, or omit model to auto-select. Not used by video-to-video (its controller rejects unknown models with its own error). |
UNSUPPORTED_FILE_TYPE | 400 | A multipart upload to /generate/lipsync, /generate/first-last-frame or /generate/video-from-video is not an image, video or audio file. SVG, HTML and XML are always rejected. A generic application/octet-stream part is judged by its file extension. |
TOO_MANY_REFERENCE_IMAGES | 400 | POST /generate/video received more than one reference_images entry. Use /generate/first-last-frame for two keyframes or /generate/elements for multiple references. |
REFERENCE_IMAGES_NOT_SUPPORTED | 400 | reference_images was sent to POST /generate/video with a text-to-video model that has no image-to-video variant. |
MOTION_CONTROL_INPUTS | 400 | A motion-control video-to-video model was called without reference_images[0] (the character image). The source video (video_url or an uploaded file) supplies the motion that is transferred. |
INVALID_VIDEO_URL | 400 | video_url on POST /generate/music is not a string, or failed URL validation. |
CUSTOM_FONTS_UNSUPPORTED | 400 | font_ids was sent to POST /edit/image with an operation other than prompt-based editing. |
VOICE_REQUIRED / VOICE_NOT_FOUND | 400 | Only with the cartesia_sonic_3_5 speech model: no voice was given, or no Cartesia voice matches the name you passed. Other speech models fall back to a default voice instead. These two arrive as { "status": false, "code": "...", "message": "..." }, not the success / error shape. |
SDK_PROJECT_INVALID_ID / SDK_SESSION_INVALID_ID | 400 | project_id / session_id is not a valid id. |
SDK_PROJECT_NOT_FOUND / SDK_SESSION_NOT_FOUND | 404 | The project or session does not exist or is not accessible to you — or, for session_id, it belongs to a different generation type. |
SDK_PROJECT_ACCESS_DENIED | 403 | You need at least edit permission on the project to generate into it. |
GENERATION_ERROR | varies | Fallback code when the underlying generation controller returns an error without its own code. The HTTP status is whatever that controller set — commonly 400, 422 or 500. When the controller did set a code (for example DUPLICATE_REQUEST), that code is relayed instead; any other fields it returned are dropped. |
TRACKING_ERROR | 2xx or 500 | Two cases. 500: the job was accepted but its tracking record could not be written, so the job was cancelled — safe to retry. 2xx with success: false: the controller answered without an id the SDK could capture, so the job may be running untracked — reconcile (see Retrying a Submit That Never Answered) before you retry. |
CHAT_ERROR / VISUAL_DNA_ERROR / DELETE_ERROR | varies | Fallback codes for the chat, Visual DNA and delete wrappers when the underlying controller returned no code of its own. On POST /visual-dna specifically, code is populated from the inner controller's error field, so it can be an arbitrary human-readable string rather than a stable code — treat it as untrusted for branching. |
NOT_FOUND / FORBIDDEN / ACCESS_DENIED | 404 / 403 | The target resource does not exist, or is not yours. A Visual DNA you are not allowed to use returns 404 NOT_FOUND from any route whose body names it in visual_dna_ids. |
CANNOT_CANCEL / NOT_IN_PROGRESS | 409 | POST /generate/{id}/cancel on a generation that is no longer cancellable. Provider-specific refusals (PROVIDER_ALREADY_RUNNING, PROVIDER_ALREADY_COMPLETED, PROVIDER_STATUS_UNAVAILABLE) and INTERNAL_ERROR arrive as 500, and CANCELLATION_SETTLEMENT_PENDING as 503. Cancel errors also carry generation_id, type and, when known, current_status. |
DUPLICATE_REQUEST | 409 | POST /edit/image repeated the same operation on the same image in the same session within 30 seconds — see Retrying. |
MUSIC_LIBRARY_* | 402 / 404 / 422 / 429 / 502 / 503 / 504 | Music-library read errors: _INSUFFICIENT_CREDITS (402), _NOT_FOUND (404), _SCRIPT_EMPTY (422), _RATE_LIMITED (429), _UNAVAILABLE (502 or 503), _TIMEOUT (504), _ERROR / _SCRIPT_ERROR (502). Upstream messages are never echoed — the vendor name and internal codes are scrubbed. |
SYNCI_* and related | varies | The two paid music routes (POST /music-library/clean/{trackId}, POST /music-library/import) relay their own codes — for example SYNCI_TERMS_REQUIRED, SYNCI_LICENSE_REQUIRED, SYNCI_INSUFFICIENT_CREDITS, SYNCI_CLEAN_IN_PROGRESS, SYNCI_CLEAN_REQUEST_USED, SYNCI_IDEMPOTENCY_MISMATCH, INVALID_PROJECT_ID, PROJECT_ACCESS_DENIED — with a fixed public message, plus creditsRemaining when known. |
STOCK_* | 404 / 422 / 502 | Stock-library errors: STOCK_NOT_FOUND (404), STOCK_SCRIPT_EMPTY (422), STOCK_ERROR / STOCK_SCRIPT_ERROR (502). |
RATE_LIMITED is the one code you can also receive from outside the SDK layer — see Rate Limits.
Some responses on /api/v1 carry no code at all. Detect them by HTTP status.
401from the API-key middleware (bad prefix, unknown / revoked / expired key, a restricted account, or an account pending permanent deletion) returns{ "message": "...", "tokenExpired": false }.403for an unverified email returns{ "status": false, "message": "...", "emailNotVerified": true }.- Shape 3 (malformed or oversized JSON) never carries a
code.
403 API_KEY_READ_ONLY also arrives in two different shapes depending on where it was raised: the auth middleware emits { "status": false, "message": "...", "code": "API_KEY_READ_ONLY" }, while the two paid music routes and the project-transfer routes emit { "success": false, "error": "...", "code": "API_KEY_READ_ONLY" }. Read error ?? message. The auth middleware only enforces read-only on keys created with scope enforcement — older keys are grandfathered — but the paid music and project-transfer routes reject any key without write.
Example: bad model identifier
curl -X POST https://api.kolbo.ai/api/v1/generate/image \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt": "A cat", "model": ["nonexistent-model"]}'Response (400):
{
"success": false,
"error": "model must be a string model identifier. Omit model to auto-select, or use GET /v1/models to list available models.",
"code": "INVALID_MODEL"
}A model identifier that is a string but is not in the catalog is rejected. The SDK layer first maps registered aliases and provider-prefixed forms to the canonical identifier; anything still unknown fails before the job starts:
- Image, image edit, video, image-to-video, elements, first-last-frame, lipsync, chat, music, speech, sound, 3D:
400 MODEL_NOT_FOUND, with near-match suggestions in the message. - Video-to-video: the unknown identifier is passed through, and the controller's error is relayed (falling back to
GENERATION_ERROR).
Omitting model, sending a blank string, or an auto alias (auto, smart-select, Smart Select, default, …) still auto-selects. If the server cannot load the catalog to check the identifier (an outage), it runs the default instead of returning 400, so compare the model in the submit response with the one you sent.
To avoid both, omit the model field (Smart Select picks a model automatically) or fetch valid identifiers first:
curl https://api.kolbo.ai/api/v1/models \
-H "X-API-Key: YOUR_API_KEY"Handling Errors (JavaScript)
async function main() {
const response = await fetch("https://api.kolbo.ai/api/v1/generate/image", {
method: "POST",
headers: {
"X-API-Key": "YOUR_API_KEY",
"Content-Type": "application/json"
},
body: JSON.stringify({ prompt: "A cat" })
});
// Shape 3 and some 429 bodies are not guaranteed to be JSON.
const data = await response.json().catch(() => ({}));
// `code` may be absent, and the message lives under `error` or `message`
// depending on which layer rejected the request.
if (!response.ok || data.success === false || data.status === false) {
console.error(`Error ${response.status}: ${data.code ?? "NO_CODE"} - ${data.error ?? data.message}`);
} else {
console.log("Generation started:", data.generation_id);
}
}
main();Handling Errors (Python)
import requests
response = requests.post(
"https://api.kolbo.ai/api/v1/generate/image",
headers={"X-API-Key": "YOUR_API_KEY", "Content-Type": "application/json"},
json={"prompt": "A cat"}
)
# Shape 3 and some 429 bodies are not guaranteed to be JSON.
try:
data = response.json()
except ValueError:
data = {}
# `code` may be absent, and the message lives under `error` or `message`
# depending on which layer rejected the request.
if not response.ok or data.get("success") is False or data.get("status") is False:
print(f"Error {response.status_code}: {data.get('code', 'NO_CODE')} - {data.get('error') or data.get('message')}")
else:
print(f"Generation started: {data['generation_id']}")Rate Limits
Two layers apply, and a request can be rejected by either.
Global ceiling
5,000 requests/minute per API key, across every endpoint. The bucket is keyed on a hash of the key itself, so one key cannot exhaust another's quota. Before that, every request also counts against a shared 15,000 requests/minute per client IP ceiling, so many keys used from one address still share that outer limit.
Exceeding either returns 429 with its own body and two extra headers (Retry-After, X-RateLimit-Source):
{
"error": "Too many requests. Please slow down.",
"retryAfter": 60,
"code": "RATE_LIMITED",
"source": "global:apikey"
}source is global:network when the per-IP ceiling fired.
Per-endpoint limits
Every window is 60 seconds. Limits are keyed by authenticated user, not by API key — several keys on one account share the same counters. Each group below is an independent bucket, so spending the image budget does not affect the generation, media or stock budgets.
| Endpoint group | Limit | Routes |
|---|---|---|
| Image generation | 30/min | /generate/image, /generate/image-edit, /edit/image |
| Generation | 30/min | /generate/video, /generate/video/from-image, /generate/video-from-video, /generate/music, /generate/music/reference-audio, /generate/music/edit-section, /generate/music/import, /generate/music/extend, /generate/music/cover, /generate/speech, /generate/sound, /generate/3d, /generate/elements, /generate/first-last-frame, /generate/lipsync, /generate/creative-director, /generate/{id}/cancel, /edit/video, /video/trim, /analyze/video, /chat, /media/upload, /visual-dna (create / update), /visual-dna/character-sheet, /voices/clone, /voices/import-elevenlabs, /skills and /agents (write), /lipsync-plans/* (write) |
| Audio separation | 10/min | /audio/separate, /audio/clean-dialogue, /audio/ambience |
| Transcription | 100/min | /transcribe |
| Video editor | 5/min | POST /editor/sessions, POST /editor/exports |
| URL downloads | 5/min | POST /downloads |
| Media library & lifecycle | 120/min | /media (list/get/delete/favorite/restore/permanent/move), /media/folders/*, /media/stats, /media/upload-ticket, /chat/conversations, /chat/conversations/{sessionId}/messages, /moodboards/*, /docs/*, /review/*, /color-palettes/*, /visual-dna/folders/*, /visual-dna/{id}/folder, /visual-dna/{id}/import, /skills and /agents (list), /sessions (list / get generations / rename / delete / restore), /projects/{id}/assets/*, /voices/{id} (delete), /video/trim/{jobId}, /editor/schema, /editor/sessions (list / get / update), /downloads/{jobId}, /lipsync-plans/* (read), PUT /apps/{appId}/fonts |
| Bulk media & session ops | 20/min | /media/bulk/*, /media/folders/{id}/move-contents, /sessions/move, /sessions/{id}/project, /sessions/{id}/generations/move, /sessions/{id}/split, /sessions/organize/undo/{operationId} |
| Music library (read) | 60/min | /music-library/search, /music-library/analyze-script, /music-library/catalog, /music-library/facets, /music-library/track/* |
| Music library (paid) | 5/min | /music-library/clean/{trackId}, /music-library/import |
| Stock library (read) | 60/min | /stock/sources, /stock/categories, /stock/collections, /stock/search, /stock/asset/* |
| Stock script analysis | 20/min | /stock/analyze-script |
| Stock import | 20/min | /stock/import |
| Project creation | 10/min | POST /projects, POST /projects/{id}/duplicate, POST /projects/{id}/transfer, /project-transfers/{id} (accept / decline / cancel) |
| Color palette analysis | 10/min | /color-palettes/analyze (on top of the 120/min media bucket) |
| Flows | 180/min | /flows/* |
| Trends | 120/min | /trends/*; estimate and run calls are additionally capped at 20/min |
| Morphious styles | 120/min | /morphious-styles/* |
The Blender, Adobe and DaVinci Resolve bridges (/blender/*, /adobe/*, /resolve/*) apply their own discovery, per-command and hourly limits.
No per-endpoint limiter is applied to GET /models, GET /account/credits, GET /account/plans, GET /presets, GET /cinematic-presets, GET /voices, GET /projects, GET /projects/{id}, GET /project/lightweight, GET /project-transfers, GET/DELETE /visual-dna/{id}, GET /visual-dna, POST /generate/music/composition-plan, the project update / archive / context / profile routes, or status polling (GET /generate/{id}/status, GET /generate/creative-director/{id}/status). Those are bounded only by the global ceiling — poll freely, but cache GET /models rather than calling it per generation.
Rejected requests do not spend the per-endpoint buckets. Those limiters skip any request that ends in an error status, so a 400 or a 429 retry does not burn the window it was rejected by. The exceptions, which count every request, are the global ceiling and the project-creation, color-palette analysis, flows, trends and morphious-styles limiters.
The music "paid" bucket is shared with the web app — the same counter backs both surfaces, so cleaning or importing tracks inside app.kolbo.ai consumes the same 5/min the API sees.
When a generation / media / stock / music / editor / download limiter fires, the body names the limit and the wait:
{
"status": false,
"error": "Too many requests to sdk — the limit is 30 per 60s. Retry in 42s.",
"message": "You have hit the rate limit of 30 requests per 60 seconds. Wait 42s, then retry.",
"limit": 30,
"window_seconds": 60,
"retryAfter": 42
}retryAfter mirrors the Retry-After header — the real seconds until the window resets, not a fixed 60.
POST /color-palettes/analyze emits a different body again:
{
"success": false,
"code": "RATE_LIMITED",
"message": "You are doing that too quickly. Please slow down and try again.",
"retryAfter": 60
}The project-creation limiter emits a fourth shape, with no retryAfter and no code:
{
"status": false,
"message": "Too many project creation requests. Please wait a moment and try again."
}The flows, trends and morphious-styles limiters return the rate-limit library's default plain-text body (Too many requests, please try again later.), not JSON.
At least five different 429 bodies exist on /api/v1 — the global-ceiling shape, the per-endpoint shape, the Color DNA shape, the project-creation shape, and plain text. Only two carry a code, the field holding the message differs, retryAfter is absent from two of them, and one is not JSON. Detect rate limiting by HTTP status 429, then fall back to retryAfter (or the Retry-After / RateLimit-Reset header) for the backoff.
All limiters emit standard RateLimit-* headers. Read RateLimit-Reset rather than assuming a fixed backoff.
Generation States
When polling status, the state field can be:
| State | Description |
|---|---|
pending | Generation is queued (Creative Director only) |
processing | Generation is in progress |
completed | Generation finished, result field contains URLs. On Creative Director, a batch where only some scenes succeeded also reports completed — check each entry in scenes |
failed | Generation failed, error field explains why |
cancelled | Generation was cancelled (a generation deleted in the app also reports cancelled) |
Handling Failures
When state is failed, the status response carries both a flat error string and a structured failure object. Branch on the structured fields rather than string-matching the message:
| Field | Description |
|---|---|
failure.message | Same text as the top-level error. |
failure.category | Cause class (e.g. a content-policy refusal vs a transient upstream error). null when the provider gave no classification. |
failure.code | Provider-specific error code, when one was returned. null otherwise. |
failure.retryable | true / false when known, null when the provider gave no signal. Only retry automatically on an explicit true. |
failure.severity | Severity hint. null when unset. |
GET /generate/creative-director/{id}/status does not return a failure object: a failed batch carries only error, and each failed scene carries its own error inside scenes.
Credits reserved for a failed or cancelled generation are released, and anything already deducted is refunded.
Retrying a Submit That Never Answered
A submit whose response you never saw — a socket reset, a proxy timeout, a crashed worker — is the one retry you must not do blindly.
Generation endpoints have no idempotency key. There is no Idempotency-Key header and no client-supplied request id on any /v1/generate/*, /v1/edit/*, /v1/chat or /v1/transcribe call, so re-POSTing the same body creates a second, separately billed job. Only two protections exist anywhere in the API, and neither is general:
POST /v1/edit/imagerejects the same operation on the sameimage_urlfrom the same account and session within 30 seconds with409 DUPLICATE_REQUEST. The check is best-effort (it is held in memory on the server that took the first request), and the SDK response does not include the id of the job already running — find it with the reconciliation steps below.- The two paid music-library routes (
POST /v1/music-library/clean/:trackId,POST /v1/music-library/import) are idempotent through therequestIdyou supply — send the same one when retrying those.
Everything else will happily run twice.
So reconcile before you retry:
- Poll
GET /v1/media?category=ai&sort=created_desc&page_size=5(addproject_idif you sent one). A job that started shows up there once it finishes. - Or diff
GET /v1/account/credits— a reservation for an in-flight generation is already unavailable even thoughtotalstill counts it. - Only submit again once you are satisfied nothing landed.
The same rule applies at the other end of the lifecycle: never re-POST because your poll window expired. The job is still running server-side and its credits are already committed. Keep the generation_id and resume polling — and if you lost the id, find the output with GET /v1/media/:id (it accepts a generation id, not just a media id) or by listing GET /v1/media. See Media Library.
Concurrency
No cap on simultaneously running generations is published. The limits in the table above are request-rate limits (requests per 60-second window), not in-flight-job limits: clearing the 30/min generation bucket says nothing about how many of those jobs may be running at once.
Bound your own fan-out instead. In practice the per-minute limit is the effective ceiling — at 30 starts/min against jobs that run for minutes, a naive batch loop hits 429 long before anything else. Queue work behind the per-endpoint limit, back off on 429 using retryAfter or the RateLimit-Reset header, and remember every key on one account shares the same per-endpoint counters (only the 5,000/min global ceiling is per key).
Timeouts
There is no fixed timeout to code against — generation time varies by model, resolution, and output duration. Size your polling deadline from the model itself:
- Read
estimated_duration_secondsfromGET /api/v1/modelsfor the model you are about to use. It is a real wall-clock measurement at the model's base settings. - Scale it up for a longer output duration or a higher resolution than the base, and add generous headroom.
- If
estimated_duration_secondsisnull, the model publishes no estimate — poll until you get a terminalstaterather than guessing.
A generation that has not reached completed, failed, or cancelled is still running. Keep polling, or call POST /api/v1/generate/{generation_id}/cancel to stop it and recover the reserved credits.