Skip to content

Upload attachments

Attachments use an authenticated HTTP upload followed by a normal WebSocket message. The upload endpoint is POST /attachments/upload on the OriginChats server; attachment_upload is advertised as a feature capability, not a WebSocket command.

The official client’s implementation is src/lib/media/attachment-uploader.ts in originchats-client.

Before offering a file picker, inspect handshake.val.attachments:

const policy = handshake.val.attachments;
if (!policy?.enabled) {
throw new Error('This server has attachments disabled');
}
if (file.size > policy.max_size) {
throw new Error('The file is too large');
}

allowed_types contains exact MIME types, wildcards such as image/*, or */*/*. An empty server allowlist permits any syntactically valid MIME type. Client-side checks improve feedback, but the HTTP endpoint remains authoritative.

The handshake also provides the connection-specific validator_key needed for upload authentication. Keep the complete handshake value; do not construct or shorten it yourself.

An upload does not inherit the WebSocket’s authenticated state. Generate a fresh Rotur validator for the handshake’s key and include both values in the HTTP body.

With rotur-sdk:

import { Rotur } from 'rotur-sdk';
const rotur = new Rotur({ token: roturToken });
const validatorKey = handshake.val.validator_key;
const { validator } = await rotur.validators.generate(validatorKey);

Without the SDK, call the endpoint used by that method:

const response = await fetch('https://api.rotur.dev/v2/validators', {
method: 'POST',
headers: {
Authorization: `Bearer ${roturToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ key: validatorKey }),
});
if (!response.ok) throw new Error(`Validator request failed: ${response.status}`);
const { validator } = await response.json();
if (!validator) throw new Error('Rotur returned no validator');

Never send the Rotur token to the OriginChats server. Only Rotur receives it; the upload receives the short-lived validator.

Multipart is the simplest path for browser File objects. The endpoint accepts:

Field Requirement
file Required file part
name Optional display name; defaults to the uploaded filename
mime_type Optional when the file part has a Content-Type; otherwise required
validator_key Required handshake validator key
validator Required Rotur validator generated for that exact key
expires_in_days Optional positive number
async function uploadAttachment(
serverUrl: string,
file: File,
validatorKey: string,
validator: string,
) {
const body = new FormData();
body.append('file', file);
body.append('name', file.name);
body.append('mime_type', file.type || 'application/octet-stream');
body.append('validator_key', validatorKey);
body.append('validator', validator);
body.append('expires_in_days', '7');
const response = await fetch(
`${serverUrl.replace(/\/$/, '')}/attachments/upload`,
{ method: 'POST', body },
);
const result = await response.json();
if (!response.ok) throw new Error(result.error || `Upload failed: ${response.status}`);
return result.attachment;
}

Do not manually set the multipart Content-Type header. The browser must add its generated boundary. The official client uses XMLHttpRequest instead of fetch for upload progress, limits itself to three concurrent uploads per destination, and retries network, 429, and 5xx failures with a newly generated validator.

JSON uploads accept base64 data in file. It may be raw base64 or an exact data URI whose MIME type matches mime_type.

{
"file": "data:image/png;base64,iVBORw0KGgoAAA…",
"name": "diagram.png",
"mime_type": "image/png",
"validator_key": "<handshake validator_key>",
"validator": "<Rotur validator>",
"expires_in_days": 7
}

Send this body with Content-Type: application/json. Invalid JSON, base64, or a mismatched data-URI MIME type receives a 400 response.

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": 1760000000,
"permanent": false
},
"permanent": false
}

The nested attachment is the object to retain. Image dimensions can be added client-side as numeric width and height; the message resolver preserves those two fields. The current upload path does not inspect dimensions, resize images, or apply the configured compression settings.

Wait until every upload has completed, then place the returned attachment objects in message_new.attachments:

socket.send(JSON.stringify({
cmd: 'message_new',
channel: 'general',
content: 'Here is the diagram.',
attachments: [attachment],
listener: crypto.randomUUID(),
}));

The message handler resolves known attachment IDs back to the server’s trusted stored metadata before saving the message. If message signing is enabled, sign the final attachment array you send; see signing and verification.

  • expires_in_days must be positive. When omitted, the server starts from seven days.
  • A positive attachments.free_tier_max_expiration_days caps the requested value. The default cap is seven days.
  • A cap of -1 produces an attachment with expires_at: null.
  • Current uploads are always recorded and returned as permanent: false; permanent-tier configuration is not used by this handler.
  • max_attachments_per_user: -1 is unlimited, 0 blocks uploads, and a positive number counts the user’s non-expired records.
  • max_total_upload_size can reject an upload when server-wide stored bytes would exceed the configured maximum.
  • Actual rate limiting uses attachments.uploads_per_minute over a rolling 60-second window. A value at or below zero is unlimited.
  • The handshake’s separate uploads.uploads_per_minute field is currently fixed at 10, so an HTTP 429 is authoritative if the operator configured a different limit.
Status Typical cause
400 Invalid body, missing file, base64/data URI, filename, or expiry
401 Missing, oversized, invalid, or expired validator credentials
403 Authenticated user is banned
413 File too large or total attachment storage exhausted
415 Unsupported body content type or disallowed file MIME type
429 Per-minute upload rate or per-user attachment count reached
500 Attachment storage or index write failed
502 Rotur validation could not be completed or parsed
503 Attachments are disabled

Generate a new validator for a retry. Do not automatically retry permanent validation errors, disallowed types, or oversized files.