Attachments
File bytes never pass through the poolse API. You ask poolse for a
presigned URL, upload the file straight to storage with it, then send a
message that lists the attachment's id. Every endpoint here takes an
end-user JWT.
The full flow, with code, is in the Attachment uploads guide.
POST /v1/attachments/upload-urlwith the file's type and size. You get an attachment in statuspendingand an upload URL.PUTthe bytes toupload.urlwithupload.headers.- Send a message with
attachment_ids: [id]. The attachment is linked to the message and its status becomesready.
poolse does not check that step 2 happened before step 3. If the upload failed, the message links an attachment with no file behind it.
What is enforced, and when. The upload URL does not lock the file's
type or size: storage accepts whatever is PUT. Your plan's limits are
checked against the byte_size you declare in step 1. In step 3, poolse
reads the stored file's size: if it is larger than the declared
byte_size, the send is refused with 422 send_failed and the message
contains attachment_size_mismatch. If storage cannot be reached for
that check, the send goes ahead.
An attachment still pending (never linked to a message) 24 hours after
it was requested is deleted automatically, file included.
Where the file goes. Into poolse's hosted storage, or, if the
workspace has connected its own S3-compatible bucket, into that bucket.
The endpoints and responses are the same either way; only the host in
upload.url and the download URL differs. The plan quotas
attachment_too_large and attachment_total_cap_exceeded apply to
hosted storage only. See Bring your own storage.
The attachment object
{
"id": "a3c9e1f0-5b7d-4e2a-8c6f-0d1b3e5a7c92",
"tenant_id": "65787825-3152-4c1e-9b8a-0d6f4e2a7c11",
"message_id": null,
"sender_id": "11111111-2222-3333-4444-555555555555",
"content_type": "image/png",
"byte_size": 142812,
"sha256": null,
"original_filename": "screenshot.png",
"status": "pending",
"inserted_at": "2026-06-02T08:13:55.123456Z",
"updated_at": "2026-06-02T08:13:55.123456Z"
}
| Field | Type | Description |
|---|---|---|
id | uuid | Pass it in a message's attachment_ids. |
message_id | uuid | null | The message it is linked to. null until then. |
sender_id | uuid | null | The uploader. Only they can link it to a message. |
content_type | string | As requested. |
byte_size | integer | As requested. |
sha256 | string | null | |
original_filename | string | null | As requested. |
status | "pending" | "ready" | ready once linked to a message. |
inserted_at, updated_at | string | ISO 8601. |
Request an upload URL
/v1/attachments/upload-urlRequest body
| Field | Type | Required | Description |
|---|---|---|---|
content_type | string | yes | MIME type, 1–255 characters. Returned in upload.headers and stored on the attachment. It is not checked against the uploaded file. |
byte_size | integer | yes | File size in bytes. Greater than 0 and at most 100 MiB, and within your plan's limits. The file is checked against it when it is attached to a message. |
original_filename | string | no | Up to 512 characters. |
Response
201:
{
"attachment": {
"id": "a3c9e1f0-5b7d-4e2a-8c6f-0d1b3e5a7c92",
"tenant_id": "65787825-3152-4c1e-9b8a-0d6f4e2a7c11",
"message_id": null,
"sender_id": "11111111-2222-3333-4444-555555555555",
"content_type": "image/png",
"byte_size": 142812,
"sha256": null,
"original_filename": "screenshot.png",
"status": "pending",
"inserted_at": "2026-06-02T08:13:55.123456Z",
"updated_at": "2026-06-02T08:13:55.123456Z"
},
"upload": {
"url": "https://…",
"method": "put",
"headers": { "content-type": "image/png" }
}
}
The upload URL is valid for 15 minutes. Send the bytes with an HTTP
PUT (the method value is lowercase) and every header in
upload.headers.
curl -X POST https://api.poolse.dev/v1/attachments/upload-url \
-H "Authorization: Bearer $USER_JWT" \
-H "Content-Type: application/json" \
-d '{"content_type":"image/png","byte_size":142812,"original_filename":"screenshot.png"}'
# then, with the returned URL:
curl -X PUT "$UPLOAD_URL" -H "content-type: image/png" --data-binary @screenshot.png
Errors
| Code | Status | When |
|---|---|---|
validation_failed | 422 | byte_size missing or not a positive integer, or content_type missing. |
byte_size_too_large | 422 | byte_size over 100 MiB. |
attachment_too_large | 402 | Over your plan's per-file limit for hosted storage. details: { bytes, cap }. |
attachment_total_cap_exceeded | 402 | Would exceed your plan's hosted storage. details: { used, would_use, cap }. |
subscription_inactive | 402 | Your subscription is past due or cancelled. |
attachments_not_in_plan | 403 | Your plan does not include attachments. |
upload_request_failed | 422 | Storage could not issue the URL. |
Get a download URL
/v1/attachments/:id/download-urlReturns a presigned URL to download the file. It is valid for 1 hour.
Who may call it: any member of the conversation the attachment's message is in. Before it is linked to a message, only the uploader.
200:
{
"url": "https://…",
"method": "get"
}
| Code | Status | When |
|---|---|---|
forbidden | 403 | The caller may not read it. |
not_found | 404 | No such attachment in the tenant. |
storage_error | 502 | Storage could not issue the URL. Retry. |
Delete an attachment
/v1/attachments/:idDeletes the attachment and its file. The file deletion is best-effort: if storage fails, the attachment is still gone from poolse.
Who may call it: the uploader, and once it is linked to a message, also
an owner or admin of that conversation.
204 on success.
| Code | Status | When |
|---|---|---|
forbidden | 403 | The caller may not delete it. |
not_found | 404 | No such attachment in the tenant. |
Deleting a message already deletes its attachments. See Delete a message.