Cariosan

Messages

The unit of conversation — text, images, or system events.

8 min readUpdated May 31, 2026

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 to CARIOS_MAX_MESSAGE_SIZE_BYTES (8 KiB default).
  • image — an attachment uploaded via presigned S3 URL. The content field still holds the caption.
  • system — server-generated notifications. Reserved for future features.

Sending

send.ts
await channel.sendMessage({ text: "Halo!" });
 
// With an attachment:
const { publicUrl } = await channel.uploadAttachment(fileBlob);
await channel.sendMessage({ text: "", attachmentUrl: publicUrl });

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_URL so 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 returns 429 with a Retry-After header.
  • Availability — if a dependency the send path needs (Redis) is unreachable, the server returns 503 SERVICE_UNAVAILABLE with a Retry-After header. Nothing was stored. Show the message as unsent (with a retry action) rather than as delivered.
  • Over the WebSocket, a message.send frame gets the same answers as error frames: RATE_LIMITED, QUOTA_EXCEEDED (with details) and SERVICE_UNAVAILABLE, each with retry_after_seconds in place of the header. See error codes.
  • Idempotency — send a client_message_id and 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.

retry.sh
KEY=$(uuidgen)   # once per message, reused on every retry of it
curl -s -i -X POST "$CARIOSAN_URL/v1/channels/order_123/messages" \
  -H "Authorization: Bearer $USER_JWT" -H "Content-Type: application/json" \
  -d "{\"content\":\"Halo!\",\"client_message_id\":\"$KEY\"}"
# first call  -> 201 Created
# same call   -> 200 OK, Idempotent-Replayed: true, the SAME message id
  • The first request creates the message (201). Any later request with the same key returns the stored message with 200 and Idempotent-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 is 400.
  • 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_at set 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.send frame whose client_message_id is a UUID is deduplicated exactly like the REST call: the retry's message.ack names the stored message and carries "replayed": true. A non-UUID client_message_id on the WebSocket is only echoed back.
  • The key comes back. Messages that were sent with a key carry it as client_message_id in the send response, the message.new frame, 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

history.ts
const page = await channel.getMessages({ limit: 50 });
// page.messages        — newest first
// page.has_more        — true if an older page exists
// page.next_cursor     — created_at of the last message in this page
// page.next_cursor_id  — id of the last message in this page
 
// Load the next older page: pass BOTH halves of the cursor.
const older = await channel.getMessages({
  limit: 50,
  before: page.next_cursor ?? undefined,
  beforeId: page.next_cursor_id ?? undefined,
});

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.resync frame 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:

catch-up.ts
import type { Channel, Message } from "@cariosan/client";
 
async function catchUp(channel: Channel, held: Message[]): Promise<Message[]> {
  const oldest = held[0];
  const collected: Message[] = [];
  let cursor: { before?: string; beforeId?: string } = {};
 
  // Page back from the newest message until we reach what we already hold
  // (cap the pages: past that, reset to the newest window instead).
  for (let page = 0; page < 10; page++) {
    const res = await channel.getMessages(cursor);
    collected.push(...res.messages);
    if (!res.has_more || !res.next_cursor) break;
    if (oldest && collected.some((m) => m.id === oldest.id)) break;
    cursor = { before: res.next_cursor, beforeId: res.next_cursor_id ?? undefined };
  }
 
  // Server copies win by id (edits, unsends and reactions made while you were
  // away converge); keep live messages newer than the snapshot.
  const fresh = new Set(collected.map((m) => m.id));
  const newest = collected[0];
  const live = held.filter((m) => !fresh.has(m.id) && (!newest || Date.parse(m.created_at) >= Date.parse(newest.created_at)));
  return [...collected.reverse(), ...live];
}
 
channel.on("resync", () => void catchUp(channel, messages).then(setMessages));
client.on("connected", () => void catchUp(channel, messages).then(setMessages));

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.

reactions.ts
await channel.addReaction(messageId, "👍");
await channel.removeReaction(messageId, "👍");
 
// Live updates for every member:
channel.on("reaction.add", ({ message_id, user_id, emoji }) => { /* … */ });
channel.on("reaction.remove", ({ message_id, user_id, emoji }) => { /* … */ });

Each message in history carries its aggregated reactions:

{
  "id": "…",
  "content": "Halo!",
  "reactions": [{ "emoji": "👍", "count": 3, "reacted_by_me": true }]
}

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.

edit.ts
await channel.editMessage(messageId, "fixed a typo");
channel.on("message.updated", ({ id, content, edited_at }) => { /* … */ });
  • 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.

