Skip to content

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_new

Don’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.

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.

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.cracked is true and the server advertises local_attachment_auth_v1, send ready.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.

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.

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.

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.

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.

  • Uploads expire after 7 days by default. The server caps expires_in_days at the operator’s limit, 7 days unless changed.
  • If the operator sets that limit to -1, uploads never expire and expires_at is null.
  • An expired upload returns 404 from GET /attachments/{id}, and attachment_get answers Attachment not found or expired. Show it as unavailable instead of retrying.
  • The handshake’s uploads.uploads_per_minute is always 10, even when the operator configured a different rate. Handle HTTP 429 whatever your own counter says.

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.

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: