Build it

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 #

pendinguploadedverifyingready, 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 #

CheckPurpose
SizeMatches what was declared and is within configured limits.
ChecksumContent integrity when you supply sha256. Streamed, so memory use is constant regardless of file size.
Magic bytesThe file's real type must match the declared MIME. Renaming an .exe to .jpg fails here.
Type policyExecutables 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.
Test with a real image. A file with a valid JPEG/PNG header but corrupt body passes the magic-byte check and then fails verification with 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…\"" }, … ] }
Presigned URL lifetimes scale with size (up to two hours), so a large transfer on a slow mobile link does not expire mid-flight.

Processing #

  • Thumbnails are generated for images, with EXIF orientation applied so rotated phone photos are not sideways.
  • Voice notes use kind: "voice"; put duration_ms in 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.