Search the complete documentation

Try idempotency, proposal lifecycle, authentication, or ordering.

Navigate Open
繁體中文
Browse documentation

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, or LPT1

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

StatusErrorMeaning
403forbiddenThe caller is not a current channel member
404attachment_not_foundThe attachment is absent or belongs to another tenant
409attachment_not_boundThe upload exists but no message has bound it
410attachment_tombstonedDeletion has started and the attachment is no longer available
429rate_limitedThe 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.