# Media

Photos, videos and voice notes share one upload flow: ask for an upload, send the file to storage, then complete it. Completing creates the message.

## Before you start

- A conversation exists and the viewer has a session token. See [Conversations](/guides/conversations/).
- The `policy` row from the sender to the peer has `photos` on, and `chat.photos` is on. Videos need `chat.videos` and voice notes need `chat.voice`.
- Your `limits` bound `photos_per_day` (photos and videos together) and `voice_per_day`.
- A site that wants a consent step sets `photo_consent_required` on the policy row toward the receiver.

```sh
export MC=https://app.mondochat.io/v1
export SESSION=<session token>
export ID=<conversation id>
```

## Steps

1. Ask for an upload with `POST /v1/photos/uploads`. `content_type` is one of `image/jpeg`, `image/png`, `image/webp`, `image/heic`, `video/mp4` or `audio/mp4`. `bytes` is the file size.

   ```sh
   curl -X POST $MC/photos/uploads -H "Authorization: Bearer $SESSION" \
     -H 'content-type: application/json' \
     -d "{\"conversation_id\": \"$ID\", \"content_type\": \"image/jpeg\", \"bytes\": 482113}"
   ```

   The answer is `{photo_id, url, fields, expires_at}`: a presigned POST valid for 5 minutes. It pins the exact key, the content type you declared, and a size from 1 byte to `chat.photo_max_bytes` (`chat.video_max_bytes` for video, `chat.voice_max_bytes` for voice). The row is `pending` until you complete it.

2. Send the file to storage with a multipart POST. Put every entry of `fields` first, then the file as the last field, `file`.

   ```js
   const form = new FormData()
   for (const [k, v] of Object.entries(upload.fields)) form.append(k, v)
   form.append('file', file)
   await fetch(upload.url, { method: 'POST', body: form })
   ```

3. Complete it with `POST /v1/photos/{id}/complete`. The body is optional for a photo.

   ```sh
   curl -X POST $MC/photos/$PHOTO_ID/complete -H "Authorization: Bearer $SESSION" \
     -H 'content-type: application/json' -d '{"caption": "Sunset", "once": false}'
   ```

   | Field | Meaning |
   |---|---|
   | `caption` | Trimmed, at most `chat.text_max_length`, else 422. Shown under the media to whoever may see it, kept out of previews and webhook `text`. |
   | `once` | Makes it view once. 403 `policy_denied` with reason `view_once` when `chat.view_once` is off. |
   | `reply_to_id`, `forwarded` | As on a text message; see [Messages](/guides/messages/). |
   | `video` | Required for a video: `{frames, width, height, duration_ms}`. |
   | `voice` | Required for a voice note: `{duration_ms, wave}`. |

   A photo must decode as JPEG, PNG, WebP or HEIC. Anything else answers 422 and the object is deleted. The server strips metadata, writes the renditions and stores the display size in `width` and `height` (the long edge is at most 2048 px). The answer is the `Photo` `{id, kind, status, width, height, thumb_url, full_url, uploader_id}`.

4. For a video, record it in the sender's browser first. The rules:

   - The file is an H.264 and AAC MP4. The server reads the first 16 bytes, requires an MP4 and never decodes or transcodes it.
   - `video.frames` is 1 to 20 base64 JPEG frames of at most 200 KB each. The first is the poster. They are what moderation looks at.
   - `video.width` and `video.height` are 1 to 16384. `duration_ms` is at most `chat.video_max_seconds` (900 by default) plus one second.
   - The upload is at most `chat.video_max_bytes` (104857600 by default). Without `chat.videos`, the request answers 403 `policy_denied` with reason `videos`.
   - A missing or invalid `video` answers 422 and leaves the upload pending, so you can retry. A file that is not an MP4 answers 422 and is deleted.

5. For a voice note, record `audio/mp4`. The rules:

   - `voice.duration_ms` is at most `chat.voice_max_seconds` (900 by default) plus one second. `voice.wave` is 40 characters of `0-9a-v`, the level bars.
   - The upload is at most `chat.voice_max_bytes` (10485760 by default). Without `chat.voice`, the request answers 403 `policy_denied` with reason `voice`.
   - The message `text` is the preview `🎤 m:ss` and it has no caption.
   - A missing or invalid `voice` answers 422 and leaves the upload pending. A file that is not an MP4 answers 422 and is deleted.

