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', ... }const { send } = useMessages();
await send('Hello everyone!', { replyTo: 'msg_3xR…' }); // replyTo optionalawait livqeno.chat!.send('Hello everyone!'); // convenience for sendMessage({ text })
await livqeno.chat!.sendMessage({ text: 'Hello', clientMessageId: 'client_123' }); // full optionsawait chat.send('Hello everyone!');
final message = await chat.send('Hello', clientMessageId: 'client_123', replyTo: 'msg_3xR…');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.
| Option | What it does |
|---|---|
text | The message text. Required for text messages. |
type | text (default) or attachment. system and event are backend only. |
replyTo | A msg_... id. The reply goes into that message's thread. See Threads. |
clientMessageId | Stops duplicates. See Idempotency. |
attachmentId | An att_... id from a finished upload. See Attachments. |
metadata | Your own JSON, up to 4 KB. For example, a product id to show a product card. |
room | The 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):
senderIdis 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/chatadds 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 cleanupon() returns a function that stops listening. Call it in your cleanup.
off() also works.
const { messages } = useMessages(); // oldest-first — render order, kept in sync for youNothing to subscribe to. The hook updates as messages arrive, and cleans up when the component unmounts.
const unsubscribe = livqeno.chat!.on('message', (message) => {
console.log(`${message.senderId}: ${message.text}`);
});Same API as web. livqeno.chat is a ChatClient.
chat.messages.listen((message) {
print('${message.senderId}: ${message.text}');
});This is a Stream<LivqenoMessage>. Cancel the subscription in your widget's
dispose().
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…');await chat.edit(messageId, 'Updated message');
await chat.delete(messageId);React Native uses the same chat.messages.update() and
chat.messages.delete() as web.
- Editing sets
edited: trueandeditedAt. Other users get amessageUpdatedevent. 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
deletedAtset. Other users get amessageDeletedevent. 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);
});await chat.send('@alice can you look?', mentions: ['alice']);At most 50 mentions per message; more is refused before anything is sent.
Link previews
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));await chat.pin(messageId);
await chat.unpin(messageId);
final pinned = await chat.listPins();Search
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;final results = await chat.search('invoice', limit: 20);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
| Problem | Cause | Fix |
|---|---|---|
NOT_IN_ROOM on send | You 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 app | Only an API key can set it. | This is expected. See The sender is never yours to choose. |
| The same message appears twice after a retry | Each retry used a new clientMessageId. | Make the id once per message and reuse it. |
| Edit fails with a permission error | Only the author can edit. | There's no moderator edit. Delete it, and ask them to send again. |
| Delete fails with a permission error | Deleting someone else's message needs chat:moderate. | See Moderation. |
Production tips
- Pass
clientMessageIdwhenever 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
erroras well asmessage. A refused message never arrives as amessageevent. - Sends that fail because the connection dropped are queued and resent for you. See Offline & sync.
Next steps
- Message history: load older messages when the chat opens.
- Threads: replies with
replyTo. - Delivery & Read Receipts: what "delivered" and "seen" mean.