livqenoDocs

ChatMessages

Messages

Send, receive, edit, and delete messages, without duplicates.

A message is one chat post in a conversation: text, a file, or a system notice. This page shows how to send, receive, edit, and delete messages, and how Livqeno makes sure each message is saved once and seen in the same order by everyone.

Where this fits: your app has a chat token and has connected to a conversation (see the Chat quickstart). Everything on this page runs in your app, unless it says "backend".

Sending

In your app:

const message = await chat.sendMessage({ text: 'Hello everyone!' });
// { id: 'msg_3xR…', roomId: 'conv_…', senderId: 'alice',
//   text: 'Hello everyone!', createdAt: '2026-08-18T12:00:00.000Z', ... }

The call finishes after the message is saved. The id and createdAt come from the server, not the device clock, so every user sees the same order even if their clocks differ.

If the connection is down when you send, the SDK sends over HTTP instead. The message is still saved and delivered to everyone else, so nothing the user typed is lost.

OptionWhat it does
textThe message text. Required for text messages.
typetext (default) or attachment. system and event are backend only.
replyToA msg_... id. The reply goes into that message's thread. See Threads.
clientMessageIdStops duplicates. See Idempotency.
attachmentIdAn att_... id from a finished upload. See Attachments.
metadataYour own JSON, up to 4 KB. For example, a product id to show a product card.
roomThe conversation to send to. Defaults to the first one you joined in connect().

The sender is never yours to choose

  • In the app (chat token): you can't set senderId. If you pass it, it's ignored without an error. The sender is always the token's user.
  • On your backend (API key): senderId is required. This is how your backend posts for a user (for example, an automated "Your order shipped" message), and why the API key must never reach a browser.

Idempotency

Idempotent means "doing it twice has the same effect as doing it once". Retries, reconnects, and double-clicks can send the same message twice. clientMessageId stops that:

await chat.sendMessage({ text: 'Hello', clientMessageId: 'client_123' });
await chat.sendMessage({ text: 'Hello', clientMessageId: 'client_123' });
// Same message. The second call returns the original, with deduplicated: true.
  • @livqeno/chat adds one for you, so the SDK's own retries are safe.
  • Pass your own when you control the retry: a job queue, a form resend, or an offline outbox. Generate the id once, when the user presses Send, and reuse it on every retry.
  • It's checked permanently (per conversation and sender), not for a few minutes. A retry an hour later still won't duplicate.

Free tier

Every account gets 100,000 free messages. This is separate from RTC minutes and Live Streaming host-hours.

  • A message counts once, when it's saved. Not once per person who gets it. A message to a 50-person channel counts as one.
  • A duplicate that's caught (see above) doesn't count.
  • Typing, presence, read receipts, and reactions never count.

See Usage for details and what happens when it runs out.

Receiving

New messages arrive as events while the app is connected. Listen once when the chat screen opens, and stop listening when it closes.

In your app:

const unsubscribe = chat.on('message', (message) => {
  console.log(`${message.senderId}: ${message.text}`);
});
 
unsubscribe(); // call this in your cleanup

on() returns a function that stops listening. Call it in your cleanup. off() also works.

You get your own messages back too. This is on purpose. The sender shows the same server copy as everyone else, so there's nothing to merge and no risk of the sender seeing a different order.

Events only cover what happens while connected. To show messages from before the user opened the chat, load Message history.

All events: message, messageUpdated, messageDeleted, reactionAdded, reactionRemoved, typing, presence, read, connectionStateChanged, connected, disconnected, reconnecting, reconnected, recovered, error. Details in Chat Events.

Editing and deleting

Let users fix a typo or take back a message. In your app:

await chat.messages.update('msg_3xR…', { text: 'Updated message' });
// { ..., text: 'Updated message', edited: true, editedAt: '...' }
 
await chat.messages.delete('msg_3xR…');

React Native uses the same chat.messages.update() and chat.messages.delete() as web.

  • Editing sets edited: true and editedAt. Other users get a messageUpdated event. Show an "(edited)" label, so edits are never hidden.
  • Only the author can edit. Moderators can delete, but not edit, so nobody can put words in someone else's mouth.
  • Deleting is soft. The message stays, with deletedAt set. Other users get a messageDeleted event. Show "This message was deleted" in its place.

Mentions

Pass the user ids a message mentions. Mentioned users get a push even when they've set notifications to mentions, and the inbox counts them in unreadMentions. How @Alice looks in the text is up to you; the server doesn't parse names out of it.

await chat.sendMessage({ text: '@alice can you look?', mentions: ['alice'] });
 
chat.on('message', (m) => {
  if (m.mentions?.includes(chat.userId)) highlight(m);
});

At most 50 mentions per message; more is refused before anything is sent.

When a message contains an http(s) link, the server fetches the first one and attaches a preview: message.linkPreview is { url, title?, description?, imageUrl?, siteName? }, or null.

The preview is added after the message is sent, so it arrives as a messageUpdated event with reason: 'link_preview'. Use the reason to tell it apart from an edit, so you don't show "(edited)":

chat.on('messageUpdated', (message, { reason }) => {
  replaceMessage(message);
  if (reason !== 'link_preview') markEdited(message.id);
});

On Flutter the message has linkPreview too, and the update arrives on messageUpdates with the same reason.

Pinned messages

Moderators and admins can pin messages to a conversation. Everyone gets a messagePinned / messageUnpinned event with { roomId, messageId, message, by, at }.

await chat.messages.pin('msg_3xR…');
await chat.messages.unpin('msg_3xR…');
const pinned = await chat.messages.listPins(); // ChatMessage[]
 
chat.on('messagePinned', ({ messageId }) => markPinned(messageId));

Search one conversation's messages (members only). Matches whole words, newest first:

const { data, nextCursor, hasMore } = await chat.messages.search({ q: 'invoice', limit: 20 });
const more = nextCursor ? await chat.messages.search({ q: 'invoice', cursor: nextCursor }) : null;

Search finds words, not parts of words: deplo doesn't match "deploy". At most 50 results per page.

Fetching a single message

Get one message by its id. Use this when you only need that one message — for example, to show a small preview of the message someone replied to, or to open a link that jumps straight to one message:

const message = await chat.messages.get('msg_3xR…');

Ordering with seq

Every message has a seq: its position in the conversation, increasing with no gaps. It's a string (it can outgrow a JavaScript number), so sort with compareSeq() from @livqeno/chat. See Offline & sync.

Common errors

ProblemCauseFix
NOT_IN_ROOM on sendYou didn't join a conversation in connect(), and didn't pass room.Pass { room } to connect() or to sendMessage().
senderId ignored when sending from the appOnly an API key can set it.This is expected. See The sender is never yours to choose.
The same message appears twice after a retryEach retry used a new clientMessageId.Make the id once per message and reuse it.
Edit fails with a permission errorOnly the author can edit.There's no moderator edit. Delete it, and ask them to send again.
Delete fails with a permission errorDeleting someone else's message needs chat:moderate.See Moderation.

Production tips

  • Pass clientMessageId whenever you retry (job queue, form resend). The SDK's automatic one only covers its own retries.
  • Skip "optimistic" local messages. The server echo is fast, and you avoid merging two copies.
  • Always listen for error as well as message. A refused message never arrives as a message event.
  • Sends that fail because the connection dropped are queued and resent for you. See Offline & sync.

Next steps