6. Handle photo consent. When `photo_consent_required` applies to the receiver and they have not answered, the message is `held`: the sender sees it and the receiver sees a placeholder. The receiver answers with `PUT /v1/conversations/{id}/photo-consent` and `{"consent": "yes"}` or `{"consent": "no"}`.

   - `yes` releases the held items and publishes `message.updated` and `photos.updated`.
   - `no` rejects the held items and deletes them from storage. Later uploads toward that receiver are refused with 403 `policy_denied` and reason `photos_off`.
   - The receiver can change the answer at any time. Items held for moderation are not released by consent.

7. Read the media. `thumb_url` and `full_url` are signed links. A client that finds a link expired fetches the gallery again for fresh ones.

   | Link | Lifetime |
   |---|---|
   | Photo `thumb_url` and `full_url`, video `thumb_url` | They end at the close of the 5-minute window after the current one, so a link lives 5 to 10 minutes. Every link issued in one window is identical. |
   | Video `full_url` | 1 hour, so a video plays to the end. |
   | Open endpoint, photo | 120 seconds. |
   | Open endpoint, video or voice note | 1 hour. |

   List a conversation's photos and videos with `GET /v1/conversations/{id}/photos?cursor=...`. It answers `{items, next_cursor}` newest first. View-once media and voice notes are never listed.

8. Open view-once media and voice notes with `POST /v1/conversations/{id}/messages/{message_id}/open`. The answer is `{url, caption, expires_at}`.

   - A view-once message carries no media URL and no caption, in lists, events or the gallery, for anyone. Only its recipient may open it, while it is `sent` and not redacted.
   - The first open stamps `opened_at` and publishes `message.updated`. An open in the next 60 seconds returns the media again, so a failed load can retry. Later opens answer 410 `gone`. The sender gets 404.
   - A voice note has no URL in lists or events for anyone but moderators. Its recipient gets the audio from the open endpoint, and the first open marks it played with `opened_at`. The sender of a voice note that is not view once may open it too, which changes nothing.
   - While either user has `read_receipts` off, the sender sees no `opened_at` on their own view-once messages. A plain voice note's played state stays visible to its sender.

9. Let a user upload an avatar. It needs `chat.avatar_upload`, which is off by default; without it the request answers 403 `policy_denied` with reason `avatar_upload`. Call `POST /v1/me/avatar` with `{content_type, bytes}`, upload as in step 2, then `POST /v1/me/avatar/{id}/complete`. The avatar stays `held` until photo moderation approves it, and then it wins over the `avatar` your server sends.

## Check it works

1. Upload a small JPEG through steps 1 to 3. The answer has `status` `ready` and a `thumb_url` that loads.
2. Send a photo with `"once": true` as one user. List the conversation as the peer: the message has no URL and no caption. Open it as the peer and you get a URL and the caption. Open it as the sender and you get 404.
3. Open it as the peer again after more than 60 seconds. The answer is 410 `gone`.

## When it fails

| Answer | Cause |
|---|---|
| 403 `policy_denied` | The reason says which rule: `photos`, `videos`, `voice`, `photos_off` (the peer refused photos), `view_once`, `avatar_upload`, `forward`, or `inbound` (a guest toward a peer who takes messages from members only). |
| 403 `limit_reached` | `photos_per_day` or `voice_per_day` is reached. |
| 403 `forbidden` with reason `undelivered` | The peer blocked the sender and `chat.refuse_blocked_send` is on. |
| 422 | The file is too big, is not the declared format, or `video` or `voice` is missing or invalid. |
| 404 | Opening a view-once item as its sender, or opening a held item as its receiver. |
| 410 `gone` | A view-once item was opened more than 60 seconds ago. |

## Limits

- An upload ticket lasts 5 minutes. A `pending` upload that is never completed is deleted after 24 hours.
- Photos and videos count together against `photos_per_day`. Voice notes count against `voice_per_day`.
- Video moderation reads the frames the sender's browser sends, and voice moderation reads a transcript of the note.
- A view-once link that was already handed out works until it expires. Nothing stops a screenshot.
- Moderators always see the media and the caption of a view-once message.
- Browsers that cannot encode H.264 and AAC are not offered video.

## Related topics

- [Request an upload](/api/operations/request-upload/), [complete an upload](/api/operations/complete-upload/), [open a message](/api/operations/open-message/), [list photos](/api/operations/list-photos/), [set photo consent](/api/operations/set-photo-consent/)
- [Messages](/guides/messages/) and [Moderation](/guides/moderation/)
- [Settings reference](/reference/settings/) for `chat`, `limits` and `policy`
- [Errors](/errors/)