Relay · Attachments
Upload and bind attachments
Relay keeps file bytes outside the conversation API. A client asks Relay for a short-lived upload URL, sends the bytes directly to object storage, and binds the resulting attachment ID when it sends a message.
request upload URL
→ PUT bytes to object storage
→ send message with attachment_id
→ Relay verifies the object and binds it atomically
→ request a fresh download URL when needed Relay currently exposes this flow through HTTP. The public SDK does not provide channel.upload(file) yet.
1. Request an upload URL
The caller must be a member of the target channel:
POST /api/uploads/presign
Authorization: Bearer <jwt>
Content-Type: application/json
{
"channel_id": "channel-uuid",
"original_filename": "photo.png",
"declared_content_type": "image/png"
} A successful request creates a tenant-scoped pending attachment and returns 201 Created:
{
"attachment_id": "attachment-uuid",
"upload_url": "https://storage.example/presigned-put-url",
"expires_at": "2026-07-14T12:05:00Z",
"key": "tenant_uuid/channel_uuid/storage-uuid"
} The upload URL is valid for five minutes by default. key is a server-generated storage path. Clients should use attachment_id as the Relay reference and should not persist or construct storage keys.
declared_content_type is advisory. It may be sent with the storage upload, but Relay does not inspect the file bytes to prove the MIME type.
Filename contract
Relay trims surrounding ASCII spaces and normalizes accepted filenames to Unicode NFC. A filename is rejected with 422 and {"error":"invalid_filename"} when it:
- Is empty after trimming
- Exceeds 255 UTF-8 bytes
- Contains
/,\\, ASCII or Unicode control characters, invisible format characters, or non-ASCII whitespace - Starts with
. - Uses a Windows reserved device name such as
CON,NUL,COM1, orLPT1
The original filename is stored as metadata. It is never used as the object-storage key.
2. Upload the bytes
Upload directly to the returned URL:
const upload = await fetch(uploadUrl, {
method: "PUT",
headers: {
"Content-Type": file.type || "application/octet-stream"
},
body: file
});
if (!upload.ok) throw new Error(`upload failed: ${upload.status}`); Completing the PUT does not publish an attachment. The row remains pending until a message binds it.
Do not treat the client-declared or uploaded Content-Type as trusted content detection. The current contract has no byte sniffing, file type allowlist, malware scan, quarantine step, or enforced maximum object size.
3. Bind the attachment to a message
Send up to 20 attachment IDs through the normal message write path:
POST /api/channels/{channel_id}/messages
Authorization: Bearer <jwt>
Content-Type: application/json
{
"body": "Here is the file",
"nonce": "client-generated-uuid",
"attachment_ids": ["attachment-uuid"]
} For every attachment, Relay verifies that:
- The ID exists in the same tenant
- The message sender created the pending attachment
- The attachment belongs to the same channel
- Its status is still
pending - The object exists in storage
Relay reads the object’s size, Content-Type metadata, and ETag during this step. It binds every attachment inside the same database transaction as the message. If any attachment fails, the message and all bindings roll back together.
An attachment is single-use. After it becomes bound, it cannot be attached to another message.
Message response
Bound attachment metadata appears inside REST, WebSocket, and webhook message payloads:
{
"data": {
"id": "message-uuid",
"nonce": "client-generated-uuid",
"seq": 42,
"attachments": [
{
"id": "attachment-uuid",
"content_type": "image/png",
"size_bytes": 1024,
"original_filename": "photo.png",
"bound_at": "2026-07-14T12:01:00Z"
}
]
}
} The payload does not contain a storage key or a long-lived download URL.
Retry an uncertain send
If message delivery times out, retry with the same nonce and the same attachment IDs. Relay returns the original message when the first send already committed.
Do not request a second upload URL merely because the message response was lost. A new attachment cited with an existing message nonce is not bound to the original message and remains pending until orphan cleanup.
4. Request a download URL
A current channel member can request a fresh URL for a bound attachment:
GET /api/attachments/{attachment_id}/url
Authorization: Bearer <jwt> {
"url": "https://storage.example/presigned-get-url",
"expires_at": "2026-07-14T12:10:00Z"
} The URL is valid for five minutes by default and forces download through Content-Disposition: attachment. PNG, JPEG, GIF, and WebP keep their image MIME type. Every other type, including SVG, is served as application/octet-stream.
Relay emits both an ASCII fallback and a UTF-8 filename in Content-Disposition, so clients should use the response header instead of rebuilding a filename from the URL.
Download errors
| Status | Error | Meaning |
|---|---|---|
403 | forbidden | The caller is not a current channel member |
404 | attachment_not_found | The attachment is absent or belongs to another tenant |
409 | attachment_not_bound | The upload exists but no message has bound it |
410 | attachment_tombstoned | Deletion has started and the attachment is no longer available |
429 | rate_limited | The download URL budget is exhausted |
Cleanup and deletion
Pending attachments older than one hour are eligible for orphan cleanup. The sweeper runs every 15 minutes and processes bounded batches, so one hour is an eligibility threshold, not an exact deletion time.
Admin message deletion tombstones its attachments and schedules object deletion. Admin user deletion does the same for attachments uploaded by that user. Storage deletion is asynchronous and retried for transient failures.
Retracting a message does not delete its attachments. They remain bound and downloadable while the caller remains a channel member. There is no public endpoint for deleting one attachment independently of its message.
Product boundary
Direct upload, tenant and channel scoping, single-use transactional binding, short-lived downloads, orphan cleanup, and asynchronous deletion are implemented.
Maximum file size, trusted content detection, MIME policy, malware scanning, quarantine, independent attachment deletion, resumable uploads, upload progress, and an SDK upload helper remain product work. Applications handling untrusted files must add their own validation and scanning before making those files available to users.
Next: search a channel.