Skip to main content
Version: 1.0

Mobile Chat Media — Integration Guide

Attach images, video, GIFs, files, and voice notes to chat messages. Companion to mobile-chat-guide.md — read that first for send/receive, sockets, receipts, and the message object. This doc only adds the media bits.

  • API base URL (local): http://localhost:3001
  • Auth: Authorization: Bearer <accessToken> on every endpoint (required)
  • Headers: Content-Type: application/json, X-Client-Type: mobile
  • Storage upload uses the same presign → PUT → reference flow as posts.

The flow — one message, three steps

Media does not upload through the API. The client uploads bytes straight to storage, then sends the message referencing the uploaded object. Media rides the same send call as text (one idempotent request — retry-safe on flaky networks). No separate "attach" round-trip.

1. POST /api/media/presign → get a signed PUT URL + storageKey
2. PUT bytes → uploadUrl → upload straight to storage (not via API)
3. POST /api/chat/conversations/:id/messages { media: [{ storageKey, ... }] }

Step 1 — presign

POST /api/media/presign
{ "files": [ { "filename": "voice.webm", "contentType": "audio/webm" } ] }
{
"success": true,
"data": [
{
"uploadUrl": "https://.../signed-put-url", // PUT bytes here
"publicUrl": "https://.../media/<you>-...", // ignore — server resolves URLs
"key": "media/<yourUserId>-1719-uuid.webm", // ← this is your storageKey
"contentType": "audio/webm"
}
]
}
  • Max 4 files per presign call.
  • filename's extension decides the object's extension — pass a real one (.webm, .jpg, .mp4, .pdf).

Step 2 — PUT the bytes

PUT <uploadUrl>
Content-Type: audio/webm ← must match the presign contentType
<raw bytes>

Direct to storage. The API never sees the bytes. Show upload progress off this PUT.

Step 3 — send the message

POST /api/chat/conversations/:id/messages
{
"clientMessageId": "<uuid>", // idempotency — see chat guide §5.2
"content": "check this out", // OPTIONAL when media present
"media": [
{
"storageKey": "media/<yourUserId>-1719-uuid.webm", // the `key` from step 1
"kind": "audio", // image | video | audio | file | gif
"mimeType": "audio/webm",
"duration": 7 // seconds (audio/video)
}
]
}

Response data is the normal message object plus media[] (see below). The same object is pushed over the socket as new_message — media included.


Send contract — the media field

Each item:

fieldrequirednotes
storageKeyThe key returned by presign. Not a URL. Server validates you own it.
kindimage | video | audio | file | gif
mimeTyperecommendede.g. audio/webm, image/jpeg. Required for correct audio playback.
width, heightimage/videopixels — lets the UI reserve space before load
durationaudio/videoseconds
sizeoptionalbytes
altTextoptional≤ 200 chars; also used as file display name

