Mobile Chat Media — Integration Guide
Attach images, GIFs, and files (PDF / DOCX / CSV / XLSX) 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.
No video, no audio. Video and voice notes are not accepted right now. Chat attachments are
image,gif, orfileonly.
Allowed types + size caps
| kind | content types | max size |
|---|---|---|
| image | image/jpeg, image/png, image/webp | 10 MB |
| image | image/heic, image/heif (iOS photos) | 15 MB |
| gif | image/gif (or a Klipy picker gif via remoteUrl) | 15 MB |
| file | application/pdf, .docx, .xlsx, text/csv | 25 MB |
Anything else — including any video/* or audio/* — is rejected at presign
(415). The server also sniffs the uploaded bytes and rejects a file whose
real content doesn't match what you declared (a .png-named PDF, etc.).
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": "photo.jpg", "contentType": "image/jpeg", "size": 348211 } ] }
{
"success": true,
"data": [
{
"uploadUrl": "https://.../signed-put-url", // PUT bytes here
"publicUrl": "https://.../media/<you>-...", // ignore — server resolves URLs
"key": "media/<yourUserId>-1719-uuid.jpg", // ← this is your storageKey
"contentType": "image/jpeg"
}
]
}
size(bytes) is now required and must be the exact byte length you will PUT — the server rejects a value over the cap (413) and pins it into the signed URL.- Max 4 files per presign call.
filename's extension decides the object's extension — pass a real one (.jpg,.png,.gif,.pdf,.docx,.csv,.xlsx).
Step 2 — PUT the bytes
PUT <uploadUrl>
Content-Type: image/jpeg ← must match the presign contentType
Content-Length: 348211 ← must match the presign size (exact)
<raw bytes>
Direct to storage. The API never sees the bytes. The Content-Length must
equal the size you presigned — storage rejects a body of any other length
(403). Most HTTP clients set Content-Length automatically for a known-size
body. 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.jpg", // the `key` from step 1
"kind": "image", // image | gif | file
"mimeType": "image/jpeg", // required for files (pdf/docx/csv/xlsx)
"width": 1290, "height": 1720
}
]
}
Response data is the normal message object plus media[] (see below). The
same object is pushed over the socket as new_message — media included.
Not in a message request
Media cannot be sent into a PENDING DM. Until the recipient accepts, a
request carries plain text only — media[] (image, file or GIF) and
postId both return 403 "Only text messages can be sent in a message
request".
Hide the attachment and GIF buttons whenever the open conversation has
status === "PENDING" and you are the requesterId. Restore them on
conversation_status_changed. See chat guide §6.4.1.
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 | gif | file. Must match the real content (server sniffs). |
mimeType | files ✅ | e.g. application/pdf, text/csv. Required for file kind so the server can validate a magic-less type like CSV. |
width, height | image | pixels — lets the UI reserve space before load |
size | optional | bytes (display hint) |
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. - Content is verified — the server sniffs the bytes; if the real type doesn't
match
kind/mimeType(or isn't allowed in chat), the send is rejected415. - Metadata (
width/height/size) is treated as a hint.
Picker GIFs (Klipy)
A picker gif is sent with remoteUrl (the Klipy URL) instead of storageKey,
kind: "gif", and no owned object. The server validates the host + a 15 MB /
image-type HEAD check. Exactly one of storageKey or remoteUrl.
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 | POST_SHARE | SYSTEM | CALL
"senderId": "...",
"media": [
{
"id": "med_xxx", // stable handle — key your UI by this
"type": "image", // ATTACHMENT kind (image | gif | file)
"url": "https://...", // ready to render — see "URLs" below
"mimeType": "image/jpeg",
"width": 1290,
"height": 1720,
"altText": null,
"variants": [] // image 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,POST_SHARE,SYSTEM,CALL.MEDIA= attachments only, no text. Text present →TEXTeven with attachments.POST_SHAREis a shared-post card (chat guide §5.10) andSYSTEMis a server-authored lifecycle event (seemobile-chat-system-messages-guide.md) — neither ever carries attachments. Never infer the attachment kind from this.media[i].type= the attachment kind (image/gif/file). 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 only)
Image attachments get resized variants generated async after send. On
first render variants may be [] and url is the original — fine to show.
gif / file never get variants. Prefer a variant (thumbnail/small) for list
previews when present; fall back to url.
Merge, don't replace, when the message updates. A media message is slower to round-trip than text (bytes upload first), so a
message_delivered/messages_readfor it can arrive before your ownnew_messageecho reconciles — and any later refetch that swaps in the processedvariantscomes even later. If you overwrite the cached row wholesale on either step, you wipe thestatusa receipt already advanced and the read/delivery tick "only shows on refresh." Splice in the newmedia/variants, keep the livestatus. Full rule + merge helper: chat guide §5.5.1.
URLs — treat as opaque + refetchable
url is ready to drop into <img> (or a file/document viewer). 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.
Files (PDF / DOCX / CSV / XLSX)
Files are kind: "file" — same presign → PUT → send flow.
- Presign with the file's real
contentType(e.g.application/pdf) andsize. - PUT with a matching
Content-Type+Content-Length. - Send
{ kind: "file", storageKey, mimeType, altText: "<filename>" }.mimeTypeis required for files — CSV has no magic number, so the server needs the declared type to validate it. - Render a file chip (icon +
altTextname); openurlin a viewer / share sheet on tap.
Video and voice notes are not supported right now.
video/*andaudio/*presigns are rejected (415). Don't offer a record-audio or attach-video path.
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); PUT Content-Length ≠ presigned size; not the sender on edit/remove; edit window expired; or attaching anything to an unaccepted message request |
| 404 | message or attachment not found |
| 413 | declared size (or the stored object) exceeds the per-type cap |
| 415 | unsupported content type (e.g. video/audio), or the uploaded bytes don't match the declared kind/mimeType |
| 429 | send rate limit (60/min/user) — see chat guide §5 |
Checklist
- Upload via presign → PUT → send; never POST bytes through the API.
- Send
sizeon presign; PUT with a matchingContent-TypeandContent-Length. - Send
media[].storageKey(the presignkey), never a raw URL. Sendkind, plusmimeTypefor files andwidth/heightfor images. - Only offer image / gif / file (PDF/DOCX/CSV/XLSX) — no video, no audio.
- Offer none of them at all on a PENDING message request you sent — it's text-only until accepted.
- Enforce the caps client-side (image 10MB, gif 15MB, file 25MB) before PUT.
- Allow media-only messages (omit
content); block send when both empty. - Cap at 4 attachments.
- Render
message.media[]; key each attachment byid. Distinguish attachments viamedia.length, notmessage.type. - Treat
urlas opaque + refetchable — don't persist it; refetch on expiry. - Prefer image
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.