Kolbo.AIKolbo.AI Docs
Developer API

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:

MechanismAvailable to API consumers?Why
GET /api/v1/generate/{id}/statusYes — this is the contractReturns state, progress, result, credits and failure detail
Webhook / callback_urlNoNo request body on any generation endpoint reads a callback URL, and no route delivers outbound notifications
WebSocket generation:progressNo — not part of the contractEvery 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-pollNoThe 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:

FieldTypeNotes
successbooleantrue on an accepted submit
generation_idstringThe poll handle. POST /api/v1/chat returns message_id instead — same value, different key
typestringTracked generation type
modelstring | nullEcho 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_chargednumber | nullEstimate at submit time, null for most families. The authoritative figure is credits_used on the completed poll
poll_urlstring/v1/generate/{id}/status, without the /api prefix
poll_interval_hintnumberSeconds — see Interval
session_idstringPresent when the generation landed in a session
project_idstringPresent 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/status

No 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

FieldTypeWhenDescription
successbooleanalwaystrue for any resolved status — including state: "failed". It reports that the poll worked, not that the generation did.
generation_idstringalwaysThe id you polled with
typestringalwaysTracked generation type (image, image_edit, global_image_edit, video, music, three_d, creative_director, …)
statestringalwaysprocessing | completed | failed | cancelled, plus pending for a Creative Director batch that has not started its scenes yet
progressnumberalways0-100. Falls back to 100 on completion / 0 otherwise when the underlying model reports no progress. Coarse — see below.
resultobjectstate: "completed"Type-specific payload — see Where Is My Output URL
credits_usednumberstate: "completed"Real multiplier-adjusted credits charged. Absent when no credit record is linked.
credits_breakdownarraystate: "completed"One entry per credit record: model, amount, base, final, duration_multiplier, pricing
errorstringstate: "failed"Human-readable failure message
failureobjectstate: "failed", non-Creative-Directormessage, category, code, retryable, severity, provider — any field may be null
scenesarrayCreative Director onlyPer-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

StatusBodyCause
400Invalid generation ID formatThe id is neither an ObjectId nor a gen_... string
404Generation not foundNo generation with that id belongs to your API key
404Generation document not foundThe 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.

stateTerminal?MeaningWhat to do
pendingNoCreative Director only. The batch exists but the AI has not planned the scenes yet, so scenes is [] and progress is 0.Keep polling
processingNoRunning, queued, or uploading the finished file. Also the fallback for any status the API does not recognise.Keep polling
completedYesFinished successfully. The only state that carries result, credits_used and credits_breakdown.Read result
failedYesPermanently 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
cancelledYesCancelled 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 attach X-API-Key — that header belongs to api.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/permanent removes 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/:id accepts a generation id as well as a media id — see Recovering a lost id.
Endpointtype on the status bodyOutput lives in
POST /v1/generate/imageimageresult.urls[]
POST /v1/generate/image-editimage_editresult.urls[]
POST /v1/edit/imageglobal_image_editresult.urls[]
POST /v1/generate/videovideoresult.urls[]
POST /v1/generate/video/from-imagevideo_from_imageresult.urls[]
POST /v1/generate/elementselementsresult.urls[]
POST /v1/generate/first-last-framefirst_last_frameresult.urls[]
POST /v1/generate/video-from-videovideo_from_videoresult.urls[]
POST /v1/generate/lipsynclipsyncresult.urls[]
POST /v1/edit/videoglobal_video_editresult.urls[] (+ result.download_url)
POST /v1/generate/musicmusicresult.urls[] and result.tracks[]
POST /v1/generate/speechspeechresult.urls[]
POST /v1/generate/soundsoundresult.urls[]
POST /v1/transcribetranscriptionresult.text, result.srt_url, result.txt_url
POST /v1/generate/3dthree_dresult.urls[]
POST /v1/chatchatresult.content (text), plus result.image_urls[] / video_urls[] / audio_urls[] only when the turn produced media — no urls key
POST /v1/generate/creative-directorcreative_directorscenes[].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" }]
  }
}
FieldTypeAlways?Notes
urlsstring[]yes (may be [])All images from the request, delivered together
thumbnail_urlstring | nullyesLiterally urls[0] — not a separate smaller asset
prompt_usedstringyesThe post-enhancement prompt when enhance_prompt was on
modelstring | nullyesThe model that actually ran after Smart Select routing or provider fallback. The router alias (auto) is never returned here.
model_namestringonly when resolvedHuman display name, present only for Smart-Select-resolved runs
created_atstring (ISO 8601)yes
visual_dnaarrayonly when visual_dna_ids were sent{ id, name, thumbnail_url }
moodboardobjectonly when moodboard_id was sent{ id, name }
presetobjectonly when preset_id was sent{ id, name }
cinematic_presetsarrayonly 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"
  }
}
FieldTypeNotes
urlsstring[]One video in practice. Still an array.
thumbnail_urlstring | nullThe generated poster frame, else the first source image. null for video-from-video.
durationnumber | string | nullUnion 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_ratiostring | null
prompt_usedstringOmitted entirely for lipsync, which has no prompt
modelstring | nullResolved model. null for lipsync.
created_atstring (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"
  }
}
EndpointResult keys
/generate/musicurls[] (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/speechurls[] (exactly one), voice (the resolved voice id, not the name you passed), duration (null on this path), model, created_at
/generate/soundurls[] (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/status

See 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
  }
}
FieldTypeReliability
errorstringAlways 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.messagestringSame value as error
failure.categorystring | nullBest-effort. Populated on the main image-generation path and on server-side timeouts; frequently null for image edits, video and 3D.
failure.codestring | nullBest-effort. GENERATION_TIMEOUT on the wall-clock timeout path.
failure.retryableboolean | nullBest-effort, and null when unknown. true on timeouts.
failure.severitystring | nullBest-effort
failure.providerstring | nullBest-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" }
StatusCodeCause
400INVALID_PROMPTprompt (or text) missing, empty, or not a string
400INVALID_MODELmodel sent as an array or object instead of a string
400MODEL_REQUIREDvideo-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
400NSFW_BLOCKED | NSFW_BLOCKED_LOCKEDPrompt blocked before any provider call
403INSUFFICIENT_CREDITSPre-flight balance gate on the submit call — your balance is below the minimum for that generation type
400SDK_PROJECT_INVALID_IDproject_id is not a valid ObjectId
404SDK_PROJECT_NOT_FOUNDNo project with that id is visible to you (same answer whether it is missing or you are not a member)
403SDK_PROJECT_ACCESS_DENIEDYou lack edit permission on the project_id you passed
429(none)Generation rate limit — { "status": false, "error": "Too many generation requests", "retryAfter": 60 }
500TRACKING_ERRORThe job started but its id could not be recorded. Rare — treat as a failed submit.
4xx / 5xxGENERATION_ERRORAny 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:

  1. GET /v1/media?category=ai&sort=created_desc&page_size=5 (add project_id if you sent one) — a job that did start shows up there once it finishes.
  2. Or diff GET /v1/account/credits — a reservation for an in-flight generation is already unavailable even though total still counts it.
  3. 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.

