Polling & Cancellation
The async completion contract for every Kolbo generation endpoint — how you learn a job finished, what the result payload looks like per family, how failures surface, and how to cancel.
Every POST /api/v1/generate/* endpoint is asynchronous. It returns immediately with a generation_id and a poll_url, and you read the result from the generic status endpoint below. The same two endpoints work for images, image edits, video, image-to-video, video-to-video, elements, first/last frame, lipsync, music, speech, sound, transcription, 3D and Creative Director — and for chat messages started with POST /api/v1/chat, which is the one endpoint that names its handle message_id instead of generation_id.
How You Learn a Generation Finished
Polling is the only completion mechanism. Kolbo never calls you back. There is no webhook, no callback_url parameter on any request body, no server-sent events and no long-poll. After the POST, the only correct pattern is a loop against GET /api/v1/generate/{generation_id}/status until state is completed, failed or cancelled.
This is by design, not an omission:
| Mechanism | Available to API consumers? | Why |
|---|---|---|
GET /api/v1/generate/{id}/status | Yes — this is the contract | Returns state, progress, result, credits and failure detail |
Webhook / callback_url | No | No request body on any generation endpoint reads a callback URL, and no route delivers outbound notifications |
WebSocket generation:progress | No — not part of the contract | Every generation started with an API key is registered as SDK-originated at submit time, and the shared progress emitter drops its events. Coverage is not total: a handful of in-app emit sites (Creative Director scene events, /v1/edit/image progress, some per-image and specialised music events) write to Socket.IO directly and never consult that registry |
| Server-sent events / long-poll | No | The status endpoint is a plain request/response. There is no wait query parameter |
An API key can authenticate a Socket.IO connection — that door was opened for an unrelated realtime feature. Do not build on it. The events you would see there are the web app's internal transport: undocumented, unversioned, suppressed for most API-started generations and free to change or disappear without notice. Poll.
poll_url in the start response is returned without the /api prefix — e.g. /v1/generate/<id>/status. Prepend https://api.kolbo.ai/api to call it.
What the Start Response Gives You
Every generation endpoint that feeds this contract answers with the same envelope:
| Field | Type | Notes |
|---|---|---|
success | boolean | true on an accepted submit |
generation_id | string | The poll handle. POST /api/v1/chat returns message_id instead — same value, different key |
type | string | Tracked generation type |
model | string | null | Echo of what you asked for — "auto" when Smart Select will pick, null when you omitted model on an endpoint with its own defaults. Not the model that ran; read result.model on completion |
credits_charged | number | null | Estimate at submit time, null for most families. The authoritative figure is credits_used on the completed poll |
poll_url | string | /v1/generate/{id}/status, without the /api prefix |
poll_interval_hint | number | Seconds — see Interval |
session_id | string | Present when the generation landed in a session |
project_id | string | Present when the generation landed in a project |
Creative Director answers with its own shorter envelope (success, generation_id, type, scene_count, model, poll_url, poll_interval_hint) at HTTP 202, and its poll_url points at the dedicated route. See Creative Director.
Poll Status
Endpoint
GET /api/v1/generate/:generationId/statusNo request body. generationId is the generation_id from the start response — a Mongo ObjectId for almost every type, or a gen_... string for POST /api/v1/edit/image. Anything else returns 400 with "Invalid generation ID format".
This route is not covered by the generation rate limiter, so a normal polling loop will not be throttled. Respect the poll_interval_hint (seconds) returned by the start endpoint.
"Not throttled" is per-endpoint, not unlimited: the global safety net of 5,000 requests/minute per API key still counts every poll. A worker watching hundreds of jobs in a tight loop can reach it. Pace by the hint rather than by how fast the endpoint answers. See Global ceiling.
Response Fields
| Field | Type | When | Description |
|---|---|---|---|
success | boolean | always | true for any resolved status — including state: "failed". It reports that the poll worked, not that the generation did. |
generation_id | string | always | The id you polled with |
type | string | always | Tracked generation type (image, image_edit, global_image_edit, video, music, three_d, creative_director, …) |
state | string | always | processing | completed | failed | cancelled, plus pending for a Creative Director batch that has not started its scenes yet |
progress | number | always | 0-100. Falls back to 100 on completion / 0 otherwise when the underlying model reports no progress. Coarse — see below. |
result | object | state: "completed" | Type-specific payload — see Where Is My Output URL |
credits_used | number | state: "completed" | Real multiplier-adjusted credits charged. Absent when no credit record is linked. |
credits_breakdown | array | state: "completed" | One entry per credit record: model, amount, base, final, duration_multiplier, pricing |
error | string | state: "failed" | Human-readable failure message |
failure | object | state: "failed", non-Creative-Director | message, category, code, retryable, severity, provider — any field may be null |
scenes | array | Creative Director only | Per-scene state. Replaces result, and a Creative Director body carries no credits_used / credits_breakdown — see Creative Director |
A failed Creative Director batch carries error but no failure object — that envelope is built only on the generic path. Read scenes[].error for the real cause.
progress is not a smooth percentage and must never be used as a completion test. Image generation persists coarse checkpoints (5, 15, 20, 25, 35, 50, 55, …) whose order depends on which branch of the pipeline ran; 3D stays at 0 for the entire run and jumps to 100; several pipelines set progress: 100 on the failure path. Branch on state, and use progress only to drive a progress bar.
Poll with the id and the key that started the generation. Status is scoped to the calling user: an id you do not own returns 404 Generation not found, not a permission error. Generations created in the Kolbo web app are not pollable through this route.
Example
curl https://api.kolbo.ai/api/v1/generate/6612f0a1b2c3d4e5f6a7b8c9/status \
-H "X-API-Key: kolbo_live_..."Response (Processing)
{
"success": true,
"generation_id": "6612f0a1b2c3d4e5f6a7b8c9",
"type": "video",
"state": "processing",
"progress": 42
}A processing body has exactly these five keys. There is no result key at all — not an empty one.
Response (Completed)
{
"success": true,
"generation_id": "6612f0a1b2c3d4e5f6a7b8c9",
"type": "video",
"state": "completed",
"progress": 100,
"result": {
"urls": ["https://media.kolbo.ai/videos/..."],
"thumbnail_url": "https://media.kolbo.ai/videos/...jpg",
"model": "MODEL_IDENTIFIER",
"created_at": "2026-07-20T10:11:12.000Z"
},
"credits_used": 84,
"credits_breakdown": [
{
"model": "MODEL_IDENTIFIER",
"amount": 84,
"base": 42,
"final": 84,
"duration_multiplier": 2,
"pricing": null
}
]
}Errors
| Status | Body | Cause |
|---|---|---|
| 400 | Invalid generation ID format | The id is neither an ObjectId nor a gen_... string |
| 404 | Generation not found | No generation with that id belongs to your API key |
| 404 | Generation document not found | The generation was tracked but its underlying record no longer exists |
The State Machine
state is a normalised value. Whatever the underlying model or the upstream provider calls a status — queued, initializing, STARTING, UPLOADING, PROCESSING, error, FAILED, streaming, deleted — it is collapsed into one of these before it reaches you. Exactly three raw values map to completed (completed, streaming), failed (error, failed) and cancelled (cancelled, deleted); an unrecognised or missing status falls through to processing.
state | Terminal? | Meaning | What to do |
|---|---|---|---|
pending | No | Creative Director only. The batch exists but the AI has not planned the scenes yet, so scenes is [] and progress is 0. | Keep polling |
processing | No | Running, queued, or uploading the finished file. Also the fallback for any status the API does not recognise. | Keep polling |
completed | Yes | Finished successfully. The only state that carries result, credits_used and credits_breakdown. | Read result |
failed | Yes | Permanently failed — including anything the server's own watchdog force-failed after its deadline. Carries error and (outside Creative Director) failure. HTTP is still 200. | Read error, do not re-poll |
cancelled | Yes | Cancelled through POST /generate/{id}/cancel from any surface — this API, the MCP tools or the Kolbo web app — or the underlying record was soft-deleted (deleted normalises to cancelled). A server-side timeout does not land here; it reports failed. | Stop — see the warning below |
cancelled carries neither result nor error/failure — the body is just success, generation_id, type, state, progress. A loop that only tests for completed and failed will poll a cancelled generation forever. Treat all three terminal states explicitly.
Music has an intermediate completed. Suno returns temporary provider stream URLs before the permanent files are stored, and that intermediate status normalises to state: "completed" with progress: 70. Those URLs play, but they are not on the Kolbo CDN and they expire. For POST /api/v1/generate/music only, treat the result as final when state === "completed" and progress === 100; otherwise keep polling to receive the permanent CDN URLs.
Chat holds at processing on purpose. The chat pipeline flips its message record to complete before the streamed tokens are persisted, so the status route deliberately keeps reporting state: "processing" until text or media actually lands — a naive "stop at the first completed" poller would otherwise read an empty reply. A genuinely empty turn is released as completed after 90 seconds. You do not need to do anything: just keep polling until state leaves processing, and do not add a chat-specific retry on empty result.content. This is the second per-type completion carve-out alongside the music rule above. See Chat.
Where Is My Output URL
result is always attached under state: "completed" and never before. Its shape depends on the generation family. Every URL in it is rewritten onto a Kolbo CDN host before it is returned.
Treat the host as opaque. More than one CDN host appears across these docs — the samples on this page use media.kolbo.ai, while Media Library and Video Editing show cdn.kolbo.ai. Never allow-list, pattern-match, or rebuild a URL from parts: read the exact string out of result and use it verbatim.
Three things about those URLs, because this is the page that promises them:
- They are plain links. No query string, no signature, no embedded credentials. Fetch them with an ordinary
GET, and do not attachX-API-Key— that header belongs toapi.kolbo.ai, not to the CDN host. - No lifetime is published. Nothing guarantees an asset stays reachable at the same address indefinitely, and
DELETE /v1/media/:id/permanentremoves the underlying file outright. If an asset matters, download the bytes into your own storage instead of persisting the URL as your system of record. - A lost URL is recoverable without re-generating.
GET /v1/media/:idaccepts a generation id as well as a media id — see Recovering a lost id.
| Endpoint | type on the status body | Output lives in |
|---|---|---|
POST /v1/generate/image | image | result.urls[] |
POST /v1/generate/image-edit | image_edit | result.urls[] |
POST /v1/edit/image | global_image_edit | result.urls[] |
POST /v1/generate/video | video | result.urls[] |
POST /v1/generate/video/from-image | video_from_image | result.urls[] |
POST /v1/generate/elements | elements | result.urls[] |
POST /v1/generate/first-last-frame | first_last_frame | result.urls[] |
POST /v1/generate/video-from-video | video_from_video | result.urls[] |
POST /v1/generate/lipsync | lipsync | result.urls[] |
POST /v1/edit/video | global_video_edit | result.urls[] (+ result.download_url) |
POST /v1/generate/music | music | result.urls[] and result.tracks[] |
POST /v1/generate/speech | speech | result.urls[] |
POST /v1/generate/sound | sound | result.urls[] |
POST /v1/transcribe | transcription | result.text, result.srt_url, result.txt_url |
POST /v1/generate/3d | three_d | result.urls[] |
POST /v1/chat | chat | result.content (text), plus result.image_urls[] / video_urls[] / audio_urls[] only when the turn produced media — no urls key |
POST /v1/generate/creative-director | creative_director | scenes[].image_urls / scenes[].video_urls — no result |
urls is always an array of strings, never a bare URL — even when a single asset was produced, and even when you asked for num_images: 1. Read urls[0], do not assume a string.
Images
Applies to POST /v1/generate/image, POST /v1/generate/image-edit and POST /v1/edit/image.
{
"result": {
"urls": [
"https://media.kolbo.ai/kolbo/images/6890...-0.png",
"https://media.kolbo.ai/kolbo/images/6890...-1.png"
],
"thumbnail_url": "https://media.kolbo.ai/kolbo/images/6890...-0.png",
"prompt_used": "a neon-lit ramen bar in the rain, shallow depth of field",
"model": "MODEL_IDENTIFIER",
"created_at": "2026-07-27T09:14:02.113Z",
"visual_dna": [{ "id": "688c...", "name": "Maya", "thumbnail_url": "https://media.kolbo.ai/..." }],
"moodboard": { "id": "6881...", "name": "Q3 Launch" },
"preset": { "id": "6877...", "name": "Cinematic Portrait" },
"cinematic_presets": [{ "dimension": "lighting", "name": "Golden Hour" }]
}
}| Field | Type | Always? | Notes |
|---|---|---|---|
urls | string[] | yes (may be []) | All images from the request, delivered together |
thumbnail_url | string | null | yes | Literally urls[0] — not a separate smaller asset |
prompt_used | string | yes | The post-enhancement prompt when enhance_prompt was on |
model | string | null | yes | The model that actually ran after Smart Select routing or provider fallback. The router alias (auto) is never returned here. |
model_name | string | only when resolved | Human display name, present only for Smart-Select-resolved runs |
created_at | string (ISO 8601) | yes | |
visual_dna | array | only when visual_dna_ids were sent | { id, name, thumbnail_url } |
moodboard | object | only when moodboard_id was sent | { id, name } |
preset | object | only when preset_id was sent | { id, name } |
cinematic_presets | array | only when cinematic was sent |
POST /v1/edit/image returns a gen_... string id and its status is resolved through your SDK session, returning the newest edit in that session. Concurrent /edit/image calls therefore report the same document. Run mechanical edits serially, or use POST /v1/generate/image-edit when you need concurrency. See Image Editing.
Video
Applies to /generate/video, /generate/video/from-image, /generate/elements, /generate/first-last-frame, /generate/video-from-video and /generate/lipsync.
{
"result": {
"urls": ["https://media.kolbo.ai/.../output.mp4"],
"thumbnail_url": "https://media.kolbo.ai/.../thumb.jpg",
"duration": 5,
"aspect_ratio": "16:9",
"prompt_used": "a neon-lit alley in the rain, slow dolly in",
"model": "MODEL_IDENTIFIER",
"created_at": "2026-07-27T09:14:02.113Z"
}
}| Field | Type | Notes |
|---|---|---|
urls | string[] | One video in practice. Still an array. |
thumbnail_url | string | null | The generated poster frame, else the first source image. null for video-from-video. |
duration | number | string | null | Union type. A number when the pipeline measured it, a string when it falls back to the requested duration (/generate/video commonly returns "5"). Always null for lipsync. Coerce it. |
aspect_ratio | string | null | |
prompt_used | string | Omitted entirely for lipsync, which has no prompt |
model | string | null | Resolved model. null for lipsync. |
created_at | string (ISO 8601) |
Creative-input marks (visual_dna, preset, cinematic_presets) are attached where the endpoint supports them: from-image, elements and first-last-frame can return all three; /generate/video can return preset and cinematic_presets; video-from-video and lipsync return none.
POST /v1/edit/video (global_video_edit) uses a different shape — urls, plus download_url for the production-quality alternate render, edit_type, duration and model. It carries no thumbnail, aspect ratio or prompt. See Video Editing.
Music, Speech and Sound
{
"result": {
"urls": [
"https://media.kolbo.ai/music/.../track-1.mp3",
"https://media.kolbo.ai/music/.../track-2.mp3"
],
"tracks": [
{ "title": "Neon Rain", "duration": 187.2, "thumbnail_url": null, "model": "MODEL_IDENTIFIER" },
{ "title": "Neon Rain", "duration": 191.0, "thumbnail_url": null, "model": "MODEL_IDENTIFIER" }
],
"title": "Neon Rain",
"duration": 187.2,
"lyrics": "[Verse 1]\n...",
"prompt_used": "dreamy synthwave, analog pads",
"model": "MODEL_IDENTIFIER",
"created_at": "2026-07-27T09:12:44.101Z"
}
}| Endpoint | Result keys |
|---|---|
/generate/music | urls[] (music normally returns two tracks), tracks[] (title, duration, thumbnail_url, model), title, duration, lyrics, prompt_used, model, created_at. tracks[].thumbnail_url is always null — the stored track record keeps cover art under a different field name that this endpoint does not read. |
/generate/speech | urls[] (exactly one), voice (the resolved voice id, not the name you passed), duration (null on this path), model, created_at |
/generate/sound | urls[] (exactly one), duration (the length you requested; null in auto mode, not the measured length), prompt_used, model, created_at |
Transcription
{
"result": {
"text": "Welcome back to the show. Today we are talking about ...",
"srt_url": "https://media.kolbo.ai/stt/.../transcript.srt",
"txt_url": "https://media.kolbo.ai/stt/.../transcript.txt",
"word_by_word_srt_url": "https://media.kolbo.ai/stt/.../word-by-word.srt",
"srt_content": "1\n00:00:00,000 --> 00:00:03,120\nWelcome back to the show.\n\n",
"duration": 412.5,
"audio_url": "https://media.kolbo.ai/uploaded-audio/.../episode-42.mp3",
"model": null,
"created_at": "2026-07-27T09:31:12.550Z"
}
}srt_url, txt_url and word_by_word_srt_url are null when generate_srt was false. srt_content carries the full SRT inline. audio_url is the stored copy of your source file. model is null for transcription, and credits_used / credits_breakdown are not returned for /v1/transcribe — its credit records are not linked to the generation. See Transcription.
3D
{
"result": {
"urls": [
"https://media.kolbo.ai/.../model.glb",
"https://media.kolbo.ai/.../model.glb",
"https://media.kolbo.ai/.../model.fbx",
"https://media.kolbo.ai/.../model.obj",
"https://media.kolbo.ai/.../model.usdz"
],
"thumbnail_url": "https://media.kolbo.ai/.../preview.png",
"mode": "single",
"prompt_used": "a ceramic mug",
"model": null,
"created_at": "2026-07-27T10:14:02.113Z"
}
}One 3D generation produces every requested format in a single urls array — there is no separate generation per format. The GLB commonly appears twice (once as the primary model URL, once from the format map); de-duplicate client-side. result.model is null for 3D. See 3D Generation.
Chat
A chat turn started with POST /api/v1/chat polls through the same status route, but its result is text-shaped — there is no urls array.
{
"result": {
"content": "Quantum computing uses quantum bits (qubits)...",
"reasoning_content": null,
"model": "MODEL_IDENTIFIER",
"created_at": "2026-07-27T09:12:44.101Z"
}
}image_urls, video_urls and audio_urls are added only when the turn actually produced media. The status route deliberately holds a chat turn at processing until text or media lands — the message record flips to complete before the streamed tokens are persisted — with a 90-second escape hatch for a genuinely empty reply. See Chat.
Creative Director
Creative Director batches carry a scenes[] array instead of a flat result, and no credits_used / credits_breakdown. Passing a Creative Director generation_id to the generic route transparently delegates to the dedicated route, so both of these return the same body:
GET /api/v1/generate/:generationId/status
GET /api/v1/generate/creative-director/:id/statusSee Creative Director for the scene payload.
Failure
A failed generation is a successful poll
The status endpoint returns HTTP 200 with success: true when a generation fails. The failure is in state.
{
"success": true,
"generation_id": "6612f0a1b2c3d4e5f6a7b8c9",
"type": "video",
"state": "failed",
"progress": 0,
"error": "Prompt was rejected by the provider's content policy",
"failure": {
"message": "Prompt was rejected by the provider's content policy",
"category": "content_policy",
"code": null,
"retryable": false,
"severity": null,
"provider": null
}
}| Field | Type | Reliability |
|---|---|---|
error | string | Always present when state: "failed". Falls back to the literal "Generation failed" when the pipeline stored no message. This is the field to log and branch on. |
failure.message | string | Same value as error |
failure.category | string | null | Best-effort. Populated on the main image-generation path and on server-side timeouts; frequently null for image edits, video and 3D. |
failure.code | string | null | Best-effort. GENERATION_TIMEOUT on the wall-clock timeout path. |
failure.retryable | boolean | null | Best-effort, and null when unknown. true on timeouts. |
failure.severity | string | null | Best-effort |
failure.provider | string | null | Best-effort |
Treat every failure.* field except message as an optional hint. Any of them can be null on a perfectly ordinary failure, and which ones are populated varies by pipeline. Build your retry logic on state plus the error string; use failure.retryable === false as a signal to change the request rather than as a guarantee.
Branch on failure.retryable before re-submitting: false means the request needs to change (rephrase the prompt, swap the model); true means a plain retry is safe. null means the cause is unknown — a single retry is reasonable, a retry loop is not.
Synchronous errors on the POST
Rejections that happen before a generation exists come back as a non-2xx on the submit call, in the SDK error envelope. No generation is created and no credits move.
{ "success": false, "error": "prompt is required and must be a non-empty string", "code": "INVALID_PROMPT" }| Status | Code | Cause |
|---|---|---|
| 400 | INVALID_PROMPT | prompt (or text) missing, empty, or not a string |
| 400 | INVALID_MODEL | model sent as an array or object instead of a string |
| 400 | MODEL_REQUIRED | video-from-video — auto model selection is not supported there |
| 400 | (no code) | Per-endpoint validation: missing image_url, missing operation, missing aspect_ratio for reframe, invalid moodboard_id, and similar |
| 400 | NSFW_BLOCKED | NSFW_BLOCKED_LOCKED | Prompt blocked before any provider call |
| 403 | INSUFFICIENT_CREDITS | Pre-flight balance gate on the submit call — your balance is below the minimum for that generation type |
| 400 | SDK_PROJECT_INVALID_ID | project_id is not a valid ObjectId |
| 404 | SDK_PROJECT_NOT_FOUND | No project with that id is visible to you (same answer whether it is missing or you are not a member) |
| 403 | SDK_PROJECT_ACCESS_DENIED | You lack edit permission on the project_id you passed |
| 429 | (none) | Generation rate limit — { "status": false, "error": "Too many generation requests", "retryAfter": 60 } |
| 500 | TRACKING_ERROR | The job started but its id could not be recorded. Rare — treat as a failed submit. |
| 4xx / 5xx | GENERATION_ERROR | Any other error raised by the underlying pipeline |
Full catalogue: Errors & Rate Limits.
A submit that never answered
The dangerous case is earlier than an expired poll window: the POST died at the socket — a reset, a proxy timeout, a crashed worker — and you never learned whether a job was created.
No generation endpoint has an 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. Two narrow exceptions exist and neither is general: POST /v1/edit/image rejects the same image_url + operation from the same key within 30 seconds with 409 DUPLICATE_REQUEST (and hands back an existingGenerationId you can poll instead), and the two paid music-library routes are idempotent through the request id you supply.
Reconcile before you retry:
GET /v1/media?category=ai&sort=created_desc&page_size=5(addproject_idif you sent one) — a job that did start 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.
Full treatment: Retrying a Submit That Never Answered.
Credits on failure
A failed generation does not cost credits. Credits are either reserved before the provider call and released on failure, or deducted only after a successful result, depending on the endpoint. Either way nothing is charged for work that did not produce output.
Because there is no charge, a failed poll carries no credits_used and no credits_breakdown — those two fields only ever appear alongside state: "completed". Do not read a missing credits_used as "free"; read it as "not completed, or no credit record linked". The real, multiplier-adjusted cost is always the credits_used on the completed poll — the credits_charged field on the start response is an estimate at best and is null for most families.
Partial Results
Almost nothing streams. For every single-asset family, result is attached only at the terminal completed transition — the output is written in one atomic step, so there is no window in which you can read half of it.
| Family | Partial reads? |
|---|---|
Images (num_images 1-4, all angles, all variants) | No. Every image lands together in one urls array. You cannot read image 1 of 4 early. |
| Video, lipsync, video edits | No. urls appears only on completion — including during the provider-side upload phase, which still reports processing. |
| Speech, sound | No. |
| Transcription | No. The transcript is held back until the files are stored, even though progress climbs to 99. |
| 3D | No. All formats arrive together; progress sits at 0 then jumps to 100. |
| Chat | No. There is no token streaming on the public API, and the status route holds the turn at processing until text or media lands — so you never read a half-written reply. See the chat callout in The State Machine. |
| Music | Partial, with a catch. See the streaming-URL warning in The State Machine. |
| Creative Director | Yes — genuinely incremental. |
Creative Director scenes
Creative Director is the one family built for incremental consumption. Each scene is persisted the moment it finishes, and the status route exposes image_urls / video_urls for any scene whose status is completed — even while the batch-level state is still processing. Those URLs are final.
The pattern is: poll, consume every scene you have not seen before, keep polling until state is terminal.
Two things to know:
scenesis[]on the first polls (state: "pending") while the AI plans the batch. That means "not planned yet", not "produced nothing".- A batch that ends
state: "completed"may still contain failed scenes — a batch is marked completed when at least one scene succeeded. Always iteratescenes[]and check eachstatusrather than trusting the batch state.
See Creative Director.
Recommended Polling Pattern
Interval
Use the poll_interval_hint (seconds) from the start response rather than hard-coding one. Its values today:
| Type | poll_interval_hint |
|---|---|
chat | 2 |
image, image_edit | 3 |
sound | 5 |
creative_director | 5 |
Everything else (video, music, speech, transcription, 3D, edit/image, edit/video) | 8 |
/v1/edit/image is tracked as global_image_edit, which is not in the 3-second bucket — it hints 8 despite being an image operation.
Overall timeout
A client-side timeout is a display decision, not a failure — the job keeps running on the server and no credits are lost. These are the windows the first-party Kolbo MCP client ships with, tuned against production run times:
| Endpoint | Client window |
|---|---|
/generate/image | 2 min |
/generate/image-edit | 2 min (4 min for multi-source or Visual-DNA-anchored edits) |
/edit/image | 5 min |
/generate/video, /generate/video/from-image | 15 min |
/generate/elements, /generate/lipsync, /generate/video-from-video, /edit/video | 10 min |
/generate/first-last-frame | 5 min |
/generate/music | 5 min |
/generate/speech, /generate/sound | 2 min |
/transcribe | 30 min |
/generate/3d | 15 min |
/generate/creative-director | 10 min image batches, 30 min video batches |
/v1/chat | 2 min normally, 4 min with web_search, 10 min with deep_think |
For reference, image generation measured over recent production traffic runs p50 ~51s, p90 ~148s, p99 ~277s.
The server runs its own watchdogs. When one fires it marks the generation failed (never cancelled) and releases the credit hold:
| Family | Server deadline |
|---|---|
| Images, image edits, 3D, speech, sound | 15 min |
| Text-to-video | 45 min (20 min on Sora 2 models) |
| Image-to-video | 20 min |
| Creative Director batch | 30 min — pending scenes are marked failed and the batch is finalised completed if any scene succeeded, else failed |
Backstop sweeper for anything still processing | 90 min |
Per-model estimates are available up front: GET /api/v1/models returns estimated_duration_seconds for every model. It is not echoed on the status response — there is no ETA field there.
Never re-POST when your poll window expires. The generation is still running and its credits are already committed; a second submit is a second paid job. Keep the generation_id and resume polling.
Recovering a lost id
Resuming assumes you still have the handle. If the id is gone — the worker died, the row was never written — do not re-generate. The media library is the recovery path:
| You have | Call |
|---|---|
The generation_id (or message_id) | GET /v1/media/:id — when no media item matches the id directly, the server falls back to a generation id lookup and returns that generation's lowest-index media item |
| Nothing but a rough idea of what you made | GET /v1/media?category=ai&sort=created_desc (add project_id, type, or search) |
See Finding a Generation's Output Again.
Concurrency
No cap on simultaneously running generations is published. The limits that apply to you are request-rate limits — requests per 60-second window — not in-flight-job limits, so clearing the 10/min generation bucket says nothing about how many of those jobs may run at once. If you are designing a batch worker, bound your own fan-out: in practice the per-minute submit limit is the effective ceiling, and every key on one account shares the same per-endpoint counters (only the 5,000/min global ceiling is per key). See Concurrency.
Transient errors
A failed status request is not a failed generation. Retry these with backoff instead of aborting the loop:
| Condition | Treat as |
|---|---|
Network failure (ECONNRESET, ECONNREFUSED, ETIMEDOUT, EPIPE, DNS, fetch TypeError) | Transient — retry |
No HTTP status at all (status 0 — request never completed) | Transient — retry |
408, 425, 429 | Transient — retry |
500, 502, 503, 504, 522, 524 | Transient — retry |
401, 403 | Fatal — fix the key or permissions |
400 | Fatal — malformed id |
404 | Fatal — wrong id, or not yours |
That list is exactly what the first-party Kolbo MCP client treats as transient. It backs off by interval × 1.5^n, capped at 30 seconds and at 5 doublings, resets the counter on any successful poll, and re-throws only after 30 consecutive failed polls.
Never map a failed status request to pending. This is the most expensive mistake we see in real integrations:
if (!res.ok) return { state: "pending" }; // ✗ never do thisA permanent error — most often a 404 from the /api-prefix mistake above — then disguises itself as "still working", and the loop spins until it times out with no diagnostic. The generation may have completed successfully the whole time.
Surface the status code and the response body instead. A 404 or 400 means stop and fix the request; only the codes in the table above deserve a retry.
JavaScript
const API_KEY = "kolbo_live_...";
const BASE = "https://api.kolbo.ai/api";
const HEADERS = { "X-API-Key": API_KEY, "Content-Type": "application/json" };
const TRANSIENT = new Set([408, 425, 429, 500, 502, 503, 504, 522, 524]);
const TERMINAL = new Set(["completed", "failed", "cancelled"]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function pollUntilDone(generationId, { interval = 8000, timeout = 900000 } = {}) {
const url = `${BASE}/v1/generate/${encodeURIComponent(generationId)}/status`;
const deadline = Date.now() + timeout;
let transientFailures = 0;
while (true) {
if (Date.now() > deadline) {
// NOT a failure — the job is still running server-side and no credits were lost.
throw new Error(`Poll window expired for ${generationId}; keep the id and resume polling.`);
}
let status;
try {
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) {
if (!TRANSIENT.has(res.status)) throw new Error(`Poll failed: HTTP ${res.status}`);
throw Object.assign(new Error(`HTTP ${res.status}`), { transient: true });
}
status = await res.json();
transientFailures = 0;
} catch (err) {
const transient = err.transient || err.name === "TypeError";
if (!transient || ++transientFailures > 30) throw err;
await sleep(Math.min(interval * Math.pow(1.5, Math.min(transientFailures - 1, 5)), 30000));
continue;
}
if (TERMINAL.has(status.state)) {
// Music only: an intermediate "completed" at progress 70 carries temporary URLs.
if (status.type === "music" && status.state === "completed" && status.progress !== 100) {
await sleep(interval);
continue;
}
if (status.state === "failed") throw new Error(status.error || "Generation failed");
if (status.state === "cancelled") throw new Error("Generation was cancelled");
return status;
}
await sleep(interval);
}
}
// Usage
const start = await fetch(`${BASE}/v1/generate/image`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({ prompt: "A red sneaker on concrete", model: "MODEL_IDENTIFIER" }),
}).then((r) => r.json());
// `?? start.message_id` is not optional boilerplate: POST /v1/chat is the one
// endpoint that names its handle `message_id`, so a helper that reads only
// `generation_id` polls /v1/generate/undefined/status and gets 400.
const done = await pollUntilDone(start.generation_id ?? start.message_id, {
interval: (start.poll_interval_hint || 8) * 1000,
timeout: 120000,
});
console.log(done.result.urls, done.credits_used);Python
import time
import requests
API_KEY = "kolbo_live_..." # Replace with your API key
BASE = "https://api.kolbo.ai/api"
HEADERS = {"X-API-Key": API_KEY}
TRANSIENT = {408, 425, 429, 500, 502, 503, 504, 522, 524}
TERMINAL = {"completed", "failed", "cancelled"}
def poll_until_done(generation_id, interval=8.0, timeout=900.0):
url = f"{BASE}/v1/generate/{generation_id}/status"
deadline = time.time() + timeout
transient_failures = 0
while True:
if time.time() > deadline:
# NOT a failure — the job is still running server-side and no credits were lost.
raise TimeoutError(f"Poll window expired for {generation_id}; keep the id and resume polling.")
try:
res = requests.get(url, headers=HEADERS, timeout=30)
if res.status_code in TRANSIENT:
raise requests.RequestException(f"HTTP {res.status_code}")
res.raise_for_status()
status = res.json()
transient_failures = 0
except requests.RequestException:
transient_failures += 1
if transient_failures > 30:
raise
time.sleep(min(interval * (1.5 ** min(transient_failures - 1, 5)), 30))
continue
if status["state"] in TERMINAL:
# Music only: an intermediate "completed" at progress 70 carries temporary URLs.
if status["type"] == "music" and status["state"] == "completed" and status.get("progress") != 100:
time.sleep(interval)
continue
if status["state"] == "failed":
raise RuntimeError(status.get("error", "Generation failed"))
if status["state"] == "cancelled":
raise RuntimeError("Generation was cancelled")
return status
time.sleep(interval)
start = requests.post(
f"{BASE}/v1/generate/image",
headers=HEADERS,
json={"prompt": "A red sneaker on concrete", "model": "MODEL_IDENTIFIER"},
).json()
# `or start.get("message_id")` is not optional boilerplate: POST /v1/chat is the
# one endpoint that names its handle `message_id`, so a helper that reads only
# `generation_id` polls /v1/generate/None/status and gets 400.
job_id = start.get("generation_id") or start.get("message_id")
done = poll_until_done(job_id, interval=start.get("poll_interval_hint", 8), timeout=120)
print(done["result"]["urls"], done.get("credits_used"))Cancel
Stops a running generation and releases or refunds its credits. This is the same code path the Kolbo web app uses, so provider aborts and refunds behave identically whichever surface cancelled.
Endpoint
POST /api/v1/generate/:generationId/cancelNo request body. Rate limited with the shared generation limiter (10 requests/minute in production, keyed by account rather than by individual API key).
Example
curl -X POST https://api.kolbo.ai/api/v1/generate/6612f0a1b2c3d4e5f6a7b8c9/cancel \
-H "X-API-Key: kolbo_live_..."Response (Cancelled)
{
"success": true,
"generation_id": "6612f0a1b2c3d4e5f6a7b8c9",
"type": "video",
"state": "cancelled",
"credits_refunded": 84
}For a Creative Director batch the response also carries batch_cancelled_count — the number of in-flight scenes that were stopped along with the batch. A Creative Director cancel refunds only the scenes that had not finished; completed scenes stay charged, and a batch where some scenes already succeeded is finalised as completed rather than cancelled.
Response Fields
| Field | Type | Description |
|---|---|---|
success | boolean | true when the generation was cancelled |
generation_id | string | The id you cancelled |
type | string | Tracked generation type |
state | string | Always cancelled on success |
credits_refunded | number | Credits returned to the balance. 0 when nothing had been deducted yet — which is the normal case for endpoints that charge only on success. |
batch_cancelled_count | number | Creative Director only — child scenes cancelled with the batch. Omitted otherwise. |
Errors
| Status | Code | Cause |
|---|---|---|
| 404 | NOT_FOUND (or no code) | No generation with that id belongs to your API key |
| 403 | FORBIDDEN | The generation exists but you may not cancel it |
| 409 | CANNOT_CANCEL | The generation already reached a terminal state. The body includes current_status. |
A 409 is an answer, not a failure — the work already finished or failed, so there was nothing to stop. Do not retry it.
Only generations in a live state can be cancelled (processing, queued, pending, initializing, starting, generating). Anything else returns 409.
What cannot be cancelled
| Endpoint | Why |
|---|---|
POST /api/v1/transcribe | Transcription is not in the cancellable registry — the route returns 404. Transcription charges only after the transcript exists, so an abandoned job costs nothing. |
POST /api/v1/edit/image | Its gen_... id is a transient correlation id that is never persisted, so cancel cannot resolve it and returns 404. Let the edit finish. |
POST /api/v1/chat | Chat messages are not in the cancellable registry either — passing a message_id to the cancel route returns 404. Let the turn finish; a chat turn is short, and the cancelled state you may still observe on the status route comes from a stop issued inside the Kolbo web app, not from this API. |
| Video Trim jobs | No public cancel route at all. cancelled exists in the trim status vocabulary but nothing in the public API can put a job into it. See Video Editing. |
Flows That Do Not Use This Route
One flow is job-based rather than generation-based. It has its own poll route and its own state vocabulary, and passing its id to GET /api/v1/generate/{id}/status returns 404 Generation not found.
| Flow | Poll with | Interval | Documented in |
|---|---|---|---|
| Video Trim | GET /api/v1/video/trim/{jobId} (lowercase status, url on completion) | No hint field, and none suggested. Trims usually finish in seconds — 2–4 s is reasonable | Video Editing |
It does not return poll_interval_hint — the Interval section above applies only to the generic status route. Build the poll URL yourself and pick an interval from the column above.
The rule at the top still holds for all three: they poll too. There is no push mechanism anywhere in the API.