Kolbo.AIKolbo.AI Docs
Developer API

Media Library

Upload, list, and filter all the media in a user's Kolbo library — by project, folder, type, and section — the same view as the desktop app and Adobe plugin.

The Media Library API exposes the same library the user sees in the Kolbo web app, desktop app, and Adobe plugin. Every uploaded file and every AI-generated output the user has saved is here, with rich filtering by project, folder, type, and section (category).

The base endpoint, GET /api/v1/media, was originally a thin list of uploads. It has been extended with the full filter set used by the desktop app — the older parameter spellings (pageSize, searchTerm) continue to work exactly as before.

Endpoints

EndpointDescription
POST /v1/media/uploadUpload a file (multipart) and receive a stable Kolbo CDN URL
POST /v1/media/upload-ticketMint a short-lived, upload-only ticket so a browser can upload without your API key
GET /v1/mediaList media with filters by project, folder, type, section, source, search, and sort
GET /v1/media/:idFetch one media item by id (full metadata)
DELETE /v1/media/:idSoft-delete (move to trash, 30-day recovery) — owner only
POST /v1/media/:id/restoreRestore a trashed item — owner only
DELETE /v1/media/:id/permanentPermanently delete (S3 + DB) — owner only, NOT reversible
PATCH /v1/media/:id/projectRe-assign item to a different project
POST /v1/media/bulk/deleteBulk soft-delete (≤1000 ids)
POST /v1/media/bulk/restoreBulk restore from trash (≤1000 ids)
POST /v1/media/bulk/permanentBulk permanent delete (≤1000 ids) — NOT reversible
POST /v1/media/bulk/moveBulk move to project (≤1000 ids, atomic)
GET /v1/media/statsItem counts + total storage bytes (optionally per project)
POST /v1/media/:id/favoriteMark a media item as favorited (idempotent)
DELETE /v1/media/:id/favoriteRemove a media item from favorites (idempotent)
GET /v1/media/foldersList the user's media folders (owned + shared + project-assigned)
POST /v1/media/foldersCreate a new folder
PUT /v1/media/folders/:idUpdate folder name / description / color / icon (owner only)
DELETE /v1/media/folders/:idSoft-delete a folder (owner only)
POST /v1/media/folders/:id/itemsAdd media items to a folder
DELETE /v1/media/folders/:id/itemsRemove media items from a folder
POST /v1/media/folders/:id/shareShare a folder with users by email (owner only)
DELETE /v1/media/folders/:id/share/:user_idRevoke a user's access (owner only)
POST /v1/media/folders/:id/move-contentsMove every item in a folder to a project you own

