Messages
The unit of conversation — text, images, or system events.
Messages are chat content inside a channel. Cariosan ships three message types out of the box, with explicit limits enforced server-side so a misbehaving client can't degrade the workspace.
text— the default. Up toCARIOS_MAX_MESSAGE_SIZE_BYTES(8 KiB default).image— an attachment uploaded via presigned S3 URL. Thecontentfield still holds the caption.system— server-generated notifications. Reserved for future features.
Sending
The server validates every send:
- Membership — non-members get
403 NOT_A_MEMBER. - Size — over-limit content is rejected with
413. - Attachment URL — must start with
CARIOS_S3_PUBLIC_URLso users can't post arbitrary third-party URLs as attachments. - Rate limit — per-user,
CARIOS_RATE_LIMIT_MESSAGES_PER_MINUTE(60/min default). Over-limit returns429with aRetry-Afterheader.
Idempotency
Pass an idempotency_key (any client-unique string) to make sendMessage retryable. The server returns the same message ID on re-send, so a flaky network won't double-post.
History
Cursor pagination by created_at DESC, id DESC. Max limit is 100 (default 50).
Reactions
Members react to a message with any emoji. Reactions are aggregated per emoji (a count plus whether you reacted) and fan out over WebSocket in realtime.
Each message in history carries its aggregated reactions:
Adding the same emoji twice is idempotent; removing one you never added is a no-op.
Editing
The author can edit their own message. The edit fans out as message.updated and the message gains an edited_at timestamp.
- Author-only — others get
403 NOT_AUTHOR. - Optional edit window (server config; unlimited by default). Past the window:
422 EDIT_WINDOW_EXPIRED.
Unsending (delete-for-everyone)
The author can unsend their message. The content and attachment are blanked server-side — a true unsend, the original text is not retained — deleted_at is set, and a message.deleted tombstone fans out.
- Author-only (channel-admin moderation arrives with the moderation phase).
- Idempotent; optional unsend window (
422 UNSEND_WINDOW_EXPIREDpast it). - History still returns the row with blanked
contentanddeleted_atset — render it as “This message was deleted”.
Realtime over REST
Reactions, edits, and unsends are issued over REST and fan out to connected members over WebSocket automatically (Redis pub/sub). See the WebSocket protocol for the event payloads. These helpers are available in the TypeScript client + React kit today; other languages can call the REST endpoints directly.
Quoted replies
Reply to a message by passing parentId — the id of the message you're quoting. The server validates the parent lives in the same channel and embeds a truncated quoted_message preview on the reply, so clients render the quote without a second fetch.
Every reply carries parent_id plus a quoted_message preview — on the send response, in history, and on the live message.new event:
- An unknown
parentId, or one belonging to a different channel, is rejected with400 VALIDATION_ERROR. - The preview is re-resolved on every history fetch, not frozen at send time: an edited parent shows its latest content, and an unsent parent surfaces
deleted: true(render it as "Original message was deleted"). @cariosan/react'sMessageListrenders the quote above the reply automatically; themessage.createdwebhook also carriesparent_id+quoted_message.
Mentions
@-mention channel members by passing their external_ids in mentions when you send. Mentions are explicit ids from the client — Cariosan never parses @handles out of your text, so there's no ambiguity about who was mentioned.
The server validates every mentioned id is a member of the channel (a non-member or unknown id rejects the whole send with 400 VALIDATION_ERROR), then resolves them into a mentions array on the message:
Each mentioned member is also pinged in realtime on their own WebSocket topic (notification.mention), so they're alerted even if they aren't currently viewing the channel — wire it to an in-app badge or a notification. The message.created webhook carries the mentions array too, and a dedicated message.mentioned webhook fires once per mentioning message.
- Unread mentions —
GET /v1/me/channelsreturnsmention_unread_countper channel alongsideunread_count, so you can badge channels with unread mentions on load. @cariosan/react'sMessageInputships an@autocomplete over channel members;MessageListhighlights mention tokens;useMentions()tracks live mention counts for badges.
Read markers
Read state is per-user, per-channel — not per-message. The next GET /v1/me/channels response includes an unread_count for every channel, computed as "messages with created_at > last_read_at, excluding ones you authored".
Read & delivery receipts
WhatsApp-style ticks, cursor-based — one last_delivered_at + last_read_at per member, not a row per message, so receipts stay cheap regardless of volume. For each of your sent messages:
- ✓ sent — persisted.
- ✓✓ delivered — every other member's
last_delivered_at≥ the message'screated_at. - ✓✓ blue read — every other member's
last_read_at≥ the message'screated_at.
The SDK acks delivery automatically (it sends channel.delivered on every inbound message), and markRead() advances the read cursor. Both broadcast in realtime so senders see ticks flip live:
GET /v1/channels/:id/receipts returns the per-member cursors for an initial render; live channel.read / channel.delivered events keep them current. In @cariosan/react, pass currentUserId to MessageList for automatic ✓/✓✓/✓✓-blue ticks on your own bubbles, or use useReadReceipts().
Privacy toggle
Read receipts honour a workspace setting read_receipts_enabled (default true, configurable from the Cloud dashboard). When off, the server stops broadcasting channel.read (no blue ticks, either direction) — delivery receipts (✓✓ grey) still flow, and unread counts are unaffected.
Search
Full-text search over message content, ranked by relevance. Search within one channel, or across every channel the caller belongs to:
The query accepts websearch syntax — "quoted phrases", OR, and -exclude. Matching uses Postgres' simple text-search config (language-agnostic — no stemming), a deliberate choice for a mixed Indonesian + English corpus where a single-language stemmer would mangle the other.
- Member-scoped — cross-channel search only ever returns messages from channels you're a member of; it never leaks other channels.
- Excludes deleted — unsent (tombstoned) messages drop out of the index automatically.
- Paginated —
?limit=(max 100, default 50) and?before=<RFC3339>to window to older matches; the response carrieshas_more+next_cursorlike history.
REST: GET /v1/channels/:external_id/messages/search?q=… and GET /v1/search/messages?q=…. In @cariosan/react, the useSearch(channelId?) hook wraps both (the search-box UI is left to you to style).
Attachments
Uploads are direct-to-S3 via a presigned PUT URL. Bytes never proxy through the Cariosan server, which keeps the server tiny even with image-heavy chat.
Under the hood:
- Client POSTs metadata (filename, mime, size) to
/v1/attachments/presign. - Server returns a short-lived (15 min) presigned URL plus the public URL.
- Client PUTs the file body directly to S3.
- Client passes the public URL in
sendMessage.
Allowed types
image/jpeg, image/png, image/webp, image/gif. Max size CARIOS_MAX_ATTACHMENT_SIZE_BYTES (10 MB default). Reject other types client-side too so you don't waste a presign round-trip.
Realtime delivery
Every message inserted over REST fans out over WebSocket to every connected channel member on every Cariosan instance (via Redis pub/sub). See the WebSocket protocol reference for the wire format.
Delivery is at-least-once
Clients should de-dupe messages by id when reconciling REST history with live WebSocket events — reconnects may replay messages briefly while the gateway recovers state. The SDK handles this automatically; only relevant if you build a custom client.
Was this page helpful?