Uploading attachments
An attachment is a file stored by the server and linked from a message. Sending one takes two steps: upload the bytes over HTTP, then send a normal WebSocket message that references the upload. This guide covers both steps, plus expiry, quotas, and error handling.
read policy -> HTTP upload -> attachment record -> message_newDon’t send the message until every upload has finished. If one upload fails, keep the draft so the user can retry or remove that file.
Read the upload policy
Section titled “Read the upload policy”The handshake carries the server’s policy in attachments:
{ "enabled": true, "max_size": 104857600, "allowed_types": ["image/*", "video/*", "audio/*", "application/pdf"], "max_attachments_per_user": -1, "permanent_tiers": ["pro", "max"]}| Field | Meaning |
|---|---|
enabled |
Uploads work only when this is true. |
max_size |
Largest file the server accepts, in bytes. |
allowed_types |
MIME types the server accepts. Entries can be exact (application/pdf), a family (image/*), or everything (* or */*). An empty list accepts any MIME type. |
max_attachments_per_user |
How many unexpired uploads one account can hold. -1 means no limit and 0 blocks uploads. |
permanent_tiers |
Reserved. Uploads are never permanent on this server. |
Checking the policy first saves wasted uploads, but the server has the final say. It can also refuse an upload because of its total storage limit or its upload rate.
Choose how to authenticate
Section titled “Choose how to authenticate”Signing in on the WebSocket doesn’t sign in your HTTP requests. Each upload carries its own proof, chosen by the kind of account in ready:
-
Rotur account. Ask Rotur for a fresh validator for this connection’s
validator_key, and send both:const { validator } = await rotur.validators.generate(handshake.validator_key);form.append("validator_key", handshake.validator_key);form.append("validator", validator);Never send the user’s Rotur token to an OriginChats server.
-
Server account. When
ready.user.crackedis true and the server advertiseslocal_attachment_auth_v1, sendready.local_upload_token:form.append("local_upload_token", ready.local_upload_token);
Send one method, not both. If local_upload_token is present, the server uses it and ignores the validator fields.
Upload the file
Section titled “Upload the file”POST /attachments/upload accepts multipart form data or JSON. Multipart sends the bytes as they are, so prefer it:
async function uploadAttachment(serverUrl: string, file: File, auth: UploadAuth) { const form = new FormData(); form.append("file", file); form.append("name", file.name); form.append("mime_type", file.type || "application/octet-stream"); form.append("expires_in_days", "7");
if (auth.type === "local") { form.append("local_upload_token", auth.token); } else { form.append("validator_key", auth.key); form.append("validator", auth.validator); }
const response = await fetch(new URL("/attachments/upload", serverUrl), { method: "POST", body: form, }); const body = await response.json(); if (!response.ok) throw new Error(body.error || `Upload failed with ${response.status}`); return body.attachment;}Don’t set the Content-Type header yourself. The browser adds it with the multipart boundary.
| Field | Required | Description |
|---|---|---|
file |
Yes | The file. |
name |
No | File name to store. Defaults to the multipart file name. At most 255 characters. |
mime_type |
No | MIME type. Defaults to the file part’s Content-Type. Parameters such as ; charset=utf-8 are dropped before the allowlist check. |
expires_in_days |
No | Days until the upload expires. Defaults to 7. Must be positive. |
Upload as JSON
Section titled “Upload as JSON”A JSON upload sends the file as base64 with Content-Type: application/json:
{ "file": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", "name": "diagram.png", "mime_type": "image/png", "validator_key": "originChats-https://chat.example.com-4c1f0a9b2e7d45f8a3b6c9d0e1f2a3b4", "validator": "<validator from Rotur>", "expires_in_days": 7}file can be raw base64 or a data URI. A data URI must start with data:<mime_type>;base64,, using the same MIME type as mime_type.
What the server does to images
Section titled “What the server does to images”For PNG and JPEG uploads the server records width and height. It always strips EXIF metadata, and applies EXIF rotation to the pixels first. If the operator enabled compression, it also shrinks large images and re-encodes JPEGs, keeping the result only when it’s smaller. Animated images are stored unchanged apart from the EXIF removal.
Attach the upload to a message
Section titled “Attach the upload to a message”A successful upload returns HTTP 201:
{ "attachment": { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "diagram.png", "mime_type": "image/png", "size": 28431, "url": "https://chat.example.com/attachments/550e8400-e29b-41d4-a716-446655440000", "expires_at": 1788782400, "permanent": false, "width": 1280, "height": 720 }, "permanent": false}body.attachment follows the attachment schema. Reference it by id in message_new or message_edit:
{ "cmd": "message_new", "channel": "general", "content": "Here is the diagram.", "attachments": [{ "id": "550e8400-e29b-41d4-a716-446655440000" }]}The server replaces each reference with its stored record. You may pass numeric width and height to override the stored ones. If an ID matches an upload that has expired or whose file is missing, the message fails with Attachment <id> not found or expired.
If you sign the message, build the final attachment list first. Attachments are part of the signed payload.
Expiry and quotas
Section titled “Expiry and quotas”- Uploads expire after 7 days by default. The server caps
expires_in_daysat the operator’s limit, 7 days unless changed. - If the operator sets that limit to
-1, uploads never expire andexpires_atisnull. - An expired upload returns 404 from
GET /attachments/{id}, andattachment_getanswersAttachment not found or expired. Show it as unavailable instead of retrying. - The handshake’s
uploads.uploads_per_minuteis always10, even when the operator configured a different rate. Handle HTTP 429 whatever your own counter says.
Handle errors
Section titled “Handle errors”A failed upload returns an HTTP error status and a JSON body:
{ "error": "File type application/x-msdownload is not allowed" }| Status | Cause | What to do |
|---|---|---|
400 |
Bad JSON, base64, data URI, file name, empty file, or expires_in_days. |
Fix the request. |
401 |
Missing or invalid validator or upload token. | For Rotur, generate a new validator. For a server account, reconnect for a new token. |
403 |
The account is banned. | Stop. |
413 |
The file is too big, or the server’s storage is full. | Remove or shrink the file. |
415 |
Wrong request Content-Type, a MIME type that isn’t allowed, or an image the server can’t process. |
Convert or remove the file. |
429 |
Upload rate limit, attachment count limit, or an account timeout. | Read error. A timeout response also has length, the remaining time in milliseconds. |
500 |
The server couldn’t store the file. | Retry a limited number of times. |
502 |
The server couldn’t reach Rotur to check the validator. | Retry later with a new validator. |
503 |
Uploads are turned off. | Hide upload controls until a new handshake says otherwise. |
Don’t retry 400, 403, 413, or 415 automatically. The same bytes fail the same way. HTTP routes lists every error string.
Download and delete
Section titled “Download and delete”GET /attachments/{id} returns the file bytes with the stored MIME type and Cache-Control: public, max-age=86400, immutable. Use the url from the attachment record, which may point to external storage instead.
Over the WebSocket:
attachment_getreturns an attachment’s metadata.attachment_deletedeletes an upload. The uploader or anyone withmanage_servercan use it.message_deletealso deletes the attachments of the message it removes.
Next steps
Section titled “Next steps”- Notifications: alert users about new messages.
- HTTP routes: every HTTP endpoint the server exposes.
- Emoji, stickers, and attachments: how uploaded media fits together.
