Errors & Rate Limits
Error handling and rate limiting for the Kolbo Developer API.
Error Format
There is no single error envelope. Two 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, and generation failures:
{
"success": false,
"error": "Human-readable error message",
"code": "ERROR_CODE"
}2. Errors that fall through to the global error middleware — unhandled exceptions, Mongoose validation, malformed IDs, upload limits, timeouts:
{
"status": false,
"message": "Human-readable error message",
"errors": ["Human-readable error message"]
}Shape 2 has no code field. The HTTP status is whatever the thrown error carried, defaulting to 500.
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 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, 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 returns a 2xx with success: false, and the global error middleware uses status: false / message rather than success: false / error.
HTTP Status Codes
| Status | Meaning |
|---|---|
| 200 | Success |
| 201 | Created — POST /api/api-keys, POST /v1/media/folders, POST /v1/generate/image-edit |
| 202 | Accepted — an async job was queued. Used by POST /v1/edit/image, /v1/edit/video, /v1/generate/speech, /sound, /lipsync, /elements, /first-last-frame, /creative-director, and POST /v1/video/trim |
| 400 | Bad request — missing or invalid parameters |
| 401 | Unauthorized — invalid, expired, revoked or wrongly-formatted API key; also a restricted or soft-deleted account |
| 403 | Forbidden — insufficient credits, read-only key, unverified email, or no permission on the target resource |
| 404 | Not found |
| 409 | Conflict — the generation is not in a cancellable state (CANNOT_CANCEL) |
| 422 | Unprocessable — well-formed request, but the script analysis produced no usable searches |
| 429 | Rate limited |
| 500 | Server error, and the default for anything that reaches the global error middleware without its own status (malformed ObjectId, multipart size-limit rejection, unhandled exception) |
| 502 / 503 / 504 | Upstream music / stock vendor failed, is unconfigured, or timed out |
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. |
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. |
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). |
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 or 500. |
TRACKING_ERROR | usually 200 | The generation started but the SDK could not capture its id, so it cannot be polled. The status is whatever the underlying controller already set, so this arrives as a 2xx with success: false — check the body, not just the status. Treat as a failure and retry. |
INVALID_VIDEO_URL | 400 | video_url is not a string, or failed URL validation. |
VIDEO_REQUIRED | 400 | A source video is mandatory for this operation. Upload one via POST /api/v1/media/upload first. |
VOICE_REQUIRED / VOICE_NOT_FOUND | 400 / 404 | Speech generation needs a voice, or the given voice id does not exist on your account. |
MOTION_CONTROL_INPUTS | 400 | The motion-control inputs supplied are incomplete or inconsistent. |
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. |
CANNOT_CANCEL | 409 | POST /generate/{id}/cancel on a generation that has already reached a terminal state. |
MUSIC_LIBRARY_* | 404 / 422 / 429 / 502 / 503 / 504 | Stock-music vendor errors: _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. |
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.
Two 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, or an account pending permanent deletion) returns{ "message": "...", "tokenExpired": false }.- Anything that falls through to the global error middleware returns shape 2 —
{ "status": false, "message": "...", "errors": [...] }— with nocode. Mongoose validation failures, malformed ObjectIds, multipart size-limit rejections and unhandled exceptions all land here, and all of them report500unless the thrown error set its own status.
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 emit { "success": false, "error": "...", "code": "API_KEY_READ_ONLY" }. Read error ?? message.
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 does not exist is not caught by the SDK layer — the only check is typeof model === 'string'. It is passed through to the underlying generation controller, whose error and HTTP status are relayed as-is, falling back to GENERATION_ERROR when that controller supplied no code of its own.
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" })
});
const data = await response.json();
// `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) {
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"}
)
data = response.json()
# `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:
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.
Exceeding it 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"
}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 media or stock budgets.
| Endpoint group | Limit | Routes |
|---|---|---|
| Image generation | 30/min | /generate/image, /generate/image-edit, /edit/image |
| Other generation | 10/min | /generate/video, /generate/video/from-image, /generate/video-from-video, /generate/music, /generate/speech, /generate/sound, /generate/3d, /generate/elements, /generate/first-last-frame, /generate/lipsync, /generate/creative-director, /generate/{id}/cancel, /edit/video, /video/trim, /chat, /chat/conversations, /chat/conversations/{sessionId}/messages, /media/upload, /visual-dna (create), /visual-dna/character-sheet, /voices/clone, /voices/import-elevenlabs, /moodboards (list/get), /agents (write) |
| Transcription | 100/min | /transcribe |
| Media library & lifecycle | 120/min | /media (list/get/delete/favorite/restore/permanent/move), /media/folders/*, /media/stats, /media/upload-ticket, /docs/*, /color-palettes/*, /visual-dna/folders/*, /visual-dna/{id}/folder, /agents (list), /sessions (list), /voices/{id} (delete), /moodboards (write), /video/trim/{jobId} |
| Bulk media ops | 20/min | /media/bulk/*, /media/folders/{id}/move-contents, /sessions/{id}/project |
| 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 |
| Color palette analysis | 10/min | /color-palettes/analyze |
No per-endpoint limiter is applied to GET /models, GET /account/credits, GET /presets, GET /cinematic-presets, GET /voices, GET /projects, GET /project/lightweight, GET/DELETE /visual-dna/{id}, GET /visual-dna, 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 5,000/min global ceiling — poll freely, but cache GET /models rather than calling it per generation.
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. Switching surfaces does not multiply your vendor-spend allowance.
When a per-endpoint generation / media / stock / music limiter fires, the body is:
{
"status": false,
"error": "Too many generation requests",
"message": "You are sending too many generation requests. Please wait a moment before trying again.",
"retryAfter": 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
}And POST /projects 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."
}Four different 429 bodies exist on /api/v1 — the global-ceiling shape, the per-endpoint shape, the Color DNA shape, and the project-creation shape. Only two of the four carry a code, the field holding the message differs, and retryAfter is absent from one of them. Detect rate limiting by HTTP status 429, then fall back to retryAfter (or the 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 |
failed | Generation failed, error field explains why |
cancelled | Generation was 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. |
failure.provider | Which upstream provider produced the failure. null when unset. |
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 sameimage_url+operationfrom the same key within 30 seconds with409 DUPLICATE_REQUEST, and returns anexistingGenerationIdyou can poll instead.- The two paid music-library routes (
POST /v1/music-library/clean/:trackId,POST /v1/music-library/import) are idempotent through the request id you supply — send a stable 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 10/min 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 10 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.