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. - Availability — if a dependency the send path needs (Redis) is unreachable, the server returns
503 SERVICE_UNAVAILABLEwith aRetry-Afterheader. Nothing was stored. Show the message as unsent (with a retry action) rather than as delivered. - Over the WebSocket, a
message.sendframe gets the same answers as error frames:RATE_LIMITED,QUOTA_EXCEEDED(withdetails) andSERVICE_UNAVAILABLE, each withretry_after_secondsin place of the header. See error codes. - Idempotency — send a
client_message_idand a retry cannot create a second message (next section).
Retrying a send safely
A request can succeed on the server and still look like a failure to the sender: the connection drops after the message was stored but before the response arrives. Retrying then would send it twice. To make a retry safe, generate a UUID once per message and send it as client_message_id; reuse the same value on every retry of that message.
- The first request creates the message (
201). Any later request with the same key returns the stored message with200andIdempotent-Replayed: true, and does nothing else: no second realtime frame, push, webhook, or rate-limit count. - A key belongs to its sender and its channel. Two users may use the same UUID independently; reusing one key for a different channel is
409 IDEMPOTENCY_KEY_REUSED. An invalid UUID is400. - The key identifies the message, the body is not compared: a retry with different text still returns the original. A replay returns the message as it is now (for example with
deleted_atset if it was unsent in between). - Keys are kept for the life of the message. A retry is honoured even if the channel was frozen in the meantime, because the message already exists.
- Requests without a key behave as before and are never deduplicated.
- The WebSocket honours the same key. A
message.sendframe whoseclient_message_idis a UUID is deduplicated exactly like the REST call: the retry'smessage.acknames the stored message and carries"replayed": true. A non-UUIDclient_message_idon the WebSocket is only echoed back. - The key comes back. Messages that were sent with a key carry it as
client_message_idin the send response, themessage.newframe, history and search. The sender's client uses it to recognise its own message the moment it arrives (live or on a reload) and replace the "sending" or "failed" entry with it. It is not a secret; every channel member sees it, and it can only replay the sender's own message.
@cariosan/client's outbox does all of this for you: it generates the key when a message is queued and reuses it for every retry.
Messaging is a user-token API
Sending, history, read markers and attachments are called with a user's token, through @cariosan/client (or REST). The Go server SDK uses your API key and covers users, channels, members and webhooks; it has no message methods.
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 ordered by created_at DESC, id DESC. Max limit is 100 (default 50).
Send both halves of the cursor
Messages can share a timestamp (a bulk import, or a burst within the same microsecond), so a timestamp alone is not a position. next_cursor + next_cursor_id together are an exact position: pass them back as before + before_id and every message is returned exactly once. before on its own is still accepted (it is the older, timestamp-only cursor) but skips any messages that share the boundary timestamp. before_id without before is rejected with 400 VALIDATION_ERROR. Search results are relevance-ordered and use page_token instead; they carry no next_cursor or next_cursor_id.
Staying in sync
A message is durable the moment sendMessage returns. The realtime frame that tells other clients about it is a best-effort fast path: it can be lost while a socket is down, when the server's per-channel buffer overflows, or when the Redis bus rejects a publish. History is the source of truth, so a client repairs its view by re-reading history and reconciling it.
The server helps in two ways:
- It tells connected clients when it knows it lost something: a
channel.resyncframe for the affected channel. Clients that were never disconnected would otherwise never find out. The signal is coalesced, so a burst of losses produces at most a leading and a trailing frame per second. - It counts every loss (
cariosan_realtime_events_dropped_total, see monitoring).
Delivery is still at-most-once: a channel's clients on other servers cannot be told when the Redis bus itself is unreachable, and they recover on their next reconnect. If you need guaranteed delivery of every event to a backend, use webhooks, which retry.
@cariosan/react and @cariosan/react-native do all of this inside useMessages. If you build your own client, do the same on every connected and resync:
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. A message you send goes through these states:
| State | Meaning | Shown as |
|---|---|---|
| Sending | The server has not answered yet. Exists only on your device (the outbox). | 🕒 |
| Failed | The send was rejected or never got an answer. Exists only on your device, with Retry and Delete. | red ! |
| ✓ sent | The server stored it, but the other members have not received it yet (they are offline, or the live frame did not reach them). | ✓ |
| ✓✓ delivered | Every other member's last_delivered_at ≥ the message's created_at. | ✓✓ grey |
| ✓✓ read | Every other member's last_read_at ≥ the message's created_at. | ✓✓ blue |
A message that stays at ✓ is stored and will reach the recipient the next time they connect (their client catches up from history); it is not lost. Sending and Failed are local states: the server never reports them.
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). Whilehas_moreis true the response carriesnext_page_token; send it back as?page_token=(sameq) for the next matches. The token is an exact position in the ranked list, so every match is returned exactly once, best first, across all pages, including matches that share a timestamp or a rank. It is opaque (do not parse it), tied to the query and scope that produced it (a token for a different query, or for per-channel versus cross-channel search, is rejected with400), and the last page carries none. A message edited or newly matching while you page may appear above your position and so not show up on later pages. beforeis not supported on search — the old?before=<RFC3339>window re-ranked the older rows instead of continuing the list, so it could skip and repeat matches. It now returns400 VALIDATION_ERRORinstead of being silently ignored, and search responses no longer carrynext_cursor. Usepage_token.
REST: GET /v1/channels/:external_id/messages/search?q=… and GET /v1/search/messages?q=…. In @cariosan/react, the useSearch(channelId?) hook wraps both, including loadMore (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.
Private attachment downloads
public_url returned by upload presigning and attachment_url on a message are stable references. They no longer authorize anonymous reads. Request a fresh download grant using the message ID and channel external ID:
Response: { "url": "https://storage.example/...signed...", "expires_at": "..." }.
Only current members of the matching workspace and channel can request a grant. Deleted messages are unavailable. Grants expire after five minutes and remain usable until expiry even if membership is later revoked. Do not persist signed URLs or include them in analytics. Refresh a grant after expiry.
Upload references are restricted to the authenticated uploader's workspace/user prefix. Reusing another user's raw object reference as a new attachment is rejected. Existing messages retain their references and can be resolved through the authorized endpoint while the configured bucket and URL base remain unchanged.
Was this page helpful?