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
- Your app asks Livqeno for an upload URL. Livqeno checks the file's size and type first.
- Your app uploads the file to that URL.
- Your app tells Livqeno the upload is complete.
- Your app sends a message with the
attachmentId. Everyone in the conversation gets it as a normalmessageevent.
POST .../attachmentsPOST .../completesendMessage({ attachmentId })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:readscope. - 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/secretscan'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
| Problem | Cause | Fix |
|---|---|---|
RAVEN_NOT_CONFIGURED | File storage isn't set up on the server. | See Known limitations. |
Upload to the signed URL fails with 403 | The 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 working | Download URLs are short-lived. | Ask for a fresh URL on every click. |
ATTACHMENT_TOO_LARGE when creating the ticket | The 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 message | The upload wasn't completed. | Call complete() before sendMessage(). |
PERMISSION_DENIED when sending the message | Someone 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.
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.