Channels & Messages
Create conversation channels, send and retrieve messages over REST or the client SDK, and layer presence, typing indicators, and read receipts for a live chat UX.
Channel types
dm, group, and broadcast channels with server-controlled membership.
REST + WebSocket
Post and list history via REST; live delivery via the client SDK socket.
Presence
online / away / offline for members — updated as clients connect and idle.
Typing
Ephemeral start/stop typing events that auto-expire after a few seconds.
Read receipts
Mark a message (or latest) as read; others get read events in real time.
Ordered history
Monotonic seq per channel with cursor-based pagination for infinite scroll.
10 KB
UTF-8 text
50
default page size
~3s
auto clear
100
hard cap (beta)
Authentication
Authorization: Basic <token>. Auth guide →Create a channel from your server with Basic Auth. Members must already have known userIds in your system (they do not need an active token at creation time).
| Field | Type | Required | Description |
|---|---|---|---|
| type | "dm" | "group" | "broadcast" | required | dm: 2 members. group: up to 100. broadcast: many readers, restricted writers. |
| memberIds | string[] | required | User ids to add. For dm, must contain exactly 2 unique ids. |
| name | string | optional | Display title (max 120 chars). Recommended for group/broadcast. |
| metadata | object | optional | Arbitrary JSON (max 4 KB) — e.g. ticketId, orderId, locale. |
{"type": "group","name": "Order #1042","memberIds": ["user_01HZX9K2M","user_01HZX9ABC","agent_support_01"],"metadata": {"orderId": "ORD-1042","priority": "normal"}}
Response (201)
{"success": true,"code": "CHAT_CHANNEL_CREATED","message": "Channel created.","requestId": "REQ-1782001200001","timestamp": "2026-07-12T10:01:00.000Z","data": {"id": "ch_01HZYA3N","type": "dm","name": "Support · Ama","memberIds": ["user_01HZX9K2M", "agent_support_01"],"metadata": {},"createdAt": "2026-07-12T10:01:00.000Z","updatedAt": "2026-07-12T10:01:00.000Z"}}
Post as a specific user or as a system message. Server posts are useful for order updates, bots, and moderation notices. Connected members receive the message over WebSocket immediately.
| Field | Type | Required | Description |
|---|---|---|---|
| body | string | required | Message text. Max 10 KB UTF-8. |
| userId | string | optional | Author user id. Required unless type is system. Must be a channel member for user posts. |
| type | "text" | "system" | optional | Default text. system messages appear as platform notices. |
| metadata | object | optional | Client or server metadata (max 4 KB). Use clientMessageId for idempotency. |
curl -X POST 'https://api.sendexa.co/v1/chat-sdk/channels/ch_01HZYA3N/messages' \-H 'Content-Type: application/json' \-H 'Authorization: Basic YOUR_DASHBOARD_BASE64_TOKEN' \-d '{"userId": "agent_support_01","body": "Thanks for writing in — I can help with order #1042.","type": "text","metadata": {"source": "agent-console"}}'
Cursor-based history for infinite scroll. Messages are returned newest-first by default; reverse on the client if your UI paints oldest-first.
| Field | Type | Required | Description |
|---|---|---|---|
| limit | number (query) | optional | Page size 1–100. Default 50. |
| before | string (query) | optional | Message id cursor — return messages older than this id. |
| after | string (query) | optional | Message id cursor — return messages newer than this id (sync gap fill). |
# First pagecurl -X GET 'https://api.sendexa.co/v1/chat-sdk/channels/ch_01HZYA3N/messages?limit=50' \-H 'Authorization: Basic YOUR_DASHBOARD_BASE64_TOKEN'# Older pagecurl -X GET 'https://api.sendexa.co/v1/chat-sdk/channels/ch_01HZYA3N/messages?limit=50&before=msg_01HZYB8P' \-H 'Authorization: Basic YOUR_DASHBOARD_BASE64_TOKEN'
After init + connect with a user token, members send and receive in real time. The authenticated user is the author; you do not pass userId from the client.
sendMessage
| Field | Type | Required | Description |
|---|---|---|---|
| channelId | string | required | Target channel. Caller must be a member. |
| body | string | required | Text content (max 10 KB). |
| metadata | object | optional | Optional map. Prefer metadata.clientMessageId for optimistic UI / dedupe. |
import { SendexaChat } from '@sendexa/chat-sdk';const chat = SendexaChat.init({ token: userChatToken });await chat.connect();const message = await chat.sendMessage({channelId: 'ch_01HZYA3N',body: 'Hi — I need help with my order.',metadata: {clientMessageId: crypto.randomUUID(),},});console.log(message.id, message.seq, message.createdAt);
Presence is derived from active WebSocket sessions. When the last device for a user disconnects, status moves to offline after a short grace period.
| Status | Meaning |
|---|---|
| online | At least one connected socket for this userId |
| away | Connected but idle past the away threshold (~5 min) |
| offline | No active sockets; lastSeenAt is set |
// Subscribechat.on('presence', (event) => {// {// userId: 'user_01HZX9K2M',// status: 'online' | 'away' | 'offline',// lastSeenAt: '2026-07-12T10:10:00.000Z' | null// }updateAvatarDot(event.userId, event.status);});// Optional: set local status (e.g. user opens "Do not disturb")await chat.setPresence('away');// Snapshot for a channel's membersconst members = await chat.getChannelPresence('ch_01HZYA3N');// [{ userId, status, lastSeenAt }, ...]
Typing events are ephemeral and not stored in history. Call startTyping on input; the server auto-clears after ~3 seconds if you do not refresh. Use a throttle (e.g. once per 2s) while the user is typing.
const channelId = 'ch_01HZYA3N';let typingTimer;function onComposerInput() {chat.startTyping(channelId);clearTimeout(typingTimer);typingTimer = setTimeout(() => {chat.stopTyping(channelId);}, 2000);}chat.on('typing', ({ channelId, userId, isTyping }) => {if (userId === me.userId) return;setTypingLabel(channelId,isTyping ? `${userId} is typing…` : null);});
Marking read advances a per-user watermark in the channel. Other members receive a read event so you can render double-check UI.
| Field | Type | Required | Description |
|---|---|---|---|
| channelId | string | required | Channel to update. |
| messageId | string | optional | Highest message the user has seen. Omit to mark the latest message as read. |
// When the user opens a thread or scrolls to the bottomawait chat.markRead({channelId: 'ch_01HZYA3N',messageId: 'msg_01HZYB8P',});chat.on('read', (event) => {// {// channelId: 'ch_01HZYA3N',// userId: 'agent_support_01',// messageId: 'msg_01HZYB8P',// readAt: '2026-07-12T10:06:00.000Z'// }updateReadWatermark(event);});// Optional snapshotconst receipts = await chat.getReadState('ch_01HZYA3N');// [{ userId, messageId, readAt }, ...]
Client Event Reference
| Event | Payload (summary) | When |
|---|---|---|
connected | { connectionId } | WebSocket ready |
disconnected | { reason } | Socket closed / network loss |
message | Message object | New message in a joined channel |
presence | { userId, status, lastSeenAt } | Member presence changes |
typing | { channelId, userId, isTyping } | Typing start/stop |
read | { channelId, userId, messageId, readAt } | Read watermark advances |
error | { code, message } | Auth, validation, or transport errors |
Validation & Errors
Members only
Send / list / markRead fail with 403 if the acting user is not a channel member.
Idempotent client ids
Reusing the same metadata.clientMessageId within a short window returns the original message instead of duplicating.
Empty body
Whitespace-only bodies are rejected with EMPTY_MESSAGE_BODY.
| HTTP | Code | Cause |
|---|---|---|
| 400 | INVALID_CHANNEL_TYPE | type not dm | group | broadcast |
| 400 | INVALID_MEMBER_LIST | dm without exactly 2 members, empty list, or duplicates |
| 400 | EMPTY_MESSAGE_BODY | Missing or blank body |
| 400 | MESSAGE_TOO_LARGE | Body exceeds 10 KB |
| 403 | NOT_CHANNEL_MEMBER | userId not in channel.memberIds |
| 404 | CHANNEL_NOT_FOUND | Unknown channelId |
| 429 | RATE_LIMIT_EXCEEDED | Message or channel create rate limited |
Best Practices
- Create support / marketplace channels on the server so membership cannot be spoofed by clients
- Use
seq(orcreatedAt+id) to sort and dedupe when merging live events with history pages - Throttle
startTypingand callmarkReadwhen the thread is actually visible — not on every received message in background tabs - Store
metadatafor deep links (order ids, ticket ids) rather than encoding them only in free text - Subscribe to
chat.message.createdwebhooks for offline push notifications