Rules:

  • content is optional — but a message must have text OR at least one attachment. Empty + no media → 400.
  • Max 4 attachments per message.
  • OwnershipstorageKey must be a key you presigned (they're prefixed with your user id). Sending someone else's key or an arbitrary path → 403.
  • Metadata (width/height/duration/size) is treated as a hint — send accurate values; the UI is the only consumer today.

Message object — media[] (read + socket)

GET /conversations/:id/messages and the new_message socket event carry media on every message:

{
"id": "msg_xxx",
"content": "", // "" for a media-only message
"type": "MEDIA", // MESSAGE-level: TEXT | MEDIA | SYSTEM | CALL
"senderId": "...",
"media": [
{
"id": "med_xxx", // stable handle — key your UI by this
"type": "audio", // ATTACHMENT kind (image|video|audio|file|gif)
"url": "https://...", // ready to render — see "URLs" below
"mimeType": "audio/webm",
"width": null,
"height": null,
"duration": 7,
"altText": null,
"variants": [] // image/video thumbnails once processed (see below)
}
],
"status": { /* delivery/read — chat guide §5.5 */ }
}

Two different type fields — don't confuse them

  • message.type = message category: TEXT, MEDIA, SYSTEM, CALL. MEDIA = attachments only, no text. Text present → TEXT even with attachments. Never infer the attachment kind from this.
  • media[i].type = the attachment kind (image/audio/…). Note the send field is called kind but the read field is type — same values.

To know "does this message have attachments," check media.length > 0 — not message.type.

Thumbnails / variants (image + video)

Image and video attachments get resized variants generated async after send. On first render variants may be [] and url is the original — fine to show. Audio / file / gif never get variants. Prefer a variant (thumbnail/small) for list previews when present; fall back to url.


URLs — treat as opaque + refetchable

url is ready to drop into <img> / <video> / <audio>. But:

  • Key attachments by id, not by url. Do not persist the URL long-term or hard-code it.
  • URLs may become short-lived signed URLs in a future release (privacy). If you cache a URL and it later expires, refetch the message to get a fresh one. Nothing else about your integration changes.

Voice notes

Voice notes are just kind: "audio" — no special endpoint.

  1. Record with the native recorder (or browser MediaRecorder). Measure duration yourself while recording.
  2. mimeType differs by platform — Android/Chrome emit audio/webm (Opus), iOS/Safari emit audio/mp4. Send the actual mimeType and match the presign contentType / filename extension. Hard-coding webm breaks iOS playback.
  3. Presign → PUT → send { kind: "audio", storageKey, duration, mimeType }.
  4. Playback<audio src={url}>; render a play button + the duration.
  5. Enforce a max duration + size client-side before upload (kids app — keep it bounded).

Waveform UI is not provided by the backend. If you want one, compute peaks client-side at record time — the API only stores duration.


Editing media

Media is delete-only on a message you've sent — you can't swap or add attachments after sending (to add more, send a new message).

  • Text/caption edit — unchanged: PATCH /api/chat/messages/:messageId { "content": "..." } (15-min window, text only). See chat guide §5.7.
  • Remove one attachment:
DELETE /api/chat/messages/:messageId/media/:mediaId
  • Author only, within the 15-minute window (same as edit).
  • Deletes the attachment + its stored bytes.
  • Blocks removing the last attachment of a text-less message400 ("Delete the message instead"). A message can't end up empty.
  • Emits message_edited on the socket with mediaRemovedId (content unchanged) — splice that attachment out of your local copy by id.

Delete lifecycle (what happens to the bytes)

actionmessageattachmentsstored bytes
Remove one attachment (author, ≤15 min)keptthat one gonethat object deleted
Soft-delete message (DELETE .../messages/:id)tombstone ("This message was deleted")all removedall deleted
Decline a message requestconversation gonegonedeleted

A deleted message's media is gone for good — its urls stop working. Drop any cached thumbnails when you receive message_deleted.


Errors

codewhen
400message has neither text nor media; or removing the last attachment of a text-less message
401missing / invalid token
403storageKey isn't one you presigned (ownership); or not the sender on edit/remove; or edit window expired
404message or attachment not found
413 / storage errorobject too large — enforce caps client-side before PUT
429send rate limit (60/min/user) — see chat guide §5

Checklist

  • Upload via presign → PUT → send; never POST bytes through the API.
  • Send media[].storageKey (the presign key), never a raw URL. Send kind, mimeType, and duration/dimensions where they apply.
  • Allow media-only messages (omit content); block send when both empty.
  • Cap at 4 attachments; enforce size/duration limits before upload.
  • Render message.media[]; key each attachment by id. Distinguish attachments via media.length, not message.type.
  • Treat url as opaque + refetchable — don't persist it; refetch on expiry.
  • Voice notes: measure duration, send the real mimeType (webm vs mp4), play via <audio>.
  • Prefer image/video variants (thumbnails) for previews; fall back to url while variants is empty.
  • Wire remove-attachment (DELETE .../messages/:id/media/:mediaId); handle the 400 last-attachment case; update on message_edited + mediaRemovedId.
  • On message_deleted, drop cached media — the bytes are gone.