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 Authorization header on the PUT.
  • The upload URL is valid for 15 minutes.
  • An attachment is pending until a message links it, then ready. A pending attachment that is never linked is deleted after 24 hours.
  • A message can carry at most 10 attachments, with no duplicates. Its body may 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_size you declared, the send fails with 422 send_failed. Declaring more than you upload is accepted, but counts against your storage quota. The SDK sends what you pass as byteSize; use file.size in 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:

StatusCodeMeaning
422validation_failedA field is missing or invalid (for example no byte_size).
422byte_size_too_largeLarger than the 100 MiB maximum per file.
402attachment_too_largeLarger than your plan's per-file limit for hosted storage. details has bytes and cap.
402attachment_total_cap_exceededYour workspace's hosted attachment storage is full. details has used, would_use and cap.
402subscription_inactiveBilling needs attention.
403attachments_not_in_planYour 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.

Attachment uploads · poolse docs