Messages Live
Messages live inside conversations. The same payload shape covers chat, ticket replies, internal notes, and system events; only the kind field distinguishes them.
For most uses the dashboard sends messages over the Socket.io agent namespace. The REST endpoint below exists for backend integrations, automation, and replay.
List messages in a conversation
Paginated, but not with the cursor convention used elsewhere on this site: the param here is before (an ISO timestamp, exclusive), and the response returns messages oldest-first within the page so the dashboard can render top-to-bottom without re-sorting. nextCursor in the response is the timestamp to pass as before on the next call.
Code samples
curl 'https://app.message.com/api/v1/conversations/:id/messages?limit=50' \
-H 'Authorization: Bearer YOUR_WORKSPACE_JWT'const res = await fetch(`https://app.message.com/api/v1/conversations/${conversationId}/messages?limit=50`, {
headers: { Authorization: 'Bearer ' + token }
});
const { messages, nextCursor } = await res.json();import requests
r = requests.get(
f"https://app.message.com/api/v1/conversations/{conversation_id}/messages",
headers={"Authorization": f"Bearer {token}"},
params={"limit": 50},
)
messages = r.json()["messages"]require "net/http"
require "json"
uri = URI("https://app.message.com/api/v1/conversations/#{conversation_id}/messages?limit=50")
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{token}"
http.request(req)
end
messages = JSON.parse(res.body)["messages"]<?php
$ctx = stream_context_create([
"http" => ["method" => "GET", "header" => "Authorization: Bearer $token"]
]);
$url = "https://app.message.com/api/v1/conversations/{$conversationId}/messages?limit=50";
$body = file_get_contents($url, false, $ctx);
$messages = json_decode($body, true)["messages"];Message shape
{
"id": "uuid",
"conversationId": "uuid",
"senderType": "agent",
"senderId": "uuid",
"body": "Hi, thanks for reaching out. We can issue a refund.",
"bodyHtml": "<p>Hi, thanks for reaching out. We can issue a refund.</p>",
"attachments": [
{ "url": "https://cdn.message.com/abc.pdf", "name": "refund-policy.pdf", "size": 53120 }
],
"deliveryStatus": null,
"readAt": null,
"createdAt": "2026-05-13T15:30:00Z"
}senderType values
| Value | Meaning |
|---|---|
visitor | The customer sent it (chat) or it arrived as inbound email / voicemail transcript. |
agent | An agent sent it. Visible to the visitor on chat / ticket; muted on call (used for in-call note pinning). |
ai | The AI sent it directly (auto-reply), not a human-edited draft. |
system | Status change, assignment, transfer, AI engagement note. Visible to agents, sometimes to visitors. |
There is no internal_note message kind. Internal notes are a separate resource entirely, not a message with a special senderType. See below.
Send a message
Sends an agent message into the conversation. Returns the created message. For ticket replies that should go out as email, use the tickets email-transcript action.
| Field | Type | Description |
|---|---|---|
| bodyrequired | string | Plain text body, 1 to 10,000 characters. |
| attachmentsoptional | object[] | Up to 20 items: { url, name?, size? }. URLs come from the Uploads API. |
{
"body": "Plain text body",
"attachments": [
{ "url": "https://cdn.message.com/abc.pdf", "name": "abc.pdf", "size": 53120 }
]
}There is no client-facing Idempotency-Key support (see REST API conventions). A retried POST after a timeout can create a duplicate message.
Internal notes are a different resource
This is not a message send with a hidden kind flag. Notes live in their own table. See Notes API for the full surface, including the PATCH endpoint's upsert-your-own-latest-note behavior and the note-level DELETE.
{ "body": "Internal note: customer is a Series A founder, escalate." }There is no message edit or delete endpoint. Once sent, a message's body is permanent; there is no editedAt or soft-delete on the messages resource.
Errors
| Code | When |
|---|---|
400 invalid_body | Body validation failed. Empty body is not allowed. |
403 channel_not_assigned | Agent's RBAC channels don't include this conversation's channel. |
404 not_found | Conversation does not exist, belongs to another workspace, or is outside the agent's department scope. |
409 conversation_archived | Conversation status is spam or solved. Reopen it first. |
Common pitfalls
- Passing
bodyHtmlon send. There is no such input field; only plainbodyis accepted.bodyHtmlon a returned message is server-rendered for display, not something you set. - Polling for new messages. Use the Socket.io agent namespace. Polling the list endpoint will burn through rate limits without delivering sub-second freshness.
- Expecting to edit or delete a sent message. Neither exists. Plan your integration around append-only messages.