Attachments & media
Bytes never pass through the API. Clients upload and download through presigned URLs, so transfer throughput is independent of API load — but nothing can be referenced until the server has verified it.
Lifecycle #
pending → uploaded → verifying → ready, or
failed. Only a ready attachment can be attached to a message.
# 1. declare the upload
POST /v1/rooms/{room_id}/attachments/init
{ "original_name": "photo.jpg", "declared_mime": "image/jpeg",
"byte_size": 918273, "sha256": "9f86d0…" } # sha256 optional but recommended
# → { "attachment_id": "…", "upload_url": "https://…", "expires_at": "…" }
# 2. PUT the bytes straight to storage (never through the API)
curl -X PUT "$upload_url" --data-binary @photo.jpg -H "Content-Type: image/jpeg"
# 3. complete — this is what triggers verification
POST /v1/rooms/{room_id}/attachments/{attachment_id}/complete
# → status moves pending → uploaded → (worker) → ready
# 4. reference it in a message
POST /v1/rooms/{room_id}/messages
{ "client_message_id": "m-1", "kind": "image",
"body": { "text": "" }, "attachment_ids": ["…"] } What the server checks #
| Check | Purpose |
|---|---|
| Size | Matches what was declared and is within configured limits. |
| Checksum | Content integrity when you supply sha256. Streamed, so memory use is constant regardless of file size. |
| Magic bytes | The file's real type must match the declared MIME. Renaming an .exe to .jpg fails here. |
| Type policy | Executables and HTML are rejected outright. |
| Decodability (images) | An image must actually decode, because a thumbnail is generated from it. Correct magic bytes are not enough. |
image rejected: decode
image — the attachment lands in failed and download returns
409 conflict: attachment is failed. This trips up synthetic test fixtures far more
often than real uploads.
A failed attachment emits attachment.failed; a successful one emits
attachment.ready on the chat socket, which is your cue to render it.
Large files #
Above the single-PUT threshold, use multipart: parts upload in parallel, an individual part can be retried without restarting the transfer, and abandoned uploads are swept automatically.
POST /v1/rooms/{room_id}/attachments/init-multipart
{ "original_name": "talk.mp4", "declared_mime": "video/mp4",
"byte_size": 734003200,
"part_size": 8388608 } # OPTIONAL — the server picks a default if omitted
# (note: it is part_size, NOT part_count)
# → { "attachment_id": "…",
# "upload_id": "…",
# "part_size": 8388608,
# "part_urls": [ "https://…part1", "https://…part2", … ], # one presigned PUT per part, in order
# "expires_at": "…" }
# PUT each part (in parallel), collect the ETags, then:
POST /v1/rooms/{room_id}/attachments/{attachment_id}/complete-multipart
{ "parts": [ { "part_number": 1, "etag": "\"abc…\"" }, … ] } Processing #
- Thumbnails are generated for images, with EXIF orientation applied so rotated phone photos are not sideways.
- Voice notes use
kind: "voice"; putduration_msin the message body for the waveform UI. - Video uses a client-supplied poster image uploaded as a second attachment and linked to the video. There is deliberately no server-side transcoding or frame extraction — that would mean shipping GPL-family codecs.
Downloading #
GET /v1/rooms/{room_id}/attachments/{attachment_id}/download
# → { "url": "https://…", "expires_at": "…", "poster_url": "https://…" } Authorised per request against room membership, then redirected to storage. Range requests are supported, which is what makes seeking in audio and video work.
In encrypted rooms #
The client encrypts the blob and uploads ciphertext with declared_mime:
"application/octet-stream". Verification then falls back to size (and checksum) only —
magic bytes are meaningless on ciphertext — and no server-side thumbnail is possible, so send an
encrypted micro-thumbnail as a second attachment. Blocked-type enforcement necessarily moves to
your client.