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.
1. Read the handshake policy
Section titled “1. Read the handshake policy”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.
2. Generate an upload validator
Section titled “2. Generate an upload validator”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.
3. Upload with multipart form data
Section titled “3. Upload with multipart form data”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.
4. Upload as JSON
Section titled “4. Upload as JSON”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.
5. Handle the response
Section titled “5. Handle the 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.
6. Send the attachment in a message
Section titled “6. Send the attachment in a message”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.
Expiry, quotas, and rate limits
Section titled “Expiry, quotas, and rate limits”expires_in_daysmust be positive. When omitted, the server starts from seven days.- A positive
attachments.free_tier_max_expiration_dayscaps the requested value. The default cap is seven days. - A cap of
-1produces an attachment withexpires_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: -1is unlimited,0blocks uploads, and a positive number counts the user’s non-expired records.max_total_upload_sizecan reject an upload when server-wide stored bytes would exceed the configured maximum.- Actual rate limiting uses
attachments.uploads_per_minuteover a rolling 60-second window. A value at or below zero is unlimited. - The handshake’s separate
uploads.uploads_per_minutefield is currently fixed at10, so an HTTP429is authoritative if the operator configured a different limit.
HTTP errors
Section titled “HTTP errors”| 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.
