Attachment uploads
Files don't pass through the poolse API server. The client asks poolse for a presigned upload URL, PUTs the bytes straight to storage, then sends a message that references the attachment.
By default storage is hosted by poolse; you don't set up a bucket or CORS. You can instead connect your own S3-compatible bucket on any plan, and new uploads then go there with the same code. See Bring your own storage.
The flow
1. Client → poolse POST /v1/attachments/upload-url { content_type, byte_size, original_filename? }
2. poolse → Client 201 { attachment: { id, status: "pending", … }, upload: { url, method: "put", headers } }
3. Client → storage PUT upload.url (the bytes, with upload.headers)
4. Client → poolse POST /v1/conversations/:id/messages { body?, attachment_ids: [id] }
→ the attachment is linked to the message and becomes "ready"
- Every step uses the end-user JWT, except the PUT, which is authorised
by the URL itself. Don't send an
Authorizationheader on the PUT. - The upload URL is valid for 15 minutes.
- An attachment is
pendinguntil a message links it, thenready. Apendingattachment that is never linked is deleted after 24 hours. - A message can carry at most 10 attachments, with no duplicates. Its
bodymay be empty or left out when it has attachments. - Each attachment can be linked to one message only, and only by the user
who uploaded it. An unknown, already linked or someone else's attachment
makes the send fail with
422 send_failed. - Declare the real size. The upload URL doesn't limit the file size, so at
send time poolse checks the stored file. If it is larger than the
byte_sizeyou declared, the send fails with422 send_failed. Declaring more than you upload is accepted, but counts against your storage quota. The SDK sends what you pass asbyteSize; usefile.sizein browsers, as the built-in composers do.
With ConversationView
The composer in <ConversationView>
(web) and the React Native one
do all of this: pick or drop files, show progress, and send. Turn it off
with attachments={false}.
With the SDK
poolse.attachments.upload does
steps 1 to 3:
const file = input.files![0];
const attachment = await poolse.attachments.upload(
{ body: file, contentType: file.type, byteSize: file.size, filename: file.name },
{ onProgress: ({ loaded, total }) => setProgress(loaded / total) },
);
await poolse.conversations.one(conversationId).messages.send({
body: 'Here is the file',
attachment_ids: [attachment.id],
});
onProgress only fires in browsers (it needs XMLHttpRequest). Pass a
signal to cancel.
With the React hook
import { useAttachmentUpload, useMessages } from '@poolse/react';
function Composer({ conversationId }: { conversationId: string }) {
const { queue, uploadAll, remove } = useAttachmentUpload();
const { send } = useMessages(conversationId);
const ready = queue.filter((item) => item.status === 'ready' && item.attachment);
return (
<div>
<input
type="file"
multiple
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
void uploadAll(
files.map((f) => ({ body: f, contentType: f.type, byteSize: f.size, filename: f.name })),
);
}}
/>
<ul>
{queue.map((item) => (
<li key={item.localId}>
{item.filename}: {item.status}
{item.status === 'uploading'
? ` ${Math.round((item.loaded / item.byteSize) * 100)}%`
: null}
<button onClick={() => remove(item.localId)}>×</button>
</li>
))}
</ul>
<button
disabled={ready.length === 0}
onClick={async () => {
await send({ body: 'With attachments', attachment_ids: ready.map((i) => i.attachment!.id) });
ready.forEach((i) => remove(i.localId));
}}
>
Send
</button>
</div>
);
}
remove only takes an item out of the queue; it doesn't delete the upload
on the server. The full hook is described under
useAttachmentUpload.
Without the SDK
Step 1: ask for an upload URL.
curl -X POST https://api.poolse.dev/v1/attachments/upload-url \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"content_type":"image/png","byte_size":142812,"original_filename":"screenshot.png"}'
{
"attachment": { "id": "6d1c…", "status": "pending", "content_type": "image/png", "byte_size": 142812, … },
"upload": { "url": "https://…", "method": "put", "headers": { "content-type": "image/png" } }
}
Step 2: PUT the bytes, with every header from upload.headers.
curl -X PUT "$UPLOAD_URL" \
-H "content-type: image/png" \
--data-binary "@screenshot.png"
Use the same content type you declared in step 1; it is part of the signature.
Step 3: send a message with the attachment id.
curl -X POST https://api.poolse.dev/v1/conversations/$CONV_ID/messages \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"body":"Look at this","attachment_ids":["6d1c…"]}'
The response and the realtime message:new event carry the linked rows in
message.attachments.
Limits and errors
Errors from POST /v1/attachments/upload-url:
| Status | Code | Meaning |
|---|---|---|
| 422 | validation_failed | A field is missing or invalid (for example no byte_size). |
| 422 | byte_size_too_large | Larger than the 100 MiB maximum per file. |
| 402 | attachment_too_large | Larger than your plan's per-file limit for hosted storage. details has bytes and cap. |
| 402 | attachment_total_cap_exceeded | Your workspace's hosted attachment storage is full. details has used, would_use and cap. |
| 402 | subscription_inactive | Billing needs attention. |
| 403 | attachments_not_in_plan | Your plan doesn't include attachments. |
Hosted storage has per-plan limits: Free 300 MB in total and 1 MB per
file, Pro and Scale 500 MB in total and 25 MB per file. They are listed in
Limits & quotas. With your own bucket
connected, neither 402 applies: there is no storage limit from poolse,
and files up to 100 MB are accepted on every plan.
Sending a message with more than 10 attachment_ids, or the same id twice,
fails with 422 validation_failed. All error bodies are described on the
Errors page.
Downloads
curl https://api.poolse.dev/v1/attachments/$ATTACHMENT_ID/download-url \
-H "Authorization: Bearer $JWT"
# { "url": "https://…", "method": "get" }
The URL is valid for one hour. While an attachment is pending only the
uploader can get it; once it is linked, any member of the conversation can.
In React, useAttachmentUrl(id)
fetches it once and doesn't renew it, so remount it on screens that stay open
longer than an hour.
Deleting
DELETE /v1/attachments/:id (poolse.attachments.one(id).delete()) deletes
the attachment and its file. The uploader can delete a pending attachment;
once it is linked, the message's sender or a conversation owner or admin can.