FamilyPartial 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 editsNo. urls appears only on completion — including during the provider-side upload phase, which still reports processing.
Speech, soundNo.
TranscriptionNo. The transcript is held back until the files are stored, even though progress climbs to 99.
3DNo. All formats arrive together; progress sits at 0 then jumps to 100.
ChatNo. 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.
MusicPartial, with a catch. See the streaming-URL warning in The State Machine.
Creative DirectorYes — 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:

  • scenes is [] 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 iterate scenes[] and check each status rather than trusting the batch state.

See Creative Director.

Interval

Use the poll_interval_hint (seconds) from the start response rather than hard-coding one. Its values today:

Typepoll_interval_hint
chat2
image, image_edit3
sound5
creative_director5
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:

EndpointClient window
/generate/image2 min
/generate/image-edit2 min (4 min for multi-source or Visual-DNA-anchored edits)
/edit/image5 min
/generate/video, /generate/video/from-image15 min
/generate/elements, /generate/lipsync, /generate/video-from-video, /edit/video10 min
/generate/first-last-frame5 min
/generate/music5 min
/generate/speech, /generate/sound2 min
/transcribe30 min
/generate/3d15 min
/generate/creative-director10 min image batches, 30 min video batches
/v1/chat2 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:

FamilyServer deadline
Images, image edits, 3D, speech, sound15 min
Text-to-video45 min (20 min on Sora 2 models)
Image-to-video20 min
Creative Director batch30 min — pending scenes are marked failed and the batch is finalised completed if any scene succeeded, else failed
Backstop sweeper for anything still processing90 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 haveCall
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 madeGET /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:

ConditionTreat 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, 429Transient — retry
500, 502, 503, 504, 522, 524Transient — retry
401, 403Fatal — fix the key or permissions
400Fatal — malformed id
404Fatal — 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 this

A 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/cancel

No 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

FieldTypeDescription
successbooleantrue when the generation was cancelled
generation_idstringThe id you cancelled
typestringTracked generation type
statestringAlways cancelled on success
credits_refundednumberCredits 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_countnumberCreative Director only — child scenes cancelled with the batch. Omitted otherwise.

Errors

StatusCodeCause
404NOT_FOUND (or no code)No generation with that id belongs to your API key
403FORBIDDENThe generation exists but you may not cancel it
409CANNOT_CANCELThe 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

EndpointWhy
POST /api/v1/transcribeTranscription 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/imageIts 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/chatChat 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 jobsNo 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.

FlowPoll withIntervalDocumented in
Video TrimGET /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 reasonableVideo 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.