Rate limits are per authenticated user, per minute, and are enforced separately per group:

  • POST /v1/media/upload — 10/min, sharing one bucket with the general SDK generation routes (/v1/generate/video, /music, /speech, and friends). /v1/generate/image and /v1/transcribe have their own separate buckets, so uploads do not compete with those.
  • POST /v1/media/bulk/* and POST /v1/media/folders/:id/move-contents — 20/min.
  • Every other media route (list, get, stats, favorites, folder CRUD, upload-ticket) — 120/min.

Exceeding a limit returns 429 with retryAfter: 60.

Finding a Generation's Output Again

The media library is the recovery path when a poll loop dies, a URL is lost, or you need an asset long after the generation finished. Two routes cover it:

You haveCall
The generation_id from a /v1/generate/* submitGET /v1/media/:id — it falls back to a generation id lookup when no media item matches directly, 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)

Output URLs are opaque strings — do not hardcode the host. More than one CDN host appears across these docs (media.kolbo.ai and cdn.kolbo.ai). Never allow-list, pattern-match, or rebuild a URL: read the exact string from result.urls[] (or media[].url) and use it verbatim.

They are plain links — no query string, no signature, no embedded credentials. Fetch them with an ordinary GET and do not send X-API-Key to the CDN host; that header is for api.kolbo.ai.

No lifetime is published for them. Nothing here 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 rather than treating a Kolbo URL as your system of record.

Two ids are easy to confuse. POST /v1/media/upload returns media.id — the stored-file id, which GET /v1/media/:id and the folder / bulk routes reject with 404. The MediaLibraryItem id those routes want is the id from GET /v1/media. The stable handle to keep from an upload is media.url; match on it to find the library id. See the callout on Upload Media.

List Media

The primary discovery endpoint. Returns the user's media — uploaded files and AI-generated outputs they have saved — filtered by any combination of project, folder, type, section, source, and free-text search.

Endpoint

GET /api/v1/media

Query Parameters

All parameters are optional. The endpoint defaults to the user's full library, newest first, 50 items per page.

ParameterTypeRequiredDescription
project_idstring (ObjectId)NoRestrict to a single project. The caller must have access (owner, co-owner, or shared user); without access the response is an empty list, not an error. A malformed id returns 400.
folder_idstring (ObjectId)NoRestrict to a folder. Takes precedence over project_id. Requires the caller to own the folder or be in its shared_with list — otherwise 404.
typestringNoMedia type. One of image, video, audio, all (default all). Any other value returns 400. The library can also hold 3d_model and document items (stock 3D imports, and documents uploaded through the upload-ticket flow) — no type value selects those, so they only surface under the default all.
categorystringNo"Section" filter — matches the desktop app sidebar. One of ai, uploaded, edited, favorites, training-lab, all (default all). Any other value returns 400.
source_typestringNoLower-level provenance. One of uploaded, generated, chat-generated. Any other value returns 400. Note generated matches both generated and chat-generated items; pass chat-generated to isolate chat outputs. Use category for the common case.
sourcestringNoFree-form metadata.source tag (e.g. flow for Flow-generated items). Truncated to 64 characters.
preset_idstring (ObjectId)NoItems generated using a specific preset.
moodboard_idstring (ObjectId)NoItems generated using a specific moodboard.
searchstringNoCase-insensitive substring match across filename and the generation metadata: prompt, presetName, moodboardName, visualDnaNames, musicTitle, musicStyle, lyrics, voiceName, modelName, model. Regex-escaped server-side, so user-typed text is safe to pass through.
sortstringNoOrder. One of created_desc (default), created_asc, name_asc, name_desc. Any other value returns 400. Ignored when folder_id is set.
pageintegerNo1-indexed page. Default 1. Must be a positive integer, else 400.
page_sizeintegerNoItems per page. Default 50, silently capped at 200. Must be a positive integer, else 400.
include_deletedstringNoPass the literal string "true" to list only soft-deleted (trashed) items. Default false.

Folder listings behave differently. When folder_id is set the folder's contents are returned as-is: items come back in folder order (manual position, then most-recently-added first) and every other filter — sort, type, category, source_type, source, search, preset_id, moodboard_id, project_id — is ignored. Only page, page_size, and include_deleted still apply.

Section / Category Reference

The category parameter mirrors the section buttons in the Kolbo desktop app and Adobe plugin:

categoryMeaning
aiAnything AI-generated (text-to-image, video, music, speech, sound, lipsync, etc.)
uploadedFiles the user uploaded themselves (no AI generation)
editedAI-edited variants — matches only items whose metadata.category is exactly edited-image
favoritesItems starred by anyone in the project — favorites are project-level, not per-user
training-labAssets created or used inside Training Lab
allNo category filter (default)

Stock-library imports are stored with sourceType: "stock" and metadata.category: "stock-footage". Neither value is accepted by category or source_type, so imported stock assets are only returned when you leave both filters off.

Examples

List the user's most recent 20 items across everything:

curl "https://api.kolbo.ai/api/v1/media?page_size=20" \
  -H "X-API-Key: kolbo_live_..."

List all videos in a specific project:

curl "https://api.kolbo.ai/api/v1/media?project_id=6601a1b2c3d4e5f6a7b8c9d0&type=video" \
  -H "X-API-Key: kolbo_live_..."

List only AI-generated images the user has favorited:

curl "https://api.kolbo.ai/api/v1/media?type=image&category=favorites" \
  -H "X-API-Key: kolbo_live_..."

List everything in a folder:

curl "https://api.kolbo.ai/api/v1/media?folder_id=6601a1b2c3d4e5f6a7b8c9d0" \
  -H "X-API-Key: kolbo_live_..."

Free-text search across filenames and prompts inside one project:

curl "https://api.kolbo.ai/api/v1/media?project_id=6601...&search=sunset" \
  -H "X-API-Key: kolbo_live_..."

List the trash:

curl "https://api.kolbo.ai/api/v1/media?include_deleted=true" \
  -H "X-API-Key: kolbo_live_..."

Response

{
  "success": true,
  "media": [
    {
      "id": "660aa1b2c3d4e5f6a7b8c9d0",
      "url": "https://cdn.kolbo.ai/u/123/abc.mp4",
      "thumbnail_url": "https://cdn.kolbo.ai/u/123/abc-thumb.jpg",
      "name": "sunset-beach.mp4",
      "filename": "sunset-beach.mp4",
      "type": "video",
      "media_type": "video",
      "category": "text-to-video",
      "size": 4823901,
      "source_type": "generated",
      "generation_type": "textToVideoGeneration",
      "session_type": "text_to_video",
      "prompt": "Cinematic sunset over an empty beach, slow dolly-in",
      "width": 1920,
      "height": 1080,
      "duration": 8,
      "is_favorited": true,
      "nsfw_detected": false,
      "project_id": "6601a1b2c3d4e5f6a7b8c9d0",
      "session_id": "6601...",
      "user_id": "65f0...",
      "user_email": "you@example.com",
      "user_name": "You",
      "created_at": "2026-05-10T18:24:31.000Z",
      "metadata": { "...": "..." }
    }
  ],
  "pagination": {
    "page": 1,
    "page_size": 50,
    "total_items": 312,
    "total_pages": 7,
    "has_next": true
  }
}

Notes on the response:

  • category is the resolved bucket for the item: text-to-video, edited-image, generated, uploaded, etc. It maps to (but is not identical to) the category query filter — use the query filter for selection and the response category for display.
  • generation_type is the internal generation model name (textToVideoGeneration, imageGeneration, musicGeneration, …) and is null for uploads. session_type is the snake_case tool name derived from category (text-to-videotext_to_video, edited-imageimage_editing, musicmusic, …); an unrecognised category is passed through unchanged, and the catch-all category generated maps to text_to_image regardless of the actual media type — so treat session_type as a display hint rather than a stable enum.
  • is_favorited is project-level, not per-user: it is true when any member has favorited the item (see Favorite a Media Item).
  • user_email and user_name are only resolved when you pass project_id. Without a project filter the owner lookup is skipped entirely and both come back null — even for your own items. user_id is always present.
  • total_items is only computed on page 1. On page 2 and beyond the server returns total_items: -1 and total_pages: 0 — that is not an error, and it applies to every listing (project, folder, or unfiltered). Paginate on has_next, and keep the count you got from page 1.
  • prompt, width, height, duration are best-effort and may be null for older items or uploads.
  • metadata is always returned, and carries the generation's own metadata plus creditsUsed, generationDurationSeconds, projectId, and sessionId.

Items flagged as NSFW come back with a placeholder URL. When nsfw_detected is true, both url and thumbnail_url in this list response are replaced with a static placeholder image served from the API host (/assets/nsfw-placeholder.webp) — the real CDN URL is not returned. The substitution is skipped only for accounts whose resolved NSFW behaviour is unrestricted: either an admin-set unrestricted mode, or an account that has explicitly opted into NSFW content in its own settings. GET /v1/media/:id does not apply this substitution and returns the real URL.

Validation Errors

Invalid enum values return 400:

curl "https://api.kolbo.ai/api/v1/media?type=gif" -H "X-API-Key: ..."
# { "success": false, "error": "Invalid type \"gif\". Allowed: all, image, video, audio." }

Get One Media Item

GET /api/v1/media/:id

Returns one item with its full metadata — reference media, Visual DNA names and thumbnails, the Color DNA palette snapshot, the quality tier, credits used, and generation duration.

Access: the caller must own the item, or have access to the item's project, or be a system admin, or have an active organization that the item's owner also belongs to — otherwise 403. That last rule is plain org membership, not a role check: any member whose active organization contains the owner can read the item. (activeOrganizationId is populated for API-key callers too, falling back to the first entry in the account's organization list.) Trashed items are treated as not found (404). An id that is not a valid ObjectId returns 400.

If no media item matches :id directly, the server falls back to looking the id up as a generation id and returns that generation's lowest-index media item — useful when you only kept the id returned by a /v1/generate/* call.

curl "https://api.kolbo.ai/api/v1/media/660aa1b2c3d4e5f6a7b8c9d0" \
  -H "X-API-Key: kolbo_live_..."
{
  "success": true,
  "media": {
    "id": "660aa1b2c3d4e5f6a7b8c9d0",
    "type": "video",
    "category": "text-to-video",
    "filename": "sunset-beach.mp4",
    "url": "https://cdn.kolbo.ai/u/123/abc.mp4",
    "size": 4823901,
    "created": "2026-05-10T18:24:31.000Z",
    "projectId": "6601a1b2c3d4e5f6a7b8c9d0",
    "creditsUsed": 120,
    "generationDurationSeconds": 43,
    "sessionId": "6601...",
    "session_id": "6601...",
    "userId": "65f0...",
    "userEmail": "you@example.com",
    "userName": "You",
    "userAvatar": null,
    "isFavorited": true,
    "isDisliked": false,
    "generationType": "textToVideoGeneration",
    "sessionType": "text_to_video",
    "metadata": { "...": "..." },
    "referenceMedia": []
  }
}

This endpoint does not use the snake_case list shape. It returns the internal camelCase document: created (not created_at), projectId, userId, isFavorited, generationType, sessionType. There is no thumbnail_url, name, media_type, source_type, prompt, width, height, duration, or nsfw_detected field here — read the thumbnail from metadata.thumbnailUrl and the dimensions/prompt from metadata. If you need the list shape, use GET /v1/media with a filter instead.

category here resolves to metadata.category and falls back to sourceType (so an uploaded file reports uploaded); isDisliked mirrors the "hide" state on the source generation and is absent for generation types that do not support it. Reading an item also lazily backfills referenceMedia, metadata.visualDnaNames, metadata.colorPalette, and metadata.quality on older records.

Delete / Restore / Permanently Delete

DELETE  /api/v1/media/:id              # soft-delete (30-day trash, reversible)
POST    /api/v1/media/:id/restore      # bring back from trash
DELETE  /api/v1/media/:id/permanent    # hard-delete (removes from S3 + DB)

All three are owner-only — project access is not enough. An item you don't own returns 404.

Soft-delete is the standard "delete" — items move to trash and can be restored for 30 days.

Restore only works on items currently in the trash; a live (or unknown) item returns 404.

Permanent delete is not reversible — it removes the MongoDB record, the S3 file, every folder membership, and the source generation record. It is idempotent: deleting something already gone still returns 200.

To list trashed items, use GET /v1/media?include_deleted=true.

# Soft-delete
curl -X DELETE "https://api.kolbo.ai/api/v1/media/660..." \
  -H "X-API-Key: kolbo_live_..."
# → { "success": true, "id": "660...", "message": "Media moved to trash (30-day recovery)" }

# Restore
curl -X POST "https://api.kolbo.ai/api/v1/media/660.../restore" \
  -H "X-API-Key: kolbo_live_..."

# Hard-delete (NOT reversible)
curl -X DELETE "https://api.kolbo.ai/api/v1/media/660.../permanent" \
  -H "X-API-Key: kolbo_live_..."

Move a Media Item to Another Project

PATCH /api/v1/media/:id/project
Body fieldTypeRequiredDescription
project_idstring (ObjectId)YesTarget project. The caller must have access to it (owner, co-owner, or shared user). Missing → 400; malformed → 400; no access → 403. Legacy alias: newProjectId.

The caller must own the item and it must not be in the trash, otherwise 404.

curl -X PATCH "https://api.kolbo.ai/api/v1/media/660.../project" \
  -H "X-API-Key: kolbo_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "project_id": "6601a1b2c3d4e5f6a7b8c9d0" }'
# → { "success": true, "id": "660...", "project_id": "6601a1b2c3d4e5f6a7b8c9d0" }

Bulk Operations

POST /api/v1/media/bulk/delete       { "media_ids": [...] }
POST /api/v1/media/bulk/restore      { "media_ids": [...] }
POST /api/v1/media/bulk/permanent    { "media_ids": [...] }   # NOT reversible
POST /api/v1/media/bulk/move         { "media_ids": [...], "project_id": "…" }
Body fieldTypeRequiredDescription
media_idsstring[] (ObjectIds)YesNon-empty, max 1000 per call. Every entry must be a valid ObjectId — one bad id rejects the whole call with 400 Invalid media id: …. Legacy alias: mediaIds.
project_idstring (ObjectId)bulk/move onlyTarget project. Missing → 400; malformed → 400; caller has no access (owner, co-owner, or shared user) → 403. Legacy alias: newProjectId.

Ownership rules and response shapes differ per endpoint:

EndpointOwnershipResponse fields
bulk/deleteIds not owned by the caller are skippeddeleted_count (number), errors — an array holding at most one entry, { "error": "N item(s) not found or not owned by user" }. It is [] when every id matched — and also [] in the edge case where none matched, so check deleted_count, not errors.length
bulk/restoreOnly trashed items owned by the caller are restoredrestored_count (number), not_found — a count, not a list: supplied ids minus restored ids
bulk/permanentIds not owned by the caller are skippeddeleted_count (number), errors — same single-entry shape as bulk/delete
bulk/moveAtomic — every id must be owned by the caller and not in the trash, else 403 ("You own N of M items") and nothing movesmoved_count (number), project_id
curl -X POST "https://api.kolbo.ai/api/v1/media/bulk/delete" \
  -H "X-API-Key: kolbo_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "media_ids": ["660...a", "660...b", "660...c"] }'
# → { "success": true, "deleted_count": 3, "errors": [] }

Storage Stats

GET /api/v1/media/stats[?project_id=…]
ParameterTypeRequiredDescription
project_idstring (ObjectId)NoScope stats to one project — counts all members' media in that project. Omit for the caller's own media across all projects. Malformed id → 400. No access to the project → all-zero stats with 200. Legacy alias: projectId.
curl "https://api.kolbo.ai/api/v1/media/stats" \
  -H "X-API-Key: kolbo_live_..."
{
  "success": true,
  "stats": {
    "total": 312,
    "images": 178,
    "videos": 102,
    "audio": 32,
    "total_size_bytes": 4823901234
  }
}

Trashed items are excluded from the counts.

total counts every media type, including the 3d_model and document items that have no bucket of their own — so total can be larger than images + videos + audio. total_size_bytes sums the stored size of every counted item (items with no recorded size contribute 0).

Move Folder Contents to a Project

POST /api/v1/media/folders/:id/move-contents

Moves every item inside the folder to a different project in one call.

Body fieldTypeRequiredDescription
project_idstring (ObjectId)YesTarget project. Legacy alias: newProjectId.

Access rules — all three must hold:

  • The caller owns the folder or is in its shared_with list, else 404.
  • The caller owns the target project outright. Shared or edit access is not enough here (unlike PATCH /v1/media/:id/project), else 404.
  • The caller owns every item in the folder, else 403 with a count of the items they don't own.

An empty folder returns 200 with moved_count: 0.

curl -X POST "https://api.kolbo.ai/api/v1/media/folders/6611.../move-contents" \
  -H "X-API-Key: kolbo_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "project_id": "6601..." }'
# → { "success": true, "moved_count": 42, "project_id": "6601..." }

Favorite a Media Item

POST   /api/v1/media/:id/favorite
DELETE /api/v1/media/:id/favorite

Access: the caller must own the item or be a member of the item's project. Both calls are idempotent.

Favorites are project-level, not per-user. Favoriting adds the caller to the item's favoritedBy list, but unfavoriting clears that list for everyone — a teammate in a shared project will see the item leave Favorites too. is_favorited in the list response is true when any member has favorited the item.

Favorited items show up in GET /v1/media?category=favorites and in the Favorites section of the desktop app and Adobe plugin.

curl -X POST "https://api.kolbo.ai/api/v1/media/660aa1b2c3d4e5f6a7b8c9d0/favorite" \
  -H "X-API-Key: kolbo_live_..."

curl -X DELETE "https://api.kolbo.ai/api/v1/media/660aa1b2c3d4e5f6a7b8c9d0/favorite" \
  -H "X-API-Key: kolbo_live_..."
{ "success": true, "id": "660aa1b2c3d4e5f6a7b8c9d0", "is_favorited": true }

List Media Folders

Folders are user-scoped — they live on the user, not on projects, and can group media across multiple projects. The structure is flat: there are no nested folders.

Endpoint

GET /api/v1/media/folders

Returns folders the caller owns, folders shared with them, and folders assigned to any project they can access, newest first. No parameters; capped at 500 folders (no pagination).

Example

curl "https://api.kolbo.ai/api/v1/media/folders" \
  -H "X-API-Key: kolbo_live_..."

Response

{
  "success": true,
  "folders": [
    {
      "id": "6611...",
      "name": "Campaign A — hero shots",
      "description": null,
      "color": "#3B82F6",
      "icon": "folder",
      "item_count": 42,
      "is_owner": true,
      "shared_with_count": 0,
      "shared_with": [],
      "project_id": null,
      "created_at": "2026-04-01T10:00:00.000Z",
      "last_updated": "2026-05-12T08:14:00.000Z"
    }
  ],
  "count": 1
}

Pass the id of any folder back to GET /v1/media?folder_id=... to list its contents.

A folder that appears here only because it is assigned to a shared project (project_id set, is_owner: false, and you are not in shared_with) is visible in this list but not readable or writable through the folder endpoints — listing its contents returns 404, and adding/removing items returns 404. Only direct ownership or a direct share grants access.

Create a Folder

POST /api/v1/media/folders
Body fieldTypeRequiredDescription
namestringYesTrimmed; must be non-empty and ≤100 characters.
descriptionstringNo≤500 characters.
colorstringNoHex #RRGGBB (exactly 6 hex digits). Anything else → 400. Default #3B82F6.
iconstringNoLucide icon name (e.g. folder, star, image). ≤50 characters. Default folder. The limit is enforced by the model on this route, so an over-long icon returns the platform envelope 400 { "status": false, "message": "Validation Error", "code": "VALIDATION_ERROR" } rather than the SDK's { "success": false, "error": … }.
curl -X POST "https://api.kolbo.ai/api/v1/media/folders" \
  -H "X-API-Key: kolbo_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "name": "Campaign A — hero shots", "color": "#F59E0B", "icon": "star" }'

Returns 201 Created with { "success": true, "folder": { … } }.

The folder is always created against the caller alone: there is no project_id field on this route (or on the update route), so folder.project_id is null for every folder created through the API. Project-assigned folders only exist when created inside the Kolbo app.

Update a Folder

PUT /api/v1/media/folders/:id

Owner only — a folder shared with you returns 404. Any subset of name / description / color / icon may be provided; omitted fields are unchanged.

Body fieldTypeRequiredDescription
namestringNoTrimmed; non-empty, ≤100 characters.
descriptionstring | nullNo≤500 characters. Pass "" or null to clear it.
colorstringNoHex #RRGGBB. Sending null or a non-hex value → 400.
iconstringNo≤50 characters.
curl -X PUT "https://api.kolbo.ai/api/v1/media/folders/6611..." \
  -H "X-API-Key: kolbo_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "name": "Campaign A — final", "color": "#10B981" }'

Delete a Folder

DELETE /api/v1/media/folders/:id

Owner only. The folder is soft-deleted and item membership is cleared, but the items themselves remain in the user's media library. Returns { "success": true, "id": "6611..." }.

curl -X DELETE "https://api.kolbo.ai/api/v1/media/folders/6611..." \
  -H "X-API-Key: kolbo_live_..."

Add Items to a Folder

POST /api/v1/media/folders/:id/items
Body fieldTypeRequiredDescription
media_idsstring[] (ObjectIds)YesNon-empty, max 500 per call. Every entry must be a valid ObjectId, else 400.

Access: the caller must own the folder or be in its shared_with list, else 404.

Only media the caller owns can be added. Ids belonging to anyone else are silently dropped and returned in rejected; if none of the supplied ids are owned by the caller the call returns 403. Already-present items count towards skipped (the call is idempotent).

curl -X POST "https://api.kolbo.ai/api/v1/media/folders/6611.../items" \
  -H "X-API-Key: kolbo_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "media_ids": ["660...a", "660...b", "660...c"] }'
{ "success": true, "folder_id": "6611...", "added": 3, "skipped": 0, "rejected": [] }

Remove Items from a Folder

DELETE /api/v1/media/folders/:id/items

Body shape matches add (media_ids, non-empty, max 500). Items remain in the user's library — only the folder membership is removed.

Unlike the add route, this one does not pre-validate id format, so a malformed id fails at the database layer and comes back in the platform error envelope — 400 { "status": false, "message": "Invalid ID format", "code": "INVALID_ID_FORMAT" } — instead of the SDK's { "success": false, "error": "Invalid media id: …" }. Valid ids that are not in the folder are simply not counted in removed.

The ids are read from a JSON request body on a DELETE, so use an HTTP client that sends one (curl -X DELETE -d, or fetch with method: "DELETE", body: …).

Access: the folder owner can remove any item. A shared member can only remove items they themselves added — ids added by someone else are simply not removed and are not counted in removed.

curl -X DELETE "https://api.kolbo.ai/api/v1/media/folders/6611.../items" \
  -H "X-API-Key: kolbo_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "media_ids": ["660...a"] }'
{ "success": true, "folder_id": "6611...", "removed": 1 }

Share a Folder

POST /api/v1/media/folders/:id/share
Body fieldTypeRequiredDescription
user_emailsstring[]YesNon-empty, max 50 per call. Lower-cased and trimmed server-side. Emails with no Kolbo account come back in not_found — the rest still succeed. The owner's own email is ignored.

Owner only — a folder shared with you cannot be reshared (404).

There are no permission levels on folder shares. Sharing is a flat list of user ids: a member either has access or does not. Concretely, a shared member can list the folder's contents, add items they own, and remove items they added themselves. They cannot rename, recolor, or delete the folder, reshare it, revoke anyone's access, remove items added by the owner or other members, or move the folder's contents to another project. Sharing is also additive and idempotent — re-sharing with an existing member is a no-op.

curl -X POST "https://api.kolbo.ai/api/v1/media/folders/6611.../share" \
  -H "X-API-Key: kolbo_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "user_emails": ["alice@example.com", "bob@example.com"] }'
{
  "success": true,
  "folder": { "id": "6611...", "shared_with": ["65f1...", "65f2..."], "shared_with_count": 2, "...": "..." },
  "shared_with_user_ids": ["65f1...", "65f2..."],
  "not_found": []
}

shared_with_user_ids is the set of ids resolved from this call; folder.shared_with is the folder's full member list after the update.

Revoke Folder Access

DELETE /api/v1/media/folders/:id/share/:user_id

Owner only. :user_id must be a valid ObjectId (else 400) — use one of the ids in the folder's shared_with array. Idempotent: revoking a user who isn't a member still returns 200.

curl -X DELETE "https://api.kolbo.ai/api/v1/media/folders/6611.../share/65f1..." \
  -H "X-API-Key: kolbo_live_..."
{ "success": true, "folder": { "...": "..." }, "revoked_user_id": "65f1..." }

Upload Media

POST /api/v1/media/upload

Multipart form upload. Re-hosts the file on Kolbo CDN and registers it in the user's library so you can reuse the URL in any subsequent generation call.

FieldTypeRequiredDescription
filefileYesThe file to upload. Max 500 MB. The media kind is routed by MIME type: video/* → video storage, audio/* → audio storage, everything else → the image pipeline.
descriptionstringNoRecorded as the upload context for video and audio uploads. Ignored for images and documents.
project_idstring (ObjectId)NoProject to file the upload into. Requires at least edit permission — malformed → 400 SDK_PROJECT_INVALID_ID, unknown/no access → 404 SDK_PROJECT_NOT_FOUND, view-only → 403 SDK_PROJECT_ACCESS_DENIED. Omitted → the upload is not scoped to a project.
curl -X POST https://api.kolbo.ai/api/v1/media/upload \
  -H "X-API-Key: kolbo_live_..." \
  -F "file=@/path/to/photo.jpg" \
  -F "description=Reference photo for the campaign" \
  -F "project_id=65f1c8a2e4b0a3c1d9f5e123"
{
  "success": true,
  "media": {
    "id": "660aa1b2c3d4e5f6a7b8c9d0",
    "url": "https://cdn.kolbo.ai/u/123/photo.jpg",
    "thumbnail_url": null,
    "name": "photo.jpg",
    "type": "image",
    "size": 184320,
    "created_at": "2026-05-10T18:24:31.000Z"
  }
}

Uploads are de-duplicated by file hash: re-uploading identical bytes returns the existing file and its URL instead of storing a second copy.

name and thumbnail_url are not filled in on every path: name is populated for image uploads and for dedup hits, and is null for a fresh video or audio upload; thumbnail_url is only non-null for a fresh image upload.

This route has no document path. Anything that is not video/* or audio/* — including PDF, DOCX, TXT and CSV — is pushed through the image pipeline. Image optimisation fails on those bytes, so the original file is stored under a .jpg object key (the content type is preserved) and the library item is registered with mediaType: "image", meaning it lists as type: image, never type: document. To store real documents, use the upload-ticket flowPOST /mcp/upload classifies documents and routes them to document storage.

media.id from this endpoint is the stored-file id, not the media-library id. It comes from the uploaded-file record, while GET /v1/media/:id, the folder endpoints, and every bulk operation expect a MediaLibraryItem id. Passing the upload id to those routes returns 404. The stable handle to keep is media.url — resolve the library id afterwards with GET /v1/media?category=uploaded and match on url.

This endpoint takes a multipart file only. Sending source_url in the body without a file returns 400 — download the file yourself and post the bytes, or pass the public URL straight to the generation endpoint that needs it.

Uploads are safety-screened. A file flagged by the NSFW reference check is rejected with 422 and code: "NSFW_REFERENCE_BLOCKED" after the bytes are received — including on a dedup hit, since the check runs against the resulting URL. The screen covers the video path and the image path (which, per the callout above, is also where documents land). Only audio/* uploads skip it.

Upload Ticket (browser uploads)

POST /api/v1/media/upload-ticket

Mints a short-lived, upload-only token so a browser or embedded UI can upload files into the user's library without ever seeing your API key. Takes no body.

curl -X POST "https://api.kolbo.ai/api/v1/media/upload-ticket" \
  -H "X-API-Key: kolbo_live_..."
{
  "success": true,
  "token": "eyJhbGciOi...",
  "upload_url": "https://api.kolbo.ai/mcp/upload",
  "expires_in": 900,
  "max_file_mb": { "image": 50, "audio": 200, "video": 500, "document": 50 },
  "accepted": "images (jpg/png/webp/gif/heic/avif), video (mp4/mov/webm/mkv), audio (mp3/wav/m4a/ogg/flac), documents (pdf/txt/md/csv/json/docx/xlsx/pptx)"
}

The client then POSTs each file to upload_url as multipart:

curl -X POST "https://api.kolbo.ai/mcp/upload" \
  -H "Authorization: Bearer eyJhbGciOi..." \
  -F "file=@/path/to/photo.jpg" \
  -F "project_id=65f1c8a2e4b0a3c1d9f5e123"

Rules for the ticket flow:

  • The ticket is valid for 900 seconds (15 minutes) and is bound to the user it was minted for.
  • One file per request, with a hard multipart ceiling of 500 MB; the per-kind caps in max_file_mb are then enforced after the file is classified (over the cap → 413).
  • The declared MIME type and the filename extension must both belong to the same kind — image/png sent as evil.html is rejected. A mismatch or an unlisted type returns 415. SVG and ICO are never accepted.
  • The accepted string is a human summary; the real allowlist is slightly wider — images also take bmp/tif/tiff/heif, video also takes m4v/avi, audio also takes aac, and documents also take legacy doc/xls.
  • Uploads through this route are limited to 40/min per ticket holder.
  • Optional form fields: project_id (same edit+ permission rules as /v1/media/upload) and description (truncated to 500 characters).
  • A missing, forged, or expired ticket returns 401 — mint a new one.
  • The response is the same { success, media: { … } } envelope as /v1/media/upload, plus isDuplicate for documents.

JavaScript Example

const API_KEY = "kolbo_live_YOUR_API_KEY";
const BASE = "https://api.kolbo.ai/api";

async function main() {
  // 1. Discover the user's folders
  const fRes = await fetch(`${BASE}/v1/media/folders`, {
    headers: { "X-API-Key": API_KEY }
  });
  const { folders } = await fRes.json();
  console.log(`User has ${folders.length} folders.`);

  // 2. List AI-generated videos from a specific project, newest first
  const params = new URLSearchParams({
    project_id: "6601a1b2c3d4e5f6a7b8c9d0",
    type: "video",
    category: "ai",
    sort: "created_desc",
    page_size: "50"
  });
  const mRes = await fetch(`${BASE}/v1/media?${params}`, {
    headers: { "X-API-Key": API_KEY }
  });
  const { media, pagination } = await mRes.json();

  console.log(`Got ${media.length} of ${pagination.total_items} videos`);
  for (const item of media) {
    console.log(item.name, "→", item.url);
  }
}

main();

Python Example

import requests

API_KEY = "kolbo_live_YOUR_API_KEY"
BASE = "https://api.kolbo.ai/api"
HEADERS = {"X-API-Key": API_KEY}

# 1. List the user's favorite images across all projects
res = requests.get(
    f"{BASE}/v1/media",
    headers=HEADERS,
    params={
        "type": "image",
        "category": "favorites",
        "page_size": 100,
    },
)
data = res.json()
for item in data["media"]:
    print(item["name"], "→", item["url"])

# 2. Pagination
while data["pagination"]["has_next"]:
    next_page = data["pagination"]["page"] + 1
    res = requests.get(
        f"{BASE}/v1/media",
        headers=HEADERS,
        params={
            "type": "image",
            "category": "favorites",
            "page_size": 100,
            "page": next_page,
        },
    )
    data = res.json()
    for item in data["media"]:
        print(item["name"], "→", item["url"])

Tips

  • For shared projects, the response includes items from all members. Use user_id / user_email to attribute.
  • Combine project_id + category=ai + type=video to grab every AI-generated video clip from a campaign in one call — useful for batch downloads or stitching.
  • folder_id always wins over project_id when both are set. Folders are cross-project by design.
  • page_size is capped at 200. For larger exports, paginate with has_next.
  • The legacy query-parameter spellings (pageSize, searchTerm, sourceType, projectId, folderId, presetId, moodboardId, includeDeleted) are still accepted alongside the snake_case names on GET /v1/media. In bodies, mediaIds is accepted only on the four bulk/* routes, and newProjectId only on PATCH /v1/media/:id/project, POST /v1/media/bulk/move, and POST /v1/media/folders/:id/move-contents — the folder item routes read media_ids and nothing else.

MCP Tools

If you are using Kolbo through the @kolbo/mcp server (Claude Desktop, Claude Code, claude.ai connector), every route on this page has a matching tool:

ToolRoute
upload_mediaPOST /v1/media/upload
media_upload_widgetPOST /v1/media/upload-ticket
list_mediaGET /v1/media
get_mediaGET /v1/media/:id
get_media_statsGET /v1/media/stats
delete_media, restore_media, permanently_delete_mediaDELETE /v1/media/:id, POST /v1/media/:id/restore, DELETE /v1/media/:id/permanent
move_mediaPATCH /v1/media/:id/project
favorite_media, unfavorite_mediaPOST / DELETE /v1/media/:id/favorite
bulk_delete_media, bulk_restore_media, bulk_permanently_delete_media, bulk_move_mediaPOST /v1/media/bulk/delete | /restore | /permanent | /move
list_media_folders, create_media_folder, update_media_folder, delete_media_folder/v1/media/folders CRUD
add_media_to_folder, remove_media_from_folderPOST / DELETE /v1/media/folders/:id/items
share_media_folder, unshare_media_folderPOST /v1/media/folders/:id/share, DELETE …/share/:user_id
move_folder_contentsPOST /v1/media/folders/:id/move-contents

Differences worth knowing:

  • upload_media accepts inputs the HTTP route rejects. It takes source (a public URL or an absolute local path) or source_base64 + filename, resolves it to bytes on the client, and posts the multipart form for you — the route itself only ever accepts a multipart file. Keep source_base64 under roughly 10 MB and always send a filename with an extension, because the extension is what picks the content type.
  • media_upload_widget is the upload-ticket flow. It mints the ticket and renders an upload card; the resulting CDN URLs arrive in a follow-up message. max_files defaults to 10 and is clamped to 1–20, media_types restricts the card to any of image / video / audio / document, purpose sets the card's title, and project_id files the results into a project. On hosts that cannot render widgets it returns a hint to use upload_media instead. This is also the only path that stores real document items.
  • list_media exposes fewer filters than the route. preset_id, moodboard_id, source, and include_deleted are HTTP-only — the trash cannot be listed from the MCP tool.
  • unshare_media_folder takes user_id, matching the :user_id path segment; get the value from the folder's shared_with array.