---
title: Media
description: Upload, list, search, update, and delete images, video, audio and PDFs in your Schedule & Chill media library.
published: 2026-06-07
updated: 2026-09-14
---

# Media

Upload a file once, then attach it to any number of posts via `media_ids`.

The library also works as a searchable asset store: `alt_text` and `tags` are searched as well
as the filename, so a clip described as "cable crossover at the gym" is found by "gym" even
though its filename is `PXL_20260806_113344.mp4`. Write a real `alt_text` at upload time — an
item with no description is only findable by someone who already knows its filename.

Audio (`mp3`, `wav`, `m4a`, `aac`) is accepted as a production input — music beds, voice takes —
and can never be attached to a post.

## The media object

| Field              | Type          | Description                              |
| ------------------ | ------------- | ---------------------------------------- |
| `id`               | integer       | Unique id. Pass to a post's `media_ids`. |
| `original_name`    | string        | The uploaded filename.                   |
| `mime_type`        | string        | e.g. `image/jpeg`, `video/mp4`.          |
| `size_bytes`       | integer       | File size.                               |
| `cdn_url`          | string        | Public URL of the file.                  |
| `width` / `height` | integer\|null | Pixel dimensions for images.             |
| `duration`         | number\|null  | Seconds, for video and audio. `null` means it was never measured, not zero. |
| `sha256`           | string\|null  | Checksum of the stored bytes. Use it to verify a local copy without re-downloading. |
| `folder`           | string\|null  | Optional organizational folder.          |
| `tags`             | string[]      | Optional tags.                           |
| `alt_text`         | string\|null  | Accessibility text.                      |

---

## Upload media

```
POST /api/media
```

Send as `multipart/form-data`.

| Field      | Type     | Required | Notes                                                          |
| ---------- | -------- | -------- | -------------------------------------------------------------- |
| `file`     | file     | yes      | `jpg`, `jpeg`, `png`, `gif`, `webp`, `mp4`, `mov`, `webm`, `m4v`, `mp3`, `wav`, `m4a`, `aac`, `pdf`. Max 90 MB — use the resumable flow above that. |
| `folder`   | string   | no       | Organizational folder.                                         |
| `tags`     | string[] | no       | Each tag ≤ 50 characters.                                      |
| `alt_text` | string   | no       | ≤ 500 characters.                                              |

```bash
curl -X POST https://schedulenchill.com/api/media \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Accept: application/json" \
  -F "file=@./launch.png" \
  -F "folder=product-launch" \
  -F "tags[]=launch" \
  -F "alt_text=Product launch screenshot"
```

Returns `201 Created` with the media object. Use its `id` in a post's `media_ids`.

> Building over MCP or from a server with a public file URL? Use the `upload_media` tool, which ingests media by URL instead of multipart upload.

---

## Upload a video from your own computer

The single-request upload above is capped, and a large video in one request is fragile —
one dropped connection and you start over. For anything big, open a **resumable session**
and stream the file in chunks. Nothing is capped by request size, and an interrupted
upload picks up where it stopped.

This is what your AI tool does when you say *"upload `~/Videos/demo.mp4` and schedule it"* —
the MCP `upload_media` tool with `source="local"` returns exactly the envelope below.

### 1. Open a session

```
POST /api/media/uploads
```

| Field        | Type    | Required | Notes                                                |
| ------------ | ------- | -------- | ---------------------------------------------------- |
| `filename`   | string  | yes      | Original name, with extension.                       |
| `mime_type`  | string  | yes      | e.g. `video/mp4`.                                    |
| `size_bytes` | integer | yes      | Exact size. Finalize rejects a mismatch.             |
| `sha256`     | string  | no       | Hex digest. When given, finalize verifies against it. |
| `folder`     | string  | no       | Carried onto the finished library item.               |
| `tags`       | string[]| no       | Carried onto the finished library item.               |
| `alt_text`   | string  | no       | Carried onto the finished library item. Set it here — a large upload that lands undescribed almost never gets one added later. |

A free account may upload up to **400 MB per file**; the absolute cap is 1 GB. Going over fails at session-open with a 422 that names the limit, so nothing is streamed for nothing. `GET /api/capabilities` reports *your* ceiling in `limits.max_upload_bytes` — read it rather than assuming.

```bash
curl -X POST https://schedulenchill.com/api/media/uploads \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"filename":"demo.mp4","mime_type":"video/mp4","size_bytes":11402,
       "sha256":"86e39c5a..."}'
```

```json
{
  "upload_id": "779b36fd-6d13-4c42-b3e5-c731956b093c",
  "chunk_size": 8388608,
  "received_bytes": 0,
  "size_bytes": 11402,
  "expires_at": "2026-08-04T07:35:44+00:00"
}
```

### 2. Stream the chunks

`PATCH` each `chunk_size`-byte slice as a **raw** body — not multipart — with `offset` set
to the running byte count (`0`, `chunk_size`, `2 × chunk_size`, …).

```bash
curl -X PATCH "https://schedulenchill.com/api/media/uploads/UPLOAD_ID?offset=0" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @./chunk-0
```

```json
{ "received_bytes": 11402, "size_bytes": 11402, "completed": true }
```

**Send chunks one at a time, in order.** Parallel `PATCH`es corrupt the assembled file.
If a chunk fails, re-`GET` the session to read `received_bytes` and resume from there —
that number is the source of truth, not your own bookkeeping.

### 3. Finalize

```bash
curl -X POST https://schedulenchill.com/api/media/uploads/UPLOAD_ID/complete \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Accept: application/json"
```

Returns the media object. Its `id` goes in a post's `media_ids`.

```json
{ "id": 12, "original_name": "demo.mp4", "mime_type": "video/mp4", "size_bytes": 11402 }
```

### Notes

- Use a token with `*` abilities (the default). A restricted token 401s on the chunk `PATCH`es.
- `/mcp` and `/api/*` share one auth guard, so the same bearer works for both.
- Sessions expire — `expires_at` is in the envelope. Abandoned ones are pruned.
- `DELETE /api/media/uploads/{id}` cancels a session you no longer want.

---

## List media

```
GET /api/media
```

Paginated, 15 per page. Optional filters:

| Query param | Description         |
| ----------- | ------------------- |
| `search`    | Match filename, alt text or tags. |
| `type`      | `image` or `video`. |
| `folder`    | Filter by folder.   |
| `tags`      | Filter by tag.      |
| `per_page`  | Rows per page, 1–200. Over 200 returns 422.
| `page`      | Page number.        |

---

## Retrieve media

```
GET /api/media/{id}
```

---

## Update media

```
PUT /api/media/{id}
```

Update metadata only — `folder`, `tags`, `alt_text`. The file itself is immutable; upload a new file to replace it.

---

## Delete media

```
DELETE /api/media/{id}
```

Returns `204 No Content`.
