livqenoDocs

ChatAttachments

Attachments

Send files in chat. Files upload straight to storage, not through the WebSocket.

Attachments let users send files in chat: a screenshot to a support agent, a PDF to a team channel, a photo in a direct message. The file becomes part of a message, and only members of that conversation can download it.

Where this fits: your app is connected to a conversation and can already send text messages. An attachment is a normal message with an attachmentId. Everything on this page runs in your app.

Files never go through the chat connection. Your app uploads the file straight to storage, using a short-lived signed URL (a link that works for one file, for a few minutes).

How an upload works

  1. Your app asks Livqeno for an upload URL. Livqeno checks the file's size and type first.
  2. Your app uploads the file to that URL.
  3. Your app tells Livqeno the upload is complete.
  4. Your app sends a message with the attachmentId. Everyone in the conversation gets it as a normal message event.
Client
Livqeno
Object storage
1POST .../attachments
signed PUT URL
2PUT the bytes
3POST .../complete
4sendMessage({ attachmentId })
Uploading an attachment: the bytes go straight to storage.

Why not send files over the WebSocket?

  • A big file would block every message behind it, for everyone.
  • Base64 makes files about a third bigger.
  • Direct uploads can be retried or resumed without touching your chat.

Upload a file

React Native uses the same calls as web (livqeno.chat is a ChatClient).

The simple way, in one call. Use this for most apps. In your app:

const attachment = await chat.attachments.upload(file);
await chat.sendMessage({ type: 'attachment', attachmentId: attachment.id, text: 'See attached' });

upload() does steps 1–3 for you. Then you send the message (step 4). text is optional; it shows as a caption.

Step by step, when you need a progress bar or resumable upload:

const ticket = await chat.attachments.createUploadTicket({
  filename: file.name,
  mimeType: file.type,
  size: file.size,
});
 
await fetch(ticket.uploadUrl, { method: 'PUT', headers: ticket.uploadHeaders, body: file });
await chat.attachments.complete(ticket.id);

Send ticket.uploadHeaders exactly as given, and upload exactly size bytes. The signed URL locks in both the Content-Type and the Content-Length, so storage rejects an upload of a different type or size with 403. You don't set Content-Length yourself: browsers and fetch set it from the body.

complete() then checks the stored file. If its size or type doesn't match the ticket, or it's over the limit, Livqeno deletes it and the attachment can't be used. Ask for a new ticket.

Download a file

When a user clicks an attachment in a message, get a download URL in your app:

const { url, expiresAt } = await chat.attachments.getDownloadUrl('att_…');
  • Get the URL when the user clicks. Don't cache it. It expires.
  • Before giving a URL, Livqeno checks the project, that the user is a member of the conversation, and the chat:read scope.
  • A leaked URL gives access to one file, for a few minutes only.

Security properties

  • Storage passwords never reach the browser. Only a signed URL does, and it points to exactly one file.
  • Your filename is not the storage path. Files are stored as chat/{project}/{conversation}/{random}. A name like ../../../other-tenant/secrets can't reach anything. The original name is kept and returned on download.
  • Filenames are cleaned of slashes and control characters.
  • Size is checked before the URL is given, and both the size and the content type are signed into the URL. The app can't change them later.
  • The stored file is checked on complete(). A file that doesn't match the ticket is deleted, never attached.
  • Only the uploader can attach the file, once, to one message.

Common errors

ProblemCauseFix
RAVEN_NOT_CONFIGUREDFile storage isn't set up on the server.See Known limitations.
Upload to the signed URL fails with 403The headers, content type or byte count differ from the ticket, or the URL expired.Send ticket.uploadHeaders unchanged, upload the same file whose size you declared, and upload soon after creating the ticket.
INVALID_MESSAGE or ATTACHMENT_TOO_LARGE from complete()The stored file's size or type doesn't match the ticket. The file was deleted.Create a new ticket with the file's real size and type.
Download link stops workingDownload URLs are short-lived.Ask for a fresh URL on every click.
ATTACHMENT_TOO_LARGE when creating the ticketThe file is over the size limit (25 MB by default).Check the size in your app before uploading.
ATTACHMENT_NOT_FOUND from complete()Nothing has been uploaded to uploadUrl yet.Upload first, then call complete().
ATTACHMENT_NOT_FOUND when sending the messageThe upload wasn't completed.Call complete() before sendMessage().
PERMISSION_DENIED when sending the messageSomeone else uploaded that file.Only the uploader can attach it.

Configuration

These are server settings. Any S3-compatible storage works: AWS S3, MinIO, Cloudflare R2, DigitalOcean Spaces.

Terminal
STORAGE_ENDPOINT=https://s3.eu-west-1.amazonaws.com
STORAGE_REGION=eu-west-1
STORAGE_BUCKET=raven-attachments
STORAGE_ACCESS_KEY_ID=...
STORAGE_SECRET_ACCESS_KEY=...

Next steps

  • Messages: the message an attachment rides on.
  • Moderation: deleting a message with an attachment.