Messages
Every endpoint here takes an end-user JWT. The caller must be a member of the conversation. Message bodies are encrypted at rest and returned in plaintext to members. See Encryption & retention.
The message object
{
"id": "5f0c2a7e-8d1b-4e3f-a6c9-2b7d0e4f1a38",
"tenant_id": "65787825-3152-4c1e-9b8a-0d6f4e2a7c11",
"conversation_id": "c8a1f4e2-6b3d-4f7a-9c2e-1d5b8a0f3e61",
"sender_id": "11111111-2222-3333-4444-555555555555",
"sender_external_id": "acme-alice",
"type": "text",
"body": "hello",
"reply_to_id": null,
"thread_root_id": null,
"reply_count": 0,
"quoted_message_id": null,
"quoted_message": null,
"mentions": [],
"reactions": { "👍": ["22222222-3333-4444-5555-666666666666"] },
"attachments": [],
"edited_at": null,
"deleted_at": null,
"sequence": 184,
"inserted_at": "2026-06-02T08:13:55.123456Z",
"updated_at": "2026-06-02T08:13:55.123456Z"
}
| Field | Type | Description |
|---|---|---|
id | uuid | |
tenant_id, conversation_id | uuid | |
sender_id | uuid | null | The sender's poolse id. null on messages poolse writes itself (type: "system"). |
sender_external_id | string | null | The sender's external_id. |
type | "text" | "custom" | "system" | custom bodies are JSON strings. system messages are written by poolse, for example one per finished call. You cannot send them. |
body | string | null | Plaintext. "" on an attachment-only message, null once deleted. |
reply_to_id | uuid | null | The message this replies to in a thread. |
thread_root_id | uuid | null | The thread's top message. null for messages in the main feed. |
reply_count | integer | Number of non-deleted replies in the thread this message starts. |
quoted_message_id | uuid | null | A message quoted inline. The new message stays in the main feed. |
quoted_message | object | null | Preview of the quoted message: { id, sender_id, sender_external_id, body, deleted_at, inserted_at }. body is cut to 200 bytes plus …. |
mentions | uuid[] | Poolse ids of mentioned members. |
reactions | object | Emoji to the list of user ids who reacted with it. |
attachments | Attachment[] | Linked attachments. See Attachments. |
edited_at | string | null | Set by an edit. |
deleted_at | string | null | Set by a delete. |
sequence | integer | Per-conversation order, assigned at send. Thread replies take numbers from the same counter, so the main feed has gaps. |
inserted_at, updated_at | string | ISO 8601. |
The realtime message:new and
message:updated events carry the same shape.
List messages
/v1/conversations/:conversation_id/messagesThe main feed, newest first. Deleted messages and thread replies are not
included. Fetch replies with
GET /v1/messages/:id/replies.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
limit | integer | 50 | 1–100. Values outside the range are clamped. |
before | integer | none | Only messages with sequence lower than this. Pass the lowest sequence you have to load older messages. |
Response
200: { "data": [ Message, … ] }. There is no cursor field: when you
get fewer than limit rows, you have reached the start.
curl "https://api.poolse.dev/v1/conversations/$CONV_ID/messages?limit=50&before=135" \
-H "Authorization: Bearer $USER_JWT"
| Code | Status | When |
|---|---|---|
forbidden | 403 | The caller is not a member. |
not_found | 404 | No such conversation. |
Send a message
/v1/conversations/:conversation_id/messagesRequest body
| Field | Type | Required | Description |
|---|---|---|---|
body | string | unless attachment_ids is set | Up to 64 KB of text. A message needs text, attachments, or both. |
type | "text" | "custom" | no | Default text. For custom, body must be valid JSON. |
attachment_ids | uuid[] | no | Up to 10 attachments, uploaded by the caller and not yet used on another message. See Attachments. |
reply_to_id | uuid | no | Makes this a thread reply. Replying to a reply joins the same thread. |
quoted_message_id | uuid | no | Quote a message from the same conversation. |
mentions | uuid[] | no | Poolse ids of current members to mention. Each gets a mention:new event on their user channel. |
id | uuid | no | Your own id for the message, so a retried send cannot create a duplicate. Reusing an existing id fails with validation_failed. |
Response
201: the message, with sequence and attachments set.
Sending also marks the conversation read for the sender, updates the
conversation's last_message_* fields, and pushes the message to
members over realtime.
curl -X POST https://api.poolse.dev/v1/conversations/$CONV_ID/messages \
-H "Authorization: Bearer $USER_JWT" \
-H "Content-Type: application/json" \
-d '{"body":"hello","mentions":["22222222-3333-4444-5555-666666666666"]}'
Errors
| Code | Status | When |
|---|---|---|
validation_failed | 422 | No text and no attachments, body over 64 KB, type not text / custom, a custom body that is not JSON, more than 10 or duplicate attachment_ids, a mention of a non-member, or an id already in use. details names the field. |
send_failed | 422 | reply_to_id or quoted_message_id not found, a quote from another conversation, or an attachment that is unknown, already used, uploaded by someone else, or whose stored file is larger than the byte_size declared for it (the message then contains attachment_size_mismatch). |
bad_request | 400 | reply_to_id, quoted_message_id, or an entry of attachment_ids or mentions is not a valid UUID. |
forbidden | 403 | The caller is not a member, or your subscription is inactive. |
not_found | 404 | No such conversation. |
Edit a message
/v1/messages/:idReplaces the body. Only the sender can edit. The new text may be blank only if the message has attachments.
If this is the conversation's latest message, its
last_message_preview is rewritten too, and members get
conversation:updated on their user channel.
| Field | Type | Required | Description |
|---|---|---|---|
body | string | yes | The new text, up to 64 KB. |
200: the message, with edited_at set.
curl -X PATCH https://api.poolse.dev/v1/messages/$MSG_ID \
-H "Authorization: Bearer $USER_JWT" \
-H "Content-Type: application/json" \
-d '{"body":"hello, world"}'
| Code | Status | When |
|---|---|---|
forbidden | 403 | The caller is not the sender. |
not_found | 404 | No such message in the tenant. |
message_deleted | 409 | The message is deleted. |
validation_failed | 422 | body missing, not a string, or over 64 KB, or blank on a message with no attachments (details: { body: ["can't be blank"] }). |
Delete a message
/v1/messages/:idSoft-deletes the message: body becomes null and deleted_at is set.
Its attachments are deleted, files included. Deleted messages no longer
appear in the message list. The sender can delete their own messages.
A conversation owner or admin can delete anyone's.
204 on success. Deleting an already-deleted message also returns 204.
| Code | Status | When |
|---|---|---|
forbidden | 403 | Not the sender, and not an owner or admin. |
not_found | 404 | No such message in the tenant. |
List thread replies
/v1/messages/:id/repliesThe replies in the thread started by message :id, oldest first. The
top message itself and deleted replies are not included.
| Name | Type | Default | Description |
|---|---|---|---|
limit | integer | 500 | 1–1000. Values outside the range are clamped. |
after | integer | none | Only replies with sequence higher than this. |
200: { "data": [ Message, … ] }.
| Code | Status | When |
|---|---|---|
forbidden | 403 | The caller is not a member. |
not_found | 404 | No such message. |
Add a reaction
/v1/messages/:id/reactions| Field | Type | Required | Description |
|---|---|---|---|
emoji | string | yes | Any non-empty string, usually one emoji. |
Adding a reaction you already have changes nothing.
200: the updated message.
curl -X POST https://api.poolse.dev/v1/messages/$MSG_ID/reactions \
-H "Authorization: Bearer $USER_JWT" \
-H "Content-Type: application/json" \
-d '{"emoji":"👍"}'
| Code | Status | When |
|---|---|---|
validation_failed | 422 | emoji missing or empty. |
message_deleted | 409 | The message is deleted. |
forbidden | 403 | The caller is not a member. |
not_found | 404 | No such message. |
Remove a reaction
/v1/messages/:id/reactions/:emojiRemoves the caller's own reaction. URL-encode the emoji in the path. Removing a reaction you don't have changes nothing.
200: the updated message.
| Code | Status | When |
|---|---|---|
forbidden | 403 | The caller is not a member. |
not_found | 404 | No such message. |
Mark read
/v1/conversations/:conversation_id/readMoves the caller's read position to a message. This drives their
unread_count. The position is set to exactly the message you pass,
even an older one: marking an earlier message read moves the position
back and raises the unread count again. Send the newest message you
have shown.
| Field | Type | Required | Description |
|---|---|---|---|
message_id | uuid | yes | A message in this conversation. |
204 on success. Other members get a member:read event on the
conversation channel if the
workspace's read-receipt setting lets them see it. See
Read receipts & unread.
curl -X POST https://api.poolse.dev/v1/conversations/$CONV_ID/read \
-H "Authorization: Bearer $USER_JWT" \
-H "Content-Type: application/json" \
-d '{"message_id":"5f0c2a7e-8d1b-4e3f-a6c9-2b7d0e4f1a38"}'
| Code | Status | When |
|---|---|---|
validation_failed | 422 | message_id missing. |
bad_request | 400 | message_id is not a valid UUID. |
message_not_found | 404 | The message is not in this conversation. |
forbidden | 403 | The caller is not a member. |
not_found | 404 | No such conversation. |
Webhooks
Your backend can receive message.created, message.updated,
message.deleted and mention.created. See Webhooks.