unsend.ts
await channel.deleteMessage(messageId);
channel.on("message.deleted", ({ id, deleted_at }) => { /* render a tombstone */ });
  • Author-only (channel-admin moderation arrives with the moderation phase).
  • Idempotent; optional unsend window (422 UNSEND_WINDOW_EXPIRED past it).
  • History still returns the row with blanked content and deleted_at set — 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.

reply.ts
const original = await channel.sendMessage({ text: "Is the order shipped?" });
 
await channel.sendMessage({
  text: "Yes — tracking is on the way.",
  parentId: original.id,
});

Every reply carries parent_id plus a quoted_message preview — on the send response, in history, and on the live message.new event:

{
  "id": "…",
  "content": "Yes — tracking is on the way.",
  "parent_id": "01HX…",              // the quoted message's id
  "quoted_message": {
    "id": "01HX…",
    "user_id": "…",
    "user_name": "Andi",
    "content": "Is the order shipped?",  // truncated to 180 characters
    "type": "text",
    "deleted": false                     // true once the quoted message is unsent
  }
}
  • An unknown parentId, or one belonging to a different channel, is rejected with 400 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's MessageList renders the quote above the reply automatically; the message.created webhook also carries parent_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.

mention.ts
await channel.sendMessage({
  text: "Can you take this one, @Andi?",
  mentions: ["user_andi"], // external_ids of channel members
});

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:

{
  "id": "…",
  "content": "Can you take this one, @Andi?",
  "mentions": [
    { "user_id": "…", "external_id": "user_andi", "name": "Andi" }
  ]
}

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/channels returns mention_unread_count per channel alongside unread_count, so you can badge channels with unread mentions on load.
  • @cariosan/react's MessageInput ships an @ autocomplete over channel members; MessageList highlights 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.ts
await channel.markRead();
// Sets `last_read_at` to now.

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:

StateMeaningShown as
SendingThe server has not answered yet. Exists only on your device (the outbox).🕒
FailedThe send was rejected or never got an answer. Exists only on your device, with Retry and Delete.red !
✓ sentThe server stored it, but the other members have not received it yet (they are offline, or the live frame did not reach them).✓
✓✓ deliveredEvery other member's last_delivered_at ≥ the message's created_at.✓✓ grey
✓✓ readEvery 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:

receipts.ts
await channel.getReceipts();              // seed member cursors once
channel.on("receipt.update", () => {
  const state = channel.receiptState(message); // "sent" | "delivered" | "read"
});

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.

Full-text search over message content, ranked by relevance. Search within one channel, or across every channel the caller belongs to:

search.ts
// Within a channel:
const page = await channel.searchMessages("brown fox", { limit: 20 });
 
// Across all your channels:
const all = await client.searchMessages("brown fox");
// page.messages        — ranked by relevance, then recency; same shape as history
// page.has_more        — more matches exist
// page.next_page_token — opaque; pass it back to get the next matches
 
// Next matches (same query):
const more = await channel.searchMessages("brown fox", {
  limit: 20,
  pageToken: page.next_page_token ?? undefined,
});

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). While has_more is true the response carries next_page_token; send it back as ?page_token= (same q) 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 with 400), 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.
  • before is 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 returns 400 VALIDATION_ERROR instead of being silently ignored, and search responses no longer carry next_cursor. Use page_token.
search.sh
curl -s "$CARIOSAN_URL/v1/channels/order_123/messages/search?q=brown%20fox&limit=20" \
  -H "Authorization: Bearer $USER_JWT"
# -> { "messages": [...], "has_more": true, "next_page_token": "eyJ2IjoxLC…" }
 
curl -s "$CARIOSAN_URL/v1/channels/order_123/messages/search?q=brown%20fox&limit=20&page_token=eyJ2IjoxLC…" \
  -H "Authorization: Bearer $USER_JWT"

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.

upload.ts
const up = await channel.uploadAttachment(file);
// up.publicUrl — ready to pass to sendMessage
await channel.sendMessage({ text: "", attachmentUrl: up.publicUrl });

Under the hood:

  1. Client POSTs metadata (filename, mime, size) to /v1/attachments/presign.
  2. Server returns a short-lived (15 min) presigned URL plus the public URL.
  3. Client PUTs the file body directly to S3.
  4. 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:

curl -X POST "$CARIOSAN_URL/v1/channels/team_1/messages/$MESSAGE_ID/attachment" \
  -H "Authorization: Bearer $USER_TOKEN"

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.

const grant = await client.channel("team_1").getAttachmentDownload(message.id);
// Use grant.url as the temporary image src or download href.
// Retain message.attachment_url as the stable reference, never grant.url.

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?

On this page