Skip to main content
Version: Latest

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, or file only.


Allowed types + size caps

kindcontent typesmax size
imageimage/jpeg, image/png, image/webp10 MB
imageimage/heic, image/heif (iOS photos)15 MB
gifimage/gif (or a Klipy picker gif via remoteUrl)15 MB
fileapplication/pdf, .docx, .xlsx, text/csv25 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:

fieldrequirednotes
storageKeyThe key returned by presign. Not a URL. Server validates you own it.
kindimage | gif | file. Must match the real content (server sniffs).
mimeTypefiles ✅e.g. application/pdf, text/csv. Required for file kind so the server can validate a magic-less type like CSV.
width, heightimagepixels — lets the UI reserve space before load
sizeoptionalbytes (display hint)
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.
  • 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 rejected 415.
  • 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 → TEXT even with attachments. POST_SHARE is a shared-post card (chat guide §5.10) and SYSTEM is a server-authored lifecycle event (see mobile-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 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 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_read for it can arrive before your own new_message echo reconciles — and any later refetch that swaps in the processed variants comes even later. If you overwrite the cached row wholesale on either step, you wipe the status a receipt already advanced and the read/delivery tick "only shows on refresh." Splice in the new media/variants, keep the live status. 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 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.

Files (PDF / DOCX / CSV / XLSX)

Files are kind: "file" — same presign → PUT → send flow.

  1. Presign with the file's real contentType (e.g. application/pdf) and size.
  2. PUT with a matching Content-Type + Content-Length.
  3. Send { kind: "file", storageKey, mimeType, altText: "<filename>" }. mimeType is required for files — CSV has no magic number, so the server needs the declared type to validate it.
  4. Render a file chip (icon + altText name); open url in a viewer / share sheet on tap.

Video and voice notes are not supported right now. video/* and audio/* 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 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); PUT Content-Length ≠ presigned size; not the sender on edit/remove; edit window expired; or attaching anything to an unaccepted message request
404message or attachment not found
413declared size (or the stored object) exceeds the per-type cap
415unsupported content type (e.g. video/audio), or the uploaded bytes don't match the declared kind/mimeType
429send rate limit (60/min/user) — see chat guide §5

Checklist

  • Upload via presign → PUT → send; never POST bytes through the API.
  • Send size on presign; PUT with a matching Content-Type and Content-Length.
  • Send media[].storageKey (the presign key), never a raw URL. Send kind, plus mimeType for files and width/height for 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 by id. Distinguish attachments via media.length, not message.type.
  • Treat url as opaque + refetchable — don't persist it; refetch on expiry.
  • Prefer image 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.
⤓ Download .md