# Media · Management API

> Management API reference for media: GET /sites/{id}/media, POST /sites/{id}/media, POST /sites/{id}/media/uploads, POST /sites/{id}/media/uploads/confirm, ….

> Markdown version of https://kanorio.com/en/developers/management-api/media. Append `.md` to any developer docs URL for this format. Index: https://kanorio.com/llms.txt · MCP: https://app.kanorio.com/api/mcp

Base URL: `https://app.kanorio.com/api/v1` · [Errors](https://kanorio.com/en/developers/basics/errors) · [Pagination](https://kanorio.com/en/developers/basics/pagination)

### List media

`GET /sites/{id}/media`

- Stability: beta
- Permission: `media:view`
- Rate limit: default (120/minute per token)
- MCP tool: `media_list`

List the website's media library (the same files the user sees in the dashboard): public and protected images, videos, audio, PDFs and documents, newest first, plus storage usage (usedBytes vs limitBytes). Filter with assetKind, visibility, unusedOnly and search (file name). Files in the trash, older versions kept after media.replace, and broken records where the original file is missing (unless includeMissing=true) are not listed; files in the trash still count toward storage. Errors: FORBIDDEN, INVALID_CURSOR.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `assetKind` | query | "image" \| "video" \| "audio" \| "pdf" \| "document" \| "product" | no | Only this kind of file; all kinds by default. Deprecated value product: same as visibility=protected (removed in the next major version). |
| `visibility` | query | "public" \| "protected" | no | Only public or only protected files; both by default. |
| `tags` | query | string | no | Comma-separated tags; only files that carry every listed tag (case-insensitive) are returned. |
| `unusedOnly` | query | boolean \| "true" \| "false" | no | Only files no content uses (candidates for cleaning up storage). |
| `includeMissing` | query | boolean \| "true" \| "false" | no | Include broken asset records where the original file no longer exists on storage (defaults to false). |
| `search` | query | string | no | Part of the file name. |
| `sort` | query | "newest" \| "oldest" \| "largest" | no | newest (default), oldest or largest. |
| `limit` | query | integer | no | Maximum number of items to return, 1–100. Defaults to 20. |
| `cursor` | query | string | no | Opaque cursor from the previous page's nextCursor. Omit for the first page. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object[] | yes | — |
| `data[].assetId` | string | yes | Stable id of the file. Reference files by this id; it stays the same when media.replace uploads a new version. |
| `data[].kind` | "image" \| "video" \| "audio" \| "pdf" \| "document" | yes | What the file is (image, video, audio, pdf, document). Whether it is protected is a separate property: visibility. |
| `data[].visibility` | "public" \| "protected" | yes | public: has a public URL and can be placed in pages, posts and collections. protected: no public URL; only served to entitled buyers through signed short-lived links (reference it by assetId in deliverable_units.*). |
| `data[].tags` | string[] | yes | Owner-defined tags (up to 20 per file, 1-40 characters each); filter media.list with tags. |
| `data[].processing` | object \| null | yes | Background processing of the file itself (thumbnails, visibility moves, transcoding): null when the file is ready to use. Subscribe to the media.asset.ready and media.asset.failed webhooks instead of polling. |
| `data[].processing.status` | "pending" \| "processing" \| "failed" | yes | — |
| `data[].processing.kind` | string \| null | yes | Background job kind (for example DOCUMENT_THUMBNAIL). |
| `data[].processing.error` | string \| null | yes | Error code when status is failed. |
| `data[].processing.updatedAt` | string \| null | yes | — |
| `data[].streaming` | "none" \| "processing" \| "partial" \| "ready" | yes | Adaptive-streaming (HLS) version of a video: none (not placed anywhere that plays it, or not a video), processing (queued or transcoding), partial (lowest quality playable, higher qualities still transcoding), ready (all qualities done). Videos are transcoded when first placed in a page, post, collection or deliverable unit, or with media.update { streaming: true }; the streaming version counts toward storage (billableBytes). |
| `data[].status` | "active" \| "trash" | yes | trash = in the trash: not shown on the site, restorable with media.restore. |
| `data[].missing` | boolean | yes | true when the original no longer exists; places that use it show nothing (excluded from media.list by default unless includeMissing=true). |
| `data[].version` | integer | yes | Increases each time media.replace uploads a new version. |
| `data[].url` | string \| null | yes | Display URL for showing the file to the user. It changes with each version, so never store it; null for protected files. |
| `data[].thumbnailUrl` | string \| null | yes | Thumbnail or poster URL for public files; null for protected files (their thumbnails are only served to signed-in members). |
| `data[].fileName` | string \| null | yes | — |
| `data[].mimeType` | string | yes | — |
| `data[].bytes` | integer \| null | yes | null while the size of an older file is still being calculated. |
| `data[].billableBytes` | integer | yes | Bytes counted toward the site's storage quota (images have a 256 KB minimum; active derived files such as watermarked versions are included). |
| `data[].width` | integer \| null | yes | — |
| `data[].height` | integer \| null | yes | — |
| `data[].durationSeconds` | number \| null | yes | — |
| `data[].pageCount` | integer \| null | yes | — |
| `data[].source` | string | yes | Where it was uploaded (page module, blog, product, media library…). |
| `data[].inUse` | boolean | yes | Used by site content, products, deliverables, emails or a published snapshot. |
| `data[].focus` | object \| null | yes | Default focal point of an image (0-1 on the original): image fields without their own framing crop around it. null when unset or centered. |
| `data[].focus.x` | number | yes | — |
| `data[].focus.y` | number | yes | — |
| `data[].edgeColor` | string \| null | yes | Solid border color (#rrggbb) when all four edges of the image share a uniform opaque color (for example a white-background product photo). null for transparent or multi-colored images. |
| `data[].altText` | string \| null | yes | Default alt text, used when the place that shows the image has no alt of its own. |
| `data[].watermarkExempt` | boolean | yes | true when the owner excluded this image from the site watermark. |
| `data[].createdAt` | string | yes | — |
| `hasMore` | boolean | yes | True when another page exists; pass nextCursor to fetch it. |
| `nextCursor` | string \| null | yes | Cursor for the next page, or null on the last page. |
| `usage` | object | yes | — |
| `usage.usedBytes` | number | yes | Bytes counted against the storage limit (user-uploaded originals; platform derivatives excluded). |
| `usage.limitBytes` | number | yes | Plan storage limit in bytes (including any purchased storage add-on levels, e.g. 10,737,418,240 for 10 GB on PRO). |
| `usage.remainingBytes` | number | yes | Remaining available storage in bytes (Math.max(0, limitBytes - usedBytes)). |
| `usage.totalBytes` | number | yes | Total bytes stored on this website including platform derivatives (favicons, auto OG images). |
| `usage.quotaBytes` | number | yes | Plan storage limit in bytes (alias of limitBytes; compare usedBytes <= quotaBytes to check whether storage is within quota). |
| `usage.fileCount` | integer | yes | Active file count on this website. |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `INVALID_CURSOR` | 400 | — |

```bash
curl -X GET "https://app.kanorio.com/api/v1/sites/{id}/media?assetKind=image&unusedOnly=false" \
  -H "Authorization: Bearer $KANORIO_TOKEN"
```

```json
{
  "data": [
    {
      "assetId": "asset_example01",
      "kind": "image",
      "visibility": "public",
      "tags": [],
      "processing": null,
      "status": "active",
      "missing": false,
      "version": 1,
      "url": "https://media.kanorio.com/example/sunny-bakery-hero.jpg",
      "thumbnailUrl": null,
      "fileName": "sunny-bakery-hero.jpg",
      "mimeType": "image/jpeg",
      "bytes": 482133,
      "streaming": "none",
      "billableBytes": 482133,
      "width": 2400,
      "height": 1600,
      "durationSeconds": null,
      "pageCount": null,
      "source": "IMAGE_CONTENT",
      "inUse": true,
      "focus": null,
      "edgeColor": null,
      "altText": null,
      "watermarkExempt": false,
      "createdAt": "2026-10-01T02:30:00.000Z"
    }
  ],
  "usage": {
    "usedBytes": 482133,
    "limitBytes": 5368709120,
    "remainingBytes": 5368226987,
    "totalBytes": 482133,
    "quotaBytes": 5368709120,
    "fileCount": 1
  },
  "hasMore": false,
  "nextCursor": null
}
```

### Upload a media file

`POST /sites/{id}/media`

- Stability: beta
- Permission: `media:manage`
- Rate limit: default (120/minute per token)
- MCP tool: `media_upload`

Upload one image or file into the website's media library. Provide exactly one of base64 or url. Files the user attaches in chat are not available to you: use media.create_upload_link for those (it also takes videos and documents), media.list_page_images for a page the user owns, or media.search_stock when they have no photos. Images: put asset.url in sections (pages.replace_sections), blog posts and product images. Videos, audio, PDFs, documents, e-books and ZIP files: pass fileName; use visibility "protected" for deliverable files (course lessons, e-books, downloads) and reference them by assetId in deliverable_units.*. Files over 40 MB (base64) or 100 MB (url): use media.request_upload. Keep asset.assetId to manage the file later (media.get, media.replace, media.trash). Uploads are kept; unused files show as unused in the media library. Errors: FORBIDDEN, VALIDATION, INVALID_IMAGE_DATA, INVALID_FILE_DATA, UNSUPPORTED_IMAGE_TYPE, UNSUPPORTED_FILE_TYPE (extension not accepted, or the file header does not match it), FILE_TOO_LARGE, FETCH_FAILED, UPLOAD_FAILED, SERVICE_UNAVAILABLE, STORAGE_LIMIT_EXCEEDED (plan storage is full; every upload counts toward it).

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `base64` | string | no | File bytes as base64 (plain or data: URL), max 40 MB decoded. Images: JPEG, PNG, WebP, GIF, SVG or HEIC, stored as-is (only downscaled when over Cloudflare limits). Other files need fileName. |
| `url` | string | no | Public https URL to copy from: images up to 40 MB, other files up to 100 MB (use media.request_upload for larger files). Private networks and redirects to them are rejected. |
| `fileName` | string | no | File name with extension, e.g. lesson-01.mp4 or workbook.pdf. Required for videos, audio, PDFs, documents, e-books and ZIP files (the extension decides the format; the file header must match). Omit for images. |
| `visibility` | "public" \| "protected" | no | public (default): files for pages, posts and collections; anyone with the link can open them. protected: files for deliverables (paid courses, e-books, downloads) stored privately and only served to buyers through signed short-lived links. Protected images (JPG, PNG, WebP; pass fileName) are stored as private files too: no public variants, not offered in page or post pickers, not usable as deliverable covers. |
| `usage` | string | no | Hint for bookkeeping (images only): content (default), blog, product, favicon. |

**Response** (201)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `asset` | object | yes | — |
| `asset.assetId` | string | yes | Stable id of the file. Reference files by this id; it stays the same when media.replace uploads a new version. |
| `asset.kind` | "image" \| "video" \| "audio" \| "pdf" \| "document" | yes | What the file is (image, video, audio, pdf, document). Whether it is protected is a separate property: visibility. |
| `asset.visibility` | "public" \| "protected" | yes | public: has a public URL and can be placed in pages, posts and collections. protected: no public URL; only served to entitled buyers through signed short-lived links (reference it by assetId in deliverable_units.*). |
| `asset.tags` | string[] | yes | Owner-defined tags (up to 20 per file, 1-40 characters each); filter media.list with tags. |
| `asset.processing` | object \| null | yes | Background processing of the file itself (thumbnails, visibility moves, transcoding): null when the file is ready to use. Subscribe to the media.asset.ready and media.asset.failed webhooks instead of polling. |
| `asset.processing.status` | "pending" \| "processing" \| "failed" | yes | — |
| `asset.processing.kind` | string \| null | yes | Background job kind (for example DOCUMENT_THUMBNAIL). |
| `asset.processing.error` | string \| null | yes | Error code when status is failed. |
| `asset.processing.updatedAt` | string \| null | yes | — |
| `asset.streaming` | "none" \| "processing" \| "partial" \| "ready" | yes | Adaptive-streaming (HLS) version of a video: none (not placed anywhere that plays it, or not a video), processing (queued or transcoding), partial (lowest quality playable, higher qualities still transcoding), ready (all qualities done). Videos are transcoded when first placed in a page, post, collection or deliverable unit, or with media.update { streaming: true }; the streaming version counts toward storage (billableBytes). |
| `asset.status` | "active" \| "trash" | yes | trash = in the trash: not shown on the site, restorable with media.restore. |
| `asset.missing` | boolean | yes | true when the original no longer exists; places that use it show nothing (excluded from media.list by default unless includeMissing=true). |
| `asset.version` | integer | yes | Increases each time media.replace uploads a new version. |
| `asset.url` | string \| null | yes | Display URL for showing the file to the user. It changes with each version, so never store it; null for protected files. |
| `asset.thumbnailUrl` | string \| null | yes | Thumbnail or poster URL for public files; null for protected files (their thumbnails are only served to signed-in members). |
| `asset.fileName` | string \| null | yes | — |
| `asset.mimeType` | string | yes | — |
| `asset.bytes` | integer \| null | yes | null while the size of an older file is still being calculated. |
| `asset.billableBytes` | integer | yes | Bytes counted toward the site's storage quota (images have a 256 KB minimum; active derived files such as watermarked versions are included). |
| `asset.width` | integer \| null | yes | — |
| `asset.height` | integer \| null | yes | — |
| `asset.durationSeconds` | number \| null | yes | — |
| `asset.pageCount` | integer \| null | yes | — |
| `asset.source` | string | yes | Where it was uploaded (page module, blog, product, media library…). |
| `asset.inUse` | boolean | yes | Used by site content, products, deliverables, emails or a published snapshot. |
| `asset.focus` | object \| null | yes | Default focal point of an image (0-1 on the original): image fields without their own framing crop around it. null when unset or centered. |
| `asset.focus.x` | number | yes | — |
| `asset.focus.y` | number | yes | — |
| `asset.edgeColor` | string \| null | yes | Solid border color (#rrggbb) when all four edges of the image share a uniform opaque color (for example a white-background product photo). null for transparent or multi-colored images. |
| `asset.altText` | string \| null | yes | Default alt text, used when the place that shows the image has no alt of its own. |
| `asset.watermarkExempt` | boolean | yes | true when the owner excluded this image from the site watermark. |
| `asset.createdAt` | string | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `VALIDATION` | 400 | — |
| `INVALID_IMAGE_DATA` | 400 | — |
| `INVALID_FILE_DATA` | 400 | — |
| `UNSUPPORTED_IMAGE_TYPE` | 422 | — |
| `UNSUPPORTED_FILE_TYPE` | 422 | extension not accepted, or the file header does not match it |
| `FILE_TOO_LARGE` | 413 | — |
| `FETCH_FAILED` | 422 | — |
| `UPLOAD_FAILED` | 502 | — |
| `SERVICE_UNAVAILABLE` | 503 | — |
| `STORAGE_LIMIT_EXCEEDED` | 403 | plan storage is full; every upload counts toward it |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/media" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/photos/croissant.jpg"}'
```

```json
{
  "asset": {
    "assetId": "asset_example01",
    "kind": "image",
    "visibility": "public",
    "tags": [],
    "processing": null,
    "status": "active",
    "missing": false,
    "version": 1,
    "url": "https://media.kanorio.com/example/sunny-bakery-hero.jpg",
    "thumbnailUrl": null,
    "fileName": "croissant.jpg",
    "mimeType": "image/jpeg",
    "bytes": 482133,
    "streaming": "none",
    "billableBytes": 482133,
    "width": 2400,
    "height": 1600,
    "durationSeconds": null,
    "pageCount": null,
    "source": "IMAGE_CONTENT",
    "inUse": false,
    "focus": null,
    "edgeColor": null,
    "altText": null,
    "watermarkExempt": false,
    "createdAt": "2026-10-06T08:00:00.000Z"
  }
}
```

### Request upload

`POST /sites/{id}/media/uploads`

- Stability: beta
- Permission: `media:manage`
- Rate limit: default (120/minute per token)
- MCP tool: `media_request_upload`

Get a one-time upload URL for a large video, audio, PDF, document, e-book or ZIP file (up to 5 GB) that is too big for media.upload. Send the file with an HTTP PUT to upload.url, with exactly the returned upload.headers and the file bytes as the body (curl -X PUT -H ... --data-binary @file), within an hour; then call media.confirm_upload with fileKey. Unconfirmed uploads are discarded after 24 hours. Use visibility "protected" for deliverable files (no public URL; only served to entitled buyers). Images go through media.upload. Errors: FORBIDDEN, VALIDATION, UNSUPPORTED_FILE_TYPE, INVALID_FILE_DATA (fileSize is 0), FILE_TOO_LARGE, STORAGE_LIMIT_EXCEEDED, SERVICE_UNAVAILABLE.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `fileName` | string | yes | File name with extension; the extension decides the format. |
| `fileSize` | integer | yes | Exact size in bytes; the upload must match it. |
| `visibility` | "public" \| "protected" | no | public (default): files for pages, posts and collections; anyone with the link can open them. protected: files for deliverables (paid courses, e-books, downloads) stored privately and only served to buyers through signed short-lived links. Protected images (JPG, PNG, WebP; pass fileName) are stored as private files too: no public variants, not offered in page or post pickers, not usable as deliverable covers. |

**Response** (201)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `fileKey` | string | yes | Pass to media.confirm_upload. |
| `upload` | object | yes | — |
| `upload.method` | "PUT" | yes | — |
| `upload.url` | string | yes | — |
| `upload.headers` | object | yes | Send every header exactly as given; a missing or changed header is rejected. |
| `upload.expiresAt` | string | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `VALIDATION` | 400 | — |
| `UNSUPPORTED_FILE_TYPE` | 422 | — |
| `INVALID_FILE_DATA` | 400 | fileSize is 0 |
| `FILE_TOO_LARGE` | 413 | — |
| `STORAGE_LIMIT_EXCEEDED` | 403 | — |
| `SERVICE_UNAVAILABLE` | 503 | — |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/media/uploads" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"fileName":"lesson-01.mp4","fileSize":734003200,"visibility":"protected"}'
```

```json
{
  "fileKey": "ws_example01/deliverables/0123456789abcdef0123456789abcd",
  "upload": {
    "method": "PUT",
    "url": "https://kanorio-products.example.r2.cloudflarestorage.com/ws_example01/deliverables/0123456789abcdef0123456789abcd?X-Amz-Signature=example",
    "headers": {
      "Content-Type": "video/mp4",
      "x-amz-meta-filename": "lesson-01.mp4"
    },
    "expiresAt": "2026-10-06T08:00:00.000Z"
  }
}
```

### Confirm upload

`POST /sites/{id}/media/uploads/confirm`

- Stability: beta
- Permission: `media:manage`
- Rate limit: default (120/minute per token)
- MCP tool: `media_confirm_upload`

Finish a media.request_upload: the file is checked (size, format and file header must match the request) and added to the media library. Calling it again for the same fileKey returns the same asset. Protected files are referenced by assetId in deliverable_units.*; public files by asset.url in sections, posts and collections. Errors: FORBIDDEN, UPLOAD_NOT_FOUND (not uploaded yet, expired, or the fileKey is not from this website), UNSUPPORTED_FILE_TYPE, INVALID_FILE_DATA, FILE_TOO_LARGE, STORAGE_LIMIT_EXCEEDED, SERVICE_UNAVAILABLE, UPLOAD_FAILED.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `fileKey` | string | yes | fileKey from media.request_upload. |
| `visibility` | "public" \| "protected" | no | The same visibility passed to media.request_upload. |

**Response** (201)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `asset` | object | yes | — |
| `asset.assetId` | string | yes | Stable id of the file. Reference files by this id; it stays the same when media.replace uploads a new version. |
| `asset.kind` | "image" \| "video" \| "audio" \| "pdf" \| "document" | yes | What the file is (image, video, audio, pdf, document). Whether it is protected is a separate property: visibility. |
| `asset.visibility` | "public" \| "protected" | yes | public: has a public URL and can be placed in pages, posts and collections. protected: no public URL; only served to entitled buyers through signed short-lived links (reference it by assetId in deliverable_units.*). |
| `asset.tags` | string[] | yes | Owner-defined tags (up to 20 per file, 1-40 characters each); filter media.list with tags. |
| `asset.processing` | object \| null | yes | Background processing of the file itself (thumbnails, visibility moves, transcoding): null when the file is ready to use. Subscribe to the media.asset.ready and media.asset.failed webhooks instead of polling. |
| `asset.processing.status` | "pending" \| "processing" \| "failed" | yes | — |
| `asset.processing.kind` | string \| null | yes | Background job kind (for example DOCUMENT_THUMBNAIL). |
| `asset.processing.error` | string \| null | yes | Error code when status is failed. |
| `asset.processing.updatedAt` | string \| null | yes | — |
| `asset.streaming` | "none" \| "processing" \| "partial" \| "ready" | yes | Adaptive-streaming (HLS) version of a video: none (not placed anywhere that plays it, or not a video), processing (queued or transcoding), partial (lowest quality playable, higher qualities still transcoding), ready (all qualities done). Videos are transcoded when first placed in a page, post, collection or deliverable unit, or with media.update { streaming: true }; the streaming version counts toward storage (billableBytes). |
| `asset.status` | "active" \| "trash" | yes | trash = in the trash: not shown on the site, restorable with media.restore. |
| `asset.missing` | boolean | yes | true when the original no longer exists; places that use it show nothing (excluded from media.list by default unless includeMissing=true). |
| `asset.version` | integer | yes | Increases each time media.replace uploads a new version. |
| `asset.url` | string \| null | yes | Display URL for showing the file to the user. It changes with each version, so never store it; null for protected files. |
| `asset.thumbnailUrl` | string \| null | yes | Thumbnail or poster URL for public files; null for protected files (their thumbnails are only served to signed-in members). |
| `asset.fileName` | string \| null | yes | — |
| `asset.mimeType` | string | yes | — |
| `asset.bytes` | integer \| null | yes | null while the size of an older file is still being calculated. |
| `asset.billableBytes` | integer | yes | Bytes counted toward the site's storage quota (images have a 256 KB minimum; active derived files such as watermarked versions are included). |
| `asset.width` | integer \| null | yes | — |
| `asset.height` | integer \| null | yes | — |
| `asset.durationSeconds` | number \| null | yes | — |
| `asset.pageCount` | integer \| null | yes | — |
| `asset.source` | string | yes | Where it was uploaded (page module, blog, product, media library…). |
| `asset.inUse` | boolean | yes | Used by site content, products, deliverables, emails or a published snapshot. |
| `asset.focus` | object \| null | yes | Default focal point of an image (0-1 on the original): image fields without their own framing crop around it. null when unset or centered. |
| `asset.focus.x` | number | yes | — |
| `asset.focus.y` | number | yes | — |
| `asset.edgeColor` | string \| null | yes | Solid border color (#rrggbb) when all four edges of the image share a uniform opaque color (for example a white-background product photo). null for transparent or multi-colored images. |
| `asset.altText` | string \| null | yes | Default alt text, used when the place that shows the image has no alt of its own. |
| `asset.watermarkExempt` | boolean | yes | true when the owner excluded this image from the site watermark. |
| `asset.createdAt` | string | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `UPLOAD_NOT_FOUND` | 404 | not uploaded yet, expired, or the fileKey is not from this website |
| `UNSUPPORTED_FILE_TYPE` | 422 | — |
| `INVALID_FILE_DATA` | 400 | — |
| `FILE_TOO_LARGE` | 413 | — |
| `STORAGE_LIMIT_EXCEEDED` | 403 | — |
| `SERVICE_UNAVAILABLE` | 503 | — |
| `UPLOAD_FAILED` | 502 | — |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/media/uploads/confirm" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"fileKey":"ws_example01/deliverables/0123456789abcdef0123456789abcd","visibility":"protected"}'
```

```json
{
  "asset": {
    "assetId": "asset_example01",
    "kind": "video",
    "visibility": "protected",
    "tags": [],
    "processing": null,
    "status": "active",
    "missing": false,
    "version": 1,
    "url": null,
    "thumbnailUrl": null,
    "fileName": "lesson-01.mp4",
    "mimeType": "video/mp4",
    "bytes": 734003200,
    "streaming": "none",
    "billableBytes": 734003200,
    "width": null,
    "height": null,
    "durationSeconds": null,
    "pageCount": null,
    "source": "PRODUCT",
    "inUse": false,
    "focus": null,
    "edgeColor": null,
    "altText": null,
    "watermarkExempt": false,
    "createdAt": "2026-10-06T08:00:00.000Z"
  }
}
```

### Create upload link

`POST /sites/{id}/media/upload-links`

- Stability: beta
- Permission: `media:manage`
- Rate limit: default (120/minute per token)
- MCP tool: `media_create_upload_link`

Create a short-lived (15 min) upload page the user opens on their phone or computer to send you photos or files. Use it whenever the user offers photos, videos or documents in the conversation: chat clients never pass attachments to tools, so you cannot see or upload those files yourself and must not pretend you did. target: images (default) for photos; files for videos, audio, PDFs and documents shown on pages or posts (public); protected for deliverable files such as course lessons and e-books (private, no public URL). Files go up to 5 GB each. Reply to the user with the returned url in plain words (e.g. 'Open this link and add your photos, then tell me when you're done'). Afterwards call media.upload_link_status with the same token to get the uploaded assets. Errors: FORBIDDEN, SERVICE_UNAVAILABLE (try again shortly).

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `maxFiles` | integer | no | How many photos or files the link accepts (default 10, max 20). |
| `purpose` | string | no | Shown on the upload page in the user's words, e.g. 'menu photos' or 'storefront and team'. |
| `target` | "images" \| "files" \| "protected" | no | What the link accepts: images (default), files (public) or protected (deliverable files). |

**Response** (201)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | string | yes | Give this to the user. |
| `token` | string | yes | Keep it to call media.upload_link_status. |
| `linkId` | string | yes | — |
| `expiresAt` | string | yes | — |
| `maxFiles` | integer | yes | — |
| `target` | "images" \| "files" \| "protected" | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `SERVICE_UNAVAILABLE` | 503 | try again shortly |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/media/upload-links" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"maxFiles":6,"purpose":"menu photos"}'
```

```json
{
  "url": "https://app.kanorio.com/u/example-upload-link-token",
  "token": "example-upload-link-token",
  "linkId": "lnk_example01",
  "expiresAt": "2026-10-06T08:00:00.000Z",
  "maxFiles": 6,
  "target": "images"
}
```

### Upload link status

`GET /sites/{id}/media/upload-links/status`

- Stability: beta
- Permission: `media:view`
- Rate limit: default (120/minute per token)
- MCP tool: `media_upload_link_status`

Check what the user has uploaded through an upload link (media.create_upload_link). Call it when the user says they are done, or to poll every 20-30 seconds while waiting. Returns the uploaded images or files as assets (put asset:<assetId> in sections; reference protected files by assetId in deliverable_units.*) and how many more the link accepts. The status stays readable for a few minutes after the link expires. Errors: FORBIDDEN, LINK_INVALID (token does not belong to this website or is malformed).

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `token` | query | string | yes | The token returned by media.create_upload_link. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `linkId` | string | yes | — |
| `expiresAt` | string | yes | — |
| `maxFiles` | integer | yes | — |
| `uploadedCount` | integer | yes | — |
| `remaining` | integer | yes | — |
| `purpose` | string \| null | yes | — |
| `target` | "images" \| "files" \| "protected" | yes | — |
| `assets` | object[] | yes | — |
| `assets[].assetId` | string | yes | Stable id of the file. Reference files by this id; it stays the same when media.replace uploads a new version. |
| `assets[].kind` | "image" \| "video" \| "audio" \| "pdf" \| "document" | yes | What the file is (image, video, audio, pdf, document). Whether it is protected is a separate property: visibility. |
| `assets[].visibility` | "public" \| "protected" | yes | public: has a public URL and can be placed in pages, posts and collections. protected: no public URL; only served to entitled buyers through signed short-lived links (reference it by assetId in deliverable_units.*). |
| `assets[].tags` | string[] | yes | Owner-defined tags (up to 20 per file, 1-40 characters each); filter media.list with tags. |
| `assets[].processing` | object \| null | yes | Background processing of the file itself (thumbnails, visibility moves, transcoding): null when the file is ready to use. Subscribe to the media.asset.ready and media.asset.failed webhooks instead of polling. |
| `assets[].processing.status` | "pending" \| "processing" \| "failed" | yes | — |
| `assets[].processing.kind` | string \| null | yes | Background job kind (for example DOCUMENT_THUMBNAIL). |
| `assets[].processing.error` | string \| null | yes | Error code when status is failed. |
| `assets[].processing.updatedAt` | string \| null | yes | — |
| `assets[].streaming` | "none" \| "processing" \| "partial" \| "ready" | yes | Adaptive-streaming (HLS) version of a video: none (not placed anywhere that plays it, or not a video), processing (queued or transcoding), partial (lowest quality playable, higher qualities still transcoding), ready (all qualities done). Videos are transcoded when first placed in a page, post, collection or deliverable unit, or with media.update { streaming: true }; the streaming version counts toward storage (billableBytes). |
| `assets[].status` | "active" \| "trash" | yes | trash = in the trash: not shown on the site, restorable with media.restore. |
| `assets[].missing` | boolean | yes | true when the original no longer exists; places that use it show nothing (excluded from media.list by default unless includeMissing=true). |
| `assets[].version` | integer | yes | Increases each time media.replace uploads a new version. |
| `assets[].url` | string \| null | yes | Display URL for showing the file to the user. It changes with each version, so never store it; null for protected files. |
| `assets[].thumbnailUrl` | string \| null | yes | Thumbnail or poster URL for public files; null for protected files (their thumbnails are only served to signed-in members). |
| `assets[].fileName` | string \| null | yes | — |
| `assets[].mimeType` | string | yes | — |
| `assets[].bytes` | integer \| null | yes | null while the size of an older file is still being calculated. |
| `assets[].billableBytes` | integer | yes | Bytes counted toward the site's storage quota (images have a 256 KB minimum; active derived files such as watermarked versions are included). |
| `assets[].width` | integer \| null | yes | — |
| `assets[].height` | integer \| null | yes | — |
| `assets[].durationSeconds` | number \| null | yes | — |
| `assets[].pageCount` | integer \| null | yes | — |
| `assets[].source` | string | yes | Where it was uploaded (page module, blog, product, media library…). |
| `assets[].inUse` | boolean | yes | Used by site content, products, deliverables, emails or a published snapshot. |
| `assets[].focus` | object \| null | yes | Default focal point of an image (0-1 on the original): image fields without their own framing crop around it. null when unset or centered. |
| `assets[].focus.x` | number | yes | — |
| `assets[].focus.y` | number | yes | — |
| `assets[].edgeColor` | string \| null | yes | Solid border color (#rrggbb) when all four edges of the image share a uniform opaque color (for example a white-background product photo). null for transparent or multi-colored images. |
| `assets[].altText` | string \| null | yes | Default alt text, used when the place that shows the image has no alt of its own. |
| `assets[].watermarkExempt` | boolean | yes | true when the owner excluded this image from the site watermark. |
| `assets[].createdAt` | string | yes | — |
| `assets[].uploadedAt` | string | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `LINK_INVALID` | 404 | token does not belong to this website or is malformed |

```bash
curl -X GET "https://app.kanorio.com/api/v1/sites/{id}/media/upload-links/status?token=example-upload-link-token" \
  -H "Authorization: Bearer $KANORIO_TOKEN"
```

```json
{
  "linkId": "lnk_example01",
  "expiresAt": "2026-10-06T08:00:00.000Z",
  "maxFiles": 6,
  "uploadedCount": 2,
  "remaining": 4,
  "purpose": "menu photos",
  "target": "images",
  "assets": [
    {
      "assetId": "asset_example01",
      "kind": "image",
      "visibility": "public",
      "tags": [],
      "processing": null,
      "status": "active",
      "missing": false,
      "version": 1,
      "url": "https://media.kanorio.com/example/sunny-bakery-hero.jpg",
      "thumbnailUrl": null,
      "fileName": "menu-front.jpg",
      "mimeType": "image/jpeg",
      "bytes": 482133,
      "streaming": "none",
      "billableBytes": 482133,
      "width": 2400,
      "height": 1600,
      "durationSeconds": null,
      "pageCount": null,
      "source": "IMAGE_CONTENT",
      "inUse": false,
      "focus": null,
      "edgeColor": null,
      "altText": null,
      "watermarkExempt": false,
      "createdAt": "2026-10-06T08:00:00.000Z",
      "uploadedAt": "2026-10-06T08:00:00.000Z"
    },
    {
      "assetId": "asset_example02",
      "kind": "image",
      "visibility": "public",
      "tags": [],
      "processing": null,
      "status": "active",
      "missing": false,
      "version": 1,
      "url": "https://media.kanorio.com/example/sunny-bakery-hero.jpg",
      "thumbnailUrl": null,
      "fileName": "menu-back.jpg",
      "mimeType": "image/jpeg",
      "bytes": 391204,
      "streaming": "none",
      "billableBytes": 391204,
      "width": 2400,
      "height": 1600,
      "durationSeconds": null,
      "pageCount": null,
      "source": "IMAGE_CONTENT",
      "inUse": false,
      "focus": null,
      "edgeColor": null,
      "altText": null,
      "watermarkExempt": false,
      "createdAt": "2026-10-06T08:00:00.000Z",
      "uploadedAt": "2026-10-06T08:00:00.000Z"
    }
  ]
}
```

### Search stock

`GET /sites/{id}/media/stock`

- Stability: beta
- Permission: `editor:enter`
- Rate limit: default (120/minute per token)
- MCP tool: `media_search_stock`

Search free stock photos (Pexels and Pixabay) when the user has no photos of their own. Ask the user first whether they have photos; use stock as the fallback and tell them the images are stock photos they can replace later. Write the query in English with concrete nouns (e.g. 'sourdough bread bakery counter', not 'nice bakery'); add orientation=landscape for hero backgrounds. Both libraries allow commercial use without attribution; do not use stock photos as the brand logo or to imply endorsement. Pick a hit, then call media.import_stock to copy it into the website's media library. Errors: FORBIDDEN, SEARCH_BUSY (retry after details.retryAfter seconds), SEARCH_FAILED, UNKNOWN_PROVIDER, INVALID_CURSOR.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website the photos are for (scopes the token's access). |
| `query` | query | string | yes | English search terms work best. |
| `orientation` | query | "landscape" \| "portrait" \| "square" | no | — |
| `provider` | query | "all" \| "pexels" \| "pixabay" | no | Defaults to all (results interleaved). |
| `limit` | query | integer | no | Maximum number of items to return, 1–12. Defaults to 9. |
| `cursor` | query | string | no | Opaque cursor from the previous page's nextCursor. Omit for the first page. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object[] | yes | — |
| `data[].id` | string | yes | — |
| `data[].provider` | string | yes | pexels or pixabay. |
| `data[].previewURL` | string | yes | Small preview; do not put this in sections. |
| `data[].displayURL` | string | yes | — |
| `data[].downloadURL` | string | yes | Pass to media.import_stock together with id and provider. |
| `data[].width` | integer | yes | — |
| `data[].height` | integer | yes | — |
| `data[].photographerName` | string | yes | — |
| `data[].photoPageURL` | string | yes | — |
| `data[].alt` | string \| null | yes | — |
| `hasMore` | boolean | yes | True when another page exists; pass nextCursor to fetch it. |
| `nextCursor` | string \| null | yes | Cursor for the next page, or null on the last page. |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `SEARCH_BUSY` | 429 | retry after details.retryAfter seconds |
| `SEARCH_FAILED` | 502 | — |
| `UNKNOWN_PROVIDER` | 422 | — |
| `INVALID_CURSOR` | 400 | — |

```bash
curl -X GET "https://app.kanorio.com/api/v1/sites/{id}/media/stock?query=sourdough+bread+bakery+counter&orientation=landscape&limit=3" \
  -H "Authorization: Bearer $KANORIO_TOKEN"
```

```json
{
  "data": [
    {
      "id": "1234567",
      "provider": "pexels",
      "previewURL": "https://images.pexels.com/photos/1234567/pexels-photo-1234567.jpeg?w=280",
      "displayURL": "https://images.pexels.com/photos/1234567/pexels-photo-1234567.jpeg?w=1280",
      "downloadURL": "https://images.pexels.com/photos/1234567/pexels-photo-1234567.jpeg",
      "width": 4000,
      "height": 2667,
      "photographerName": "Example Photographer",
      "photoPageURL": "https://www.pexels.com/photo/1234567/",
      "alt": "Sourdough loaves on a wooden counter"
    }
  ],
  "hasMore": false,
  "nextCursor": null
}
```

### Import stock

`POST /sites/{id}/media/stock`

- Stability: beta
- Permission: `media:manage`
- Rate limit: default (120/minute per token)
- MCP tool: `media_import_stock`

Copy one stock photo from media.search_stock into the website's media library. Pass provider, id and downloadURL exactly as returned by the search. Returns the asset: put asset.url in sections (backgroundImage, images[].url) and keep asset.assetId to manage it. Write the imageAlt yourself in the site's content language, based on the photo's alt and the section's purpose. Errors: FORBIDDEN, UNKNOWN_PROVIDER, INVALID_STOCK_URL, STOCK_BUSY, INVALID_IMAGE_DATA, UPLOAD_FAILED, STORAGE_LIMIT_EXCEEDED.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `provider` | "pexels" \| "pixabay" | yes | — |
| `id` | string | yes | Stock photo id from the search result. |
| `downloadURL` | string | yes | downloadURL from the search result. |
| `usage` | string | no | Bookkeeping hint: content (default), blog, product. |

**Response** (201)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `asset` | object | yes | — |
| `asset.assetId` | string | yes | Stable id of the file. Reference files by this id; it stays the same when media.replace uploads a new version. |
| `asset.kind` | "image" \| "video" \| "audio" \| "pdf" \| "document" | yes | What the file is (image, video, audio, pdf, document). Whether it is protected is a separate property: visibility. |
| `asset.visibility` | "public" \| "protected" | yes | public: has a public URL and can be placed in pages, posts and collections. protected: no public URL; only served to entitled buyers through signed short-lived links (reference it by assetId in deliverable_units.*). |
| `asset.tags` | string[] | yes | Owner-defined tags (up to 20 per file, 1-40 characters each); filter media.list with tags. |
| `asset.processing` | object \| null | yes | Background processing of the file itself (thumbnails, visibility moves, transcoding): null when the file is ready to use. Subscribe to the media.asset.ready and media.asset.failed webhooks instead of polling. |
| `asset.processing.status` | "pending" \| "processing" \| "failed" | yes | — |
| `asset.processing.kind` | string \| null | yes | Background job kind (for example DOCUMENT_THUMBNAIL). |
| `asset.processing.error` | string \| null | yes | Error code when status is failed. |
| `asset.processing.updatedAt` | string \| null | yes | — |
| `asset.streaming` | "none" \| "processing" \| "partial" \| "ready" | yes | Adaptive-streaming (HLS) version of a video: none (not placed anywhere that plays it, or not a video), processing (queued or transcoding), partial (lowest quality playable, higher qualities still transcoding), ready (all qualities done). Videos are transcoded when first placed in a page, post, collection or deliverable unit, or with media.update { streaming: true }; the streaming version counts toward storage (billableBytes). |
| `asset.status` | "active" \| "trash" | yes | trash = in the trash: not shown on the site, restorable with media.restore. |
| `asset.missing` | boolean | yes | true when the original no longer exists; places that use it show nothing (excluded from media.list by default unless includeMissing=true). |
| `asset.version` | integer | yes | Increases each time media.replace uploads a new version. |
| `asset.url` | string \| null | yes | Display URL for showing the file to the user. It changes with each version, so never store it; null for protected files. |
| `asset.thumbnailUrl` | string \| null | yes | Thumbnail or poster URL for public files; null for protected files (their thumbnails are only served to signed-in members). |
| `asset.fileName` | string \| null | yes | — |
| `asset.mimeType` | string | yes | — |
| `asset.bytes` | integer \| null | yes | null while the size of an older file is still being calculated. |
| `asset.billableBytes` | integer | yes | Bytes counted toward the site's storage quota (images have a 256 KB minimum; active derived files such as watermarked versions are included). |
| `asset.width` | integer \| null | yes | — |
| `asset.height` | integer \| null | yes | — |
| `asset.durationSeconds` | number \| null | yes | — |
| `asset.pageCount` | integer \| null | yes | — |
| `asset.source` | string | yes | Where it was uploaded (page module, blog, product, media library…). |
| `asset.inUse` | boolean | yes | Used by site content, products, deliverables, emails or a published snapshot. |
| `asset.focus` | object \| null | yes | Default focal point of an image (0-1 on the original): image fields without their own framing crop around it. null when unset or centered. |
| `asset.focus.x` | number | yes | — |
| `asset.focus.y` | number | yes | — |
| `asset.edgeColor` | string \| null | yes | Solid border color (#rrggbb) when all four edges of the image share a uniform opaque color (for example a white-background product photo). null for transparent or multi-colored images. |
| `asset.altText` | string \| null | yes | Default alt text, used when the place that shows the image has no alt of its own. |
| `asset.watermarkExempt` | boolean | yes | true when the owner excluded this image from the site watermark. |
| `asset.createdAt` | string | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `UNKNOWN_PROVIDER` | 422 | — |
| `INVALID_STOCK_URL` | 400 | — |
| `STOCK_BUSY` | 429 | — |
| `INVALID_IMAGE_DATA` | 400 | — |
| `UPLOAD_FAILED` | 502 | — |
| `STORAGE_LIMIT_EXCEEDED` | 403 | — |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/media/stock" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"provider":"pexels","id":"1234567","downloadURL":"https://images.pexels.com/photos/1234567/pexels-photo-1234567.jpeg"}'
```

```json
{
  "asset": {
    "assetId": "asset_example01",
    "kind": "image",
    "visibility": "public",
    "tags": [],
    "processing": null,
    "status": "active",
    "missing": false,
    "version": 1,
    "url": "https://media.kanorio.com/example/sunny-bakery-hero.jpg",
    "thumbnailUrl": null,
    "fileName": "pexels-1234567.jpeg",
    "mimeType": "image/jpeg",
    "bytes": 482133,
    "streaming": "none",
    "billableBytes": 482133,
    "width": 2400,
    "height": 1600,
    "durationSeconds": null,
    "pageCount": null,
    "source": "IMAGE_CONTENT",
    "inUse": false,
    "focus": null,
    "edgeColor": null,
    "altText": null,
    "watermarkExempt": false,
    "createdAt": "2026-10-06T08:00:00.000Z"
  }
}
```

### List page images

`GET /sites/{id}/media/page-images`

- Stability: beta
- Permission: `media:view`
- Rate limit: default (120/minute per token)
- MCP tool: `media_list_page_images`

List the images found on a public web page (its cover image, content images and logo) when the user points you to a site they own, such as their old website or a brand page. Nothing is downloaded; pick the ones you need and copy them with media.upload (url mode). Before copying, confirm with the user that the images are theirs or they have the right to use them; do not copy images from sites that are not the user's. Pages behind a login or anti-bot walls (Instagram, Facebook) fail with FETCH_FAILED: ask the user to use an upload link instead. Errors: FORBIDDEN, INVALID_URL (private or non-http address), FETCH_FAILED (details.status is the upstream status).

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website the images are for (scopes the token's access). |
| `url` | query | string | yes | — |
| `max` | query | integer | no | Defaults to 12. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object[] | yes | — |
| `data[].url` | string | yes | — |
| `data[].role` | "cover" \| "content" \| "logo" | yes | — |
| `data[].ownAsset` | boolean | yes | false when hosted on a website-builder CDN (likely a platform asset, not the brand's). |
| `hasMore` | boolean | yes | True when another page exists; pass nextCursor to fetch it. |
| `nextCursor` | string \| null | yes | Cursor for the next page, or null on the last page. |
| `pageUrl` | string | yes | — |
| `finalUrl` | string | yes | After redirects. |
| `title` | string \| null | yes | — |
| `siteName` | string \| null | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `INVALID_URL` | 400 | private or non-http address |
| `FETCH_FAILED` | 422 | details.status is the upstream status |

```bash
curl -X GET "https://app.kanorio.com/api/v1/sites/{id}/media/page-images?url=https%3A%2F%2Fold-site.example.com%2F&max=5" \
  -H "Authorization: Bearer $KANORIO_TOKEN"
```

```json
{
  "pageUrl": "https://old-site.example.com/",
  "finalUrl": "https://old-site.example.com/",
  "title": "Sunny Bakery",
  "siteName": "Sunny Bakery",
  "data": [
    {
      "url": "https://old-site.example.com/images/og.jpg",
      "role": "cover",
      "ownAsset": true
    },
    {
      "url": "https://old-site.example.com/images/logo.png",
      "role": "logo",
      "ownAsset": true
    },
    {
      "url": "https://old-site.example.com/images/croissant.jpg",
      "role": "content",
      "ownAsset": true
    }
  ],
  "hasMore": false,
  "nextCursor": null
}
```

### Retrieve a media file

`GET /sites/{id}/media/{assetId}`

- Stability: beta
- Permission: `media:view`
- Rate limit: default (120/minute per token)
- MCP tool: `media_get`

Get one file from the media library or the trash, with every place the site uses it (page sections, share images, brand, posts, products, emails). Errors: FORBIDDEN, ASSET_NOT_FOUND.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `assetId` | path | string | yes | Asset id from media.list. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `asset` | object | yes | — |
| `asset.assetId` | string | yes | Stable id of the file. Reference files by this id; it stays the same when media.replace uploads a new version. |
| `asset.kind` | "image" \| "video" \| "audio" \| "pdf" \| "document" | yes | What the file is (image, video, audio, pdf, document). Whether it is protected is a separate property: visibility. |
| `asset.visibility` | "public" \| "protected" | yes | public: has a public URL and can be placed in pages, posts and collections. protected: no public URL; only served to entitled buyers through signed short-lived links (reference it by assetId in deliverable_units.*). |
| `asset.tags` | string[] | yes | Owner-defined tags (up to 20 per file, 1-40 characters each); filter media.list with tags. |
| `asset.processing` | object \| null | yes | Background processing of the file itself (thumbnails, visibility moves, transcoding): null when the file is ready to use. Subscribe to the media.asset.ready and media.asset.failed webhooks instead of polling. |
| `asset.processing.status` | "pending" \| "processing" \| "failed" | yes | — |
| `asset.processing.kind` | string \| null | yes | Background job kind (for example DOCUMENT_THUMBNAIL). |
| `asset.processing.error` | string \| null | yes | Error code when status is failed. |
| `asset.processing.updatedAt` | string \| null | yes | — |
| `asset.streaming` | "none" \| "processing" \| "partial" \| "ready" | yes | Adaptive-streaming (HLS) version of a video: none (not placed anywhere that plays it, or not a video), processing (queued or transcoding), partial (lowest quality playable, higher qualities still transcoding), ready (all qualities done). Videos are transcoded when first placed in a page, post, collection or deliverable unit, or with media.update { streaming: true }; the streaming version counts toward storage (billableBytes). |
| `asset.status` | "active" \| "trash" | yes | trash = in the trash: not shown on the site, restorable with media.restore. |
| `asset.missing` | boolean | yes | true when the original no longer exists; places that use it show nothing (excluded from media.list by default unless includeMissing=true). |
| `asset.version` | integer | yes | Increases each time media.replace uploads a new version. |
| `asset.url` | string \| null | yes | Display URL for showing the file to the user. It changes with each version, so never store it; null for protected files. |
| `asset.thumbnailUrl` | string \| null | yes | Thumbnail or poster URL for public files; null for protected files (their thumbnails are only served to signed-in members). |
| `asset.fileName` | string \| null | yes | — |
| `asset.mimeType` | string | yes | — |
| `asset.bytes` | integer \| null | yes | null while the size of an older file is still being calculated. |
| `asset.billableBytes` | integer | yes | Bytes counted toward the site's storage quota (images have a 256 KB minimum; active derived files such as watermarked versions are included). |
| `asset.width` | integer \| null | yes | — |
| `asset.height` | integer \| null | yes | — |
| `asset.durationSeconds` | number \| null | yes | — |
| `asset.pageCount` | integer \| null | yes | — |
| `asset.source` | string | yes | Where it was uploaded (page module, blog, product, media library…). |
| `asset.inUse` | boolean | yes | Used by site content, products, deliverables, emails or a published snapshot. |
| `asset.focus` | object \| null | yes | Default focal point of an image (0-1 on the original): image fields without their own framing crop around it. null when unset or centered. |
| `asset.focus.x` | number | yes | — |
| `asset.focus.y` | number | yes | — |
| `asset.edgeColor` | string \| null | yes | Solid border color (#rrggbb) when all four edges of the image share a uniform opaque color (for example a white-background product photo). null for transparent or multi-colored images. |
| `asset.altText` | string \| null | yes | Default alt text, used when the place that shows the image has no alt of its own. |
| `asset.watermarkExempt` | boolean | yes | true when the owner excluded this image from the site watermark. |
| `asset.createdAt` | string | yes | — |
| `usages` | object[] | yes | Empty when no content uses the file. |
| `usages[].kind` | string | yes | pageSection, pageShare, siteContent, brand, favicon, watermark, post, blogTaxonomy, product, productContent, deliverable, productCategory, collectionItem, floatingWidgets, messageTemplate or campaign. |
| `usages[].label` | string | yes | Where it is used, in words you can repeat to the user (page › section, post title, product name…). |
| `renditions` | object[] \| null | yes | Playback sources for public files: original, poster when there is one, and for transcoded videos hls (adaptive-streaming master manifest, application/vnd.apple.mpegurl; play this one, fall back to original) plus thumbnails (progress-bar storyboard VTT). Check streaming: partial means only the lowest quality is in the manifest yet. null for protected files; buyers get their sources through the Customer API. |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `ASSET_NOT_FOUND` | 404 | — |

```bash
curl -X GET "https://app.kanorio.com/api/v1/sites/{id}/media/{assetId}" \
  -H "Authorization: Bearer $KANORIO_TOKEN"
```

```json
{
  "asset": {
    "assetId": "asset_example01",
    "kind": "image",
    "visibility": "public",
    "tags": [],
    "processing": null,
    "status": "active",
    "missing": false,
    "version": 1,
    "url": "https://media.kanorio.com/example/sunny-bakery-hero.jpg",
    "thumbnailUrl": null,
    "fileName": "sunny-bakery-hero.jpg",
    "mimeType": "image/jpeg",
    "bytes": 482133,
    "streaming": "none",
    "billableBytes": 482133,
    "width": 2400,
    "height": 1600,
    "durationSeconds": null,
    "pageCount": null,
    "source": "IMAGE_CONTENT",
    "inUse": true,
    "focus": null,
    "edgeColor": null,
    "altText": null,
    "watermarkExempt": false,
    "createdAt": "2026-10-01T02:30:00.000Z"
  },
  "usages": [
    {
      "kind": "pageSection",
      "label": "Home › Our story"
    },
    {
      "kind": "brand",
      "label": "Brand logo"
    }
  ],
  "renditions": [
    {
      "kind": "original",
      "url": "https://media.kanorio.com/example/sunny-bakery-hero.jpg",
      "mimeType": "image/jpeg",
      "width": 2400,
      "height": 1600,
      "bytes": 482133
    }
  ]
}
```

### Update a media file

`PATCH /sites/{id}/media/{assetId}`

- Stability: beta
- Permission: `media:manage`
- Rate limit: default (120/minute per token)
- MCP tool: `media_update`

Set a file's tags and an image's defaults, kept across new versions. tags (any file): replaces the whole list; up to 20 tags of 1-40 characters, case-insensitive duplicates dropped; filter media.list with tags. Images only: focus (0-1 point on the original that image fields without their own framing crop around; null resets to center), altText (used where the image is shown without an alt of its own; null clears it) and watermarkExempt (show this image without the site watermark; needs the site:manage_settings scope and permission). The Delivery API and the editor use the new focus right away; published pages switch after the next site.publish. Errors: FORBIDDEN, ASSET_NOT_FOUND (also for files in the trash and older versions), IMAGES_ONLY (focus or altText on a non-image), TAGS_INVALID, VISIBILITY_BLOCKED (details.reason: in_use | deliverable | trash | missing | processing | unsupported_kind | older_version; details.locations for in_use), VALIDATION.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `assetId` | path | string | yes | Asset id from media.list. |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `focus` | object \| null | no | Focal point as 0-1 coordinates on the original image (x from the left, y from the top); null resets to center. |
| `focus.x` | number | yes | — |
| `focus.y` | number | yes | — |
| `altText` | string \| null | no | Default alt text in the site's content language; null clears it. |
| `tags` | string[] | no | Replaces the file's tags (any file). Up to 20 tags of 1-40 characters; no commas, line breaks or leading #. |
| `visibility` | "public" \| "protected" | no | Move a video, audio, PDF or document between public and protected. Images cannot be switched (public images live in the image CDN, protected images in private storage): upload the image again with the visibility you need (VISIBILITY_BLOCKED, reason unsupported_kind). The file is moved between storage buckets in the background: the asset shows processing until done and media.asset.ready fires. public → protected is refused while any content uses the file (VISIBILITY_BLOCKED, reason in_use, with locations); protected → public is refused while a deliverable unit uses it (reason deliverable). Old public URLs may stay cached at the CDN edge for a while after the move. |
| `watermarkExempt` | boolean | no | true shows this image without the site watermark. |
| `streaming` | true | no | Videos only: queue the adaptive-streaming (HLS) version now instead of waiting for the hourly check that transcodes videos placed in pages, posts, collections or deliverable units. The lowest quality becomes playable first and the rest follow; media.asset.ready fires when done. Streaming versions count toward the site's storage quota and cannot be removed separately from the video (ADR 0008). VALIDATION when the file is not a video. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `asset` | object | yes | — |
| `asset.assetId` | string | yes | Stable id of the file. Reference files by this id; it stays the same when media.replace uploads a new version. |
| `asset.kind` | "image" \| "video" \| "audio" \| "pdf" \| "document" | yes | What the file is (image, video, audio, pdf, document). Whether it is protected is a separate property: visibility. |
| `asset.visibility` | "public" \| "protected" | yes | public: has a public URL and can be placed in pages, posts and collections. protected: no public URL; only served to entitled buyers through signed short-lived links (reference it by assetId in deliverable_units.*). |
| `asset.tags` | string[] | yes | Owner-defined tags (up to 20 per file, 1-40 characters each); filter media.list with tags. |
| `asset.processing` | object \| null | yes | Background processing of the file itself (thumbnails, visibility moves, transcoding): null when the file is ready to use. Subscribe to the media.asset.ready and media.asset.failed webhooks instead of polling. |
| `asset.processing.status` | "pending" \| "processing" \| "failed" | yes | — |
| `asset.processing.kind` | string \| null | yes | Background job kind (for example DOCUMENT_THUMBNAIL). |
| `asset.processing.error` | string \| null | yes | Error code when status is failed. |
| `asset.processing.updatedAt` | string \| null | yes | — |
| `asset.streaming` | "none" \| "processing" \| "partial" \| "ready" | yes | Adaptive-streaming (HLS) version of a video: none (not placed anywhere that plays it, or not a video), processing (queued or transcoding), partial (lowest quality playable, higher qualities still transcoding), ready (all qualities done). Videos are transcoded when first placed in a page, post, collection or deliverable unit, or with media.update { streaming: true }; the streaming version counts toward storage (billableBytes). |
| `asset.status` | "active" \| "trash" | yes | trash = in the trash: not shown on the site, restorable with media.restore. |
| `asset.missing` | boolean | yes | true when the original no longer exists; places that use it show nothing (excluded from media.list by default unless includeMissing=true). |
| `asset.version` | integer | yes | Increases each time media.replace uploads a new version. |
| `asset.url` | string \| null | yes | Display URL for showing the file to the user. It changes with each version, so never store it; null for protected files. |
| `asset.thumbnailUrl` | string \| null | yes | Thumbnail or poster URL for public files; null for protected files (their thumbnails are only served to signed-in members). |
| `asset.fileName` | string \| null | yes | — |
| `asset.mimeType` | string | yes | — |
| `asset.bytes` | integer \| null | yes | null while the size of an older file is still being calculated. |
| `asset.billableBytes` | integer | yes | Bytes counted toward the site's storage quota (images have a 256 KB minimum; active derived files such as watermarked versions are included). |
| `asset.width` | integer \| null | yes | — |
| `asset.height` | integer \| null | yes | — |
| `asset.durationSeconds` | number \| null | yes | — |
| `asset.pageCount` | integer \| null | yes | — |
| `asset.source` | string | yes | Where it was uploaded (page module, blog, product, media library…). |
| `asset.inUse` | boolean | yes | Used by site content, products, deliverables, emails or a published snapshot. |
| `asset.focus` | object \| null | yes | Default focal point of an image (0-1 on the original): image fields without their own framing crop around it. null when unset or centered. |
| `asset.focus.x` | number | yes | — |
| `asset.focus.y` | number | yes | — |
| `asset.edgeColor` | string \| null | yes | Solid border color (#rrggbb) when all four edges of the image share a uniform opaque color (for example a white-background product photo). null for transparent or multi-colored images. |
| `asset.altText` | string \| null | yes | Default alt text, used when the place that shows the image has no alt of its own. |
| `asset.watermarkExempt` | boolean | yes | true when the owner excluded this image from the site watermark. |
| `asset.createdAt` | string | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `ASSET_NOT_FOUND` | 404 | also for files in the trash and older versions |
| `IMAGES_ONLY` | 422 | focus or altText on a non-image |
| `TAGS_INVALID` | 422 | — |
| `VISIBILITY_BLOCKED` | 422 | details.reason: in_use \| deliverable \| trash \| missing \| processing \| unsupported_kind \| older_version; details.locations for in_use |
| `VALIDATION` | 400 | — |

```bash
curl -X PATCH "https://app.kanorio.com/api/v1/sites/{id}/media/{assetId}" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"focus":{"x":0.32,"y":0.4},"altText":"Fresh croissants on the bakery counter"}'
```

```json
{
  "asset": {
    "assetId": "asset_example01",
    "kind": "image",
    "visibility": "public",
    "tags": [],
    "processing": null,
    "status": "active",
    "missing": false,
    "version": 1,
    "url": "https://media.kanorio.com/example/sunny-bakery-hero.jpg",
    "thumbnailUrl": null,
    "fileName": "sunny-bakery-hero.jpg",
    "mimeType": "image/jpeg",
    "bytes": 482133,
    "streaming": "none",
    "billableBytes": 482133,
    "width": 2400,
    "height": 1600,
    "durationSeconds": null,
    "pageCount": null,
    "source": "IMAGE_CONTENT",
    "inUse": true,
    "focus": {
      "x": 0.32,
      "y": 0.4
    },
    "edgeColor": null,
    "altText": "Fresh croissants on the bakery counter",
    "watermarkExempt": false,
    "createdAt": "2026-10-01T02:30:00.000Z"
  }
}
```

### Media file purge

`DELETE /sites/{id}/media/{assetId}`

- Stability: beta
- Permission: `media:manage`
- Rate limit: default (120/minute per token)
- MCP tool: `media_purge`

Permanently delete a file that is already in the trash (call media.trash first) and free its storage. This cannot be undone. If the site still uses the file, the call fails with ASSET_IN_USE and details.usages: tell the user those places will stay empty, and only retry with confirm: true after they agree. Errors: FORBIDDEN, NOT_FOUND, NOT_IN_TRASH, PRODUCT_FILE_PROTECTED, ASSET_IN_USE, EXPORT_HOLD (details.until: an export keeps the file until then), DELETE_FAILED.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `assetId` | path | string | yes | Asset id from media.list. |
| `confirm` | query | boolean \| "true" \| "false" | no | Set true (REST: ?confirm=true) only after the user agreed that the places in ASSET_IN_USE details.usages will stay empty. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `assetId` | string | yes | — |
| `deleted` | true | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `NOT_FOUND` | 404 | — |
| `NOT_IN_TRASH` | 409 | — |
| `PRODUCT_FILE_PROTECTED` | 409 | — |
| `ASSET_IN_USE` | 422 | — |
| `EXPORT_HOLD` | 409 | details.until: an export keeps the file until then |
| `DELETE_FAILED` | 502 | — |

```bash
curl -X DELETE "https://app.kanorio.com/api/v1/sites/{id}/media/{assetId}" \
  -H "Authorization: Bearer $KANORIO_TOKEN"
```

```json
{
  "assetId": "asset_example01",
  "deleted": true
}
```

### Replace a media file

`POST /sites/{id}/media/{assetId}/replace`

- Stability: beta
- Permission: `media:manage`
- Rate limit: default (120/minute per token)
- MCP tool: `media_replace`

Upload a new version of an image or file (exactly one of base64 or url). Every place that uses it switches to the new version and keeps the same assetId, so no content needs to be edited: the editor, brand logo, share images, products, blog posts and deliverables right away; published pages after the next site.publish (awaitingPublish). When the aspect ratio changes, crops are re-centered per layout (aspectRatioChanged): tell the user to check the pages listed in usages. Tell the user before replacing a file that many places use. Videos, audio, PDFs and documents need fileName and can only become the same kind (video to video, PDF to PDF). Replacing a protected file changes what buyers of the deliverable get right away. Files over 40 MB (base64) or 100 MB (url) are replaced in the dashboard media library. The older version stays until no published page uses it, then moves to the trash. Errors: FORBIDDEN, ASSET_NOT_FOUND (also for files in the trash), KIND_MISMATCH, REPLACE_NOT_AVAILABLE, VALIDATION, INVALID_IMAGE_DATA, INVALID_FILE_DATA, UNSUPPORTED_IMAGE_TYPE, UNSUPPORTED_FILE_TYPE, FILE_TOO_LARGE, FETCH_FAILED, STORAGE_LIMIT_EXCEEDED, SERVICE_UNAVAILABLE, UPLOAD_FAILED.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `assetId` | path | string | yes | Asset id from media.list. |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `base64` | string | no | File bytes as base64 (plain or data: URL), max 40 MB decoded. Images: JPEG, PNG, WebP, GIF, SVG or HEIC, stored as-is (only downscaled when over Cloudflare limits). Other files need fileName. |
| `url` | string | no | Public https URL to copy from: images up to 40 MB, other files up to 100 MB (use media.request_upload for larger files). Private networks and redirects to them are rejected. |
| `fileName` | string | no | Name of the new version with extension (required when replacing a video, audio, PDF, document or protected file; must be the same kind as the current file). |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `asset` | object | yes | The file after the replacement (version increased). |
| `asset.assetId` | string | yes | Stable id of the file. Reference files by this id; it stays the same when media.replace uploads a new version. |
| `asset.kind` | "image" \| "video" \| "audio" \| "pdf" \| "document" | yes | What the file is (image, video, audio, pdf, document). Whether it is protected is a separate property: visibility. |
| `asset.visibility` | "public" \| "protected" | yes | public: has a public URL and can be placed in pages, posts and collections. protected: no public URL; only served to entitled buyers through signed short-lived links (reference it by assetId in deliverable_units.*). |
| `asset.tags` | string[] | yes | Owner-defined tags (up to 20 per file, 1-40 characters each); filter media.list with tags. |
| `asset.processing` | object \| null | yes | Background processing of the file itself (thumbnails, visibility moves, transcoding): null when the file is ready to use. Subscribe to the media.asset.ready and media.asset.failed webhooks instead of polling. |
| `asset.processing.status` | "pending" \| "processing" \| "failed" | yes | — |
| `asset.processing.kind` | string \| null | yes | Background job kind (for example DOCUMENT_THUMBNAIL). |
| `asset.processing.error` | string \| null | yes | Error code when status is failed. |
| `asset.processing.updatedAt` | string \| null | yes | — |
| `asset.streaming` | "none" \| "processing" \| "partial" \| "ready" | yes | Adaptive-streaming (HLS) version of a video: none (not placed anywhere that plays it, or not a video), processing (queued or transcoding), partial (lowest quality playable, higher qualities still transcoding), ready (all qualities done). Videos are transcoded when first placed in a page, post, collection or deliverable unit, or with media.update { streaming: true }; the streaming version counts toward storage (billableBytes). |
| `asset.status` | "active" \| "trash" | yes | trash = in the trash: not shown on the site, restorable with media.restore. |
| `asset.missing` | boolean | yes | true when the original no longer exists; places that use it show nothing (excluded from media.list by default unless includeMissing=true). |
| `asset.version` | integer | yes | Increases each time media.replace uploads a new version. |
| `asset.url` | string \| null | yes | Display URL for showing the file to the user. It changes with each version, so never store it; null for protected files. |
| `asset.thumbnailUrl` | string \| null | yes | Thumbnail or poster URL for public files; null for protected files (their thumbnails are only served to signed-in members). |
| `asset.fileName` | string \| null | yes | — |
| `asset.mimeType` | string | yes | — |
| `asset.bytes` | integer \| null | yes | null while the size of an older file is still being calculated. |
| `asset.billableBytes` | integer | yes | Bytes counted toward the site's storage quota (images have a 256 KB minimum; active derived files such as watermarked versions are included). |
| `asset.width` | integer \| null | yes | — |
| `asset.height` | integer \| null | yes | — |
| `asset.durationSeconds` | number \| null | yes | — |
| `asset.pageCount` | integer \| null | yes | — |
| `asset.source` | string | yes | Where it was uploaded (page module, blog, product, media library…). |
| `asset.inUse` | boolean | yes | Used by site content, products, deliverables, emails or a published snapshot. |
| `asset.focus` | object \| null | yes | Default focal point of an image (0-1 on the original): image fields without their own framing crop around it. null when unset or centered. |
| `asset.focus.x` | number | yes | — |
| `asset.focus.y` | number | yes | — |
| `asset.edgeColor` | string \| null | yes | Solid border color (#rrggbb) when all four edges of the image share a uniform opaque color (for example a white-background product photo). null for transparent or multi-colored images. |
| `asset.altText` | string \| null | yes | Default alt text, used when the place that shows the image has no alt of its own. |
| `asset.watermarkExempt` | boolean | yes | true when the owner excluded this image from the site watermark. |
| `asset.createdAt` | string | yes | — |
| `usages` | object[] | yes | Places that now show the new version. |
| `usages[].kind` | string | yes | pageSection, pageShare, siteContent, brand, favicon, watermark, post, blogTaxonomy, product, productContent, deliverable, productCategory, collectionItem, floatingWidgets, messageTemplate or campaign. |
| `usages[].label` | string | yes | Where it is used, in words you can repeat to the user (page › section, post title, product name…). |
| `awaitingPublish` | boolean | yes | Published pages still show the older version until the site is published again. |
| `aspectRatioChanged` | boolean | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `ASSET_NOT_FOUND` | 404 | also for files in the trash |
| `KIND_MISMATCH` | 422 | — |
| `REPLACE_NOT_AVAILABLE` | 422 | — |
| `VALIDATION` | 400 | — |
| `INVALID_IMAGE_DATA` | 400 | — |
| `INVALID_FILE_DATA` | 400 | — |
| `UNSUPPORTED_IMAGE_TYPE` | 422 | — |
| `UNSUPPORTED_FILE_TYPE` | 422 | — |
| `FILE_TOO_LARGE` | 413 | — |
| `FETCH_FAILED` | 422 | — |
| `STORAGE_LIMIT_EXCEEDED` | 403 | — |
| `SERVICE_UNAVAILABLE` | 503 | — |
| `UPLOAD_FAILED` | 502 | — |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/media/{assetId}/replace" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/photos/hero-2026.jpg"}'
```

```json
{
  "asset": {
    "assetId": "asset_example01",
    "kind": "image",
    "visibility": "public",
    "tags": [],
    "processing": null,
    "status": "active",
    "missing": false,
    "version": 2,
    "url": "https://media.kanorio.com/example/sunny-bakery-hero.jpg",
    "thumbnailUrl": null,
    "fileName": "sunny-bakery-hero.jpg",
    "mimeType": "image/jpeg",
    "bytes": 482133,
    "streaming": "none",
    "billableBytes": 482133,
    "width": 2400,
    "height": 1600,
    "durationSeconds": null,
    "pageCount": null,
    "source": "IMAGE_CONTENT",
    "inUse": true,
    "focus": null,
    "edgeColor": null,
    "altText": null,
    "watermarkExempt": false,
    "createdAt": "2026-10-01T02:30:00.000Z"
  },
  "usages": [
    {
      "kind": "pageSection",
      "label": "Home › Our story"
    }
  ],
  "awaitingPublish": true,
  "aspectRatioChanged": false
}
```

### Media file trash

`POST /sites/{id}/media/{assetId}/trash`

- Stability: beta
- Permission: `media:manage`
- Rate limit: default (120/minute per token)
- MCP tool: `media_trash`

Move a file to the trash. It stops showing on the site right away (places that use it show nothing), still counts toward storage, and is deleted permanently after 30 days unless restored with media.restore. The response lists where it was used so you can tell the user what will look empty. Protected files that a deliverable uses cannot be trashed; remove them from the deliverable first (unused protected files can be trashed). Errors: FORBIDDEN, NOT_FOUND, PRODUCT_FILE_PROTECTED.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `assetId` | path | string | yes | Asset id from media.list. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `assetId` | string | yes | — |
| `usages` | object[] | yes | Places on the site that now show nothing; empty when the file was unused. |
| `usages[].kind` | string | yes | pageSection, pageShare, siteContent, brand, favicon, watermark, post, blogTaxonomy, product, productContent, deliverable, productCategory, collectionItem, floatingWidgets, messageTemplate or campaign. |
| `usages[].label` | string | yes | Where it is used, in words you can repeat to the user (page › section, post title, product name…). |
| `deleteAfter` | string | yes | ISO time after which the file is deleted permanently. |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `NOT_FOUND` | 404 | — |
| `PRODUCT_FILE_PROTECTED` | 409 | — |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/media/{assetId}/trash" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

```json
{
  "assetId": "asset_example01",
  "usages": [
    {
      "kind": "pageSection",
      "label": "Home › Our story"
    }
  ],
  "deleteAfter": "2026-11-05T08:00:00.000Z"
}
```

### Restore a media file

`POST /sites/{id}/media/{assetId}/restore`

- Stability: beta
- Permission: `media:manage`
- Rate limit: default (120/minute per token)
- MCP tool: `media_restore`

Restore a file from the trash; it shows on the site again right away. An older version left by replacing a file comes back as a separate file with a new assetId (returned); old content keeps showing the current version. Errors: FORBIDDEN, NOT_FOUND, PRODUCT_FILE_PROTECTED, RESTORE_FAILED.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `assetId` | path | string | yes | Asset id from media.list. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `assetId` | string | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `NOT_FOUND` | 404 | — |
| `PRODUCT_FILE_PROTECTED` | 409 | — |
| `RESTORE_FAILED` | 422 | — |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/media/{assetId}/restore" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

```json
{
  "assetId": "asset_example01"
}
```
