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:
| field | required | notes |
|---|---|---|
storageKey | ✅ | The key returned by presign. Not a URL. Server validates you own it. |
kind | ✅ | image | video | audio | file | gif |
mimeType | recommended | e.g. audio/webm, image/jpeg. Required for correct audio playback. |
width, height | image/video | pixels — lets the UI reserve space before load |
duration | audio/video | seconds |
size | optional | bytes |
altText | optional | ≤ 200 chars; also used as file display name |
Rules:
contentis optional — but a message must have text OR at least one attachment. Empty + no media →400.- Max 4 attachments per message.
- Ownership —
storageKeymust 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 →TEXTeven with attachments. Never infer the attachment kind from this.media[i].type= the attachment kind (image/audio/…). Note the send field is calledkindbut the read field istype— 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 byurl. 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.
- Record with the native recorder (or browser
MediaRecorder). Measure duration yourself while recording. - mimeType differs by platform — Android/Chrome emit
audio/webm(Opus), iOS/Safari emitaudio/mp4. Send the actual mimeType and match the presigncontentType/ filename extension. Hard-coding webm breaks iOS playback. - Presign → PUT → send
{ kind: "audio", storageKey, duration, mimeType }. - Playback —
<audio src={url}>; render a play button + theduration. - 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 message →
400("Delete the message instead"). A message can't end up empty. - Emits
message_editedon the socket withmediaRemovedId(content unchanged) — splice that attachment out of your local copy byid.
Delete lifecycle (what happens to the bytes)
| action | message | attachments | stored bytes |
|---|---|---|---|
| Remove one attachment (author, ≤15 min) | kept | that one gone | that object deleted |
Soft-delete message (DELETE .../messages/:id) | tombstone ("This message was deleted") | all removed | all deleted |
| Decline a message request | conversation gone | gone | deleted |
A deleted message's media is gone for good — its urls stop working. Drop
any cached thumbnails when you receive message_deleted.
Errors
| code | when |
|---|---|
| 400 | message has neither text nor media; or removing the last attachment of a text-less message |
| 401 | missing / invalid token |
| 403 | storageKey isn't one you presigned (ownership); or not the sender on edit/remove; or edit window expired |
| 404 | message or attachment not found |
| 413 / storage error | object too large — enforce caps client-side before PUT |
| 429 | send 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 presignkey), never a raw URL. Sendkind,mimeType, andduration/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 byid. Distinguish attachments viamedia.length, notmessage.type. - Treat
urlas opaque + refetchable — don't persist it; refetch on expiry. - Voice notes: measure
duration, send the realmimeType(webm vs mp4), play via<audio>. - Prefer image/video
variants(thumbnails) for previews; fall back tourlwhilevariantsis empty. - Wire remove-attachment (
DELETE .../messages/:id/media/:mediaId); handle the400last-attachment case; update onmessage_edited+mediaRemovedId. - On
message_deleted, drop cached media — the bytes are gone.