# Media

Images and videos for posts, uploaded in two steps.

Upload in three steps, straight to storage, so a large video never passes
through the API:

**1. `POST /v1/media`** — say what you are about to upload.

```json
{ "mime_type": "image/jpeg", "filename": "catalogue.jpg", "bytes": 482113 }
```

`mime_type` is one of `image/jpeg`, `image/png`, `image/webp`, `image/gif`,
`video/mp4`, `video/quicktime`. Images up to 8 MB, video up to 512 MB.

**`201`**:

```json
{
  "media": { "id": "e71b0f3d-2c85-4a9e-b604-93d2f7a1c5e8", "mime_type": "image/jpeg", "bytes": null, "url": null, "status": "pending", "created_at": "…" },
  "upload": {
    "signed_url": "https://…/storage/v1/object/upload/sign/social-media/e71b0f3d-…/catalogue.jpg?token=…",
    "token": "…",
    "path": "e71b0f3d-2c85-4a9e-b604-93d2f7a1c5e8/catalogue.jpg"
  }
}
```

**2. Upload the file** with `PUT` to `upload.signed_url`, the raw bytes as the
body and the same `Content-Type`. No API key on this request — the URL is the
credential, and it expires after two hours.

```bash
curl -sS -X PUT "$SIGNED_URL" -H "Content-Type: image/jpeg" --data-binary @catalogue.jpg
```

**3. `POST /v1/media/{id}/complete`** — no body. We check the file that
arrived is the type and size you declared, and the media becomes `ready`.
**`200`** with the media object; **`409`** if the upload has not finished;
**`400`** if the file does not match, in which case it is deleted and you start
again.

Only `ready` media can go on a post. An upload never completed is deleted after
a day, and ready media that no draft or scheduled post uses is deleted after 30
days.

## Operations

### `POST /v1/media`

**Start a media upload**

Scope `social:write`. Accepts `Idempotency-Key`.

Reserves a media item and returns a signed URL to upload the file to, then call
`POST /media/{id}/complete`. The organisation's media storage allowance applies
(`402 PLAN_LIMIT`). A replay with the same `Idempotency-Key` answers `upload: null`: the
upload credential is never kept, so ask for a new slot if you still need one.

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `mime_type` | `string` | (**required**; one of `image/jpeg`, `image/png`, `image/webp`, `image/gif`, `video/mp4`, `video/quicktime`) |
| `filename` | `string` | (**required**; min 1; max 255) |
| `bytes` | `integer` | (**required**; >= 1; <= 536870912) |

```json
{
  "mime_type": "image/jpeg",
  "filename": "<filename>",
  "bytes": 1
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 201 | `MediaUpload` | Reserved. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 402 | `ErrorEnvelope` | `PLAN_LIMIT`: the organisation's plan does not allow more of this. The message names the allowance. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 422 | `ErrorEnvelope` | `VALIDATION_ERROR`: the request is well formed but cannot be done as asked, such as a network that does not support it. Or `UPSTREAM_ERROR`: the network refused it. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/media/{id}/complete`

**Finish a media upload**

Scope `social:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Checks the uploaded file and makes the media item ready for posts. Before the upload finishes it is `409 CONFLICT`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Media` | The media item. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 422 | `ErrorEnvelope` | `VALIDATION_ERROR`: the request is well formed but cannot be done as asked, such as a network that does not support it. Or `UPSTREAM_ERROR`: the network refused it. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

## Shapes

The objects these operations send and answer.

### Media

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `mime_type` | `string` | (**required**) |
| `bytes` | `integer \| null` | (**required**) |
| `url` | `string \| null` | (**required**) |
| `status` | `string` | (**required**; one of `pending`, `ready`) |
| `created_at` | `date-time` | (**required**) |

### MediaCreate

| Field | Type |  |
|---|---|---|
| `mime_type` | `string` | (**required**; one of `image/jpeg`, `image/png`, `image/webp`, `image/gif`, `video/mp4`, `video/quicktime`) |
| `filename` | `string` | (**required**; min 1; max 255) |
| `bytes` | `integer` | (**required**; >= 1; <= 536870912) |

### MediaUpload

| Field | Type |  |
|---|---|---|
| `media` | `Media` | (**required**) |
| `upload` | `object \| null` | Where to upload the file, once. Null on an idempotent replay. (**required**) |

<!-- https://boltclaw.ai/docs/api/media as markdown. Generated from openapi.yaml; API version 1.22.0. -->
<!-- Every /docs page answers as markdown: add .md to its path, or send Accept: text/markdown. -->
