Skip to content

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.

  • A conversation exists and the viewer has a session token. See 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.
Terminal window
export MC=https://app.mondochat.io/v1
export SESSION=<session token>
export ID=<conversation id>
  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.

    Terminal window
    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.

    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.

    Terminal window
    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.
    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.

  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.
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.
  • 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.