In this guide you'll build a chat channel step by step, like a support chat between a customer and an agent, or a team channel.
Where this fits: project → API key → your backend makes a chat token → your app connects. Chat is separate from calls: it has its own token and its own connection, so you can build it with or without video.
Two words used throughout:
- A conversation is where messages live. Users must be members of a conversation to read or send in it.
- A chat token is a short-lived pass your backend makes for one user. It lists which conversations the user may use and what they may do there.
What you'll build
- Messages that are still there after a reload.
- A member list showing who is online.
- "Someone is typing…" indicators.
- Older messages that load as you scroll up.
What you need
- A project and an API key. See API credentials.
- A backend with
@livqeno/serverinstalled. @livqeno/chatinstalled in your app with npm, pnpm, yarn or bun (add@livqeno/reactif you want hooks).
Steps
1. Create a conversation on your backend
On your backend, create the conversation and add its members. The app can't do either. If it could, any user could add themselves to any conversation and read it. In a support app, you'd do this when a customer opens a ticket:
import { Livqeno } from '@livqeno/server';
const livqeno = new Livqeno({
apiKey: process.env.LIVQENO_API_KEY!,
baseUrl: process.env.LIVQENO_API_URL!, // https://api.livqeno.com
});
const conversation = await livqeno.chat.createConversation({
name: 'support-room-42',
getOrCreate: true, // safe to re-run: returns the existing one (members apply only on first create)
type: 'CHANNEL',
members: [
{ userId: 'alice', role: 'ADMIN' },
{ userId: 'bob' },
],
});type: 'CHANNEL' is a group conversation. Alice is an ADMIN, so she can
manage the conversation; Bob is a regular member.
2. Make a chat token
On your backend, add an endpoint that makes a chat token for the logged-in user. The app calls it before connecting:
app.post('/chat-token', async (req, res) => {
const token = await livqeno.chat.createToken({
userId: req.user.id, // from YOUR session, never the request body
conversations: [conversation.publicId],
scopes: ['chat:read', 'chat:send'],
expiresIn: 3600,
});
res.json(token); // { token, chatUrl, apiUrl, conversations, expiresAt, ... }
});The scopes say what this token may do: here, read and send. They can only
narrow what the user's role allows, never widen it.
3. Connect and send a message
In your app, get the token from your endpoint, connect, and send. Every
message from anyone in the conversation (including your own) arrives through
the message event:
import { createChatClient } from '@livqeno/chat';
const minted = await fetch('/chat-token', { method: 'POST' }).then((r) => r.json());
const chat = createChatClient({
token: minted.token,
apiUrl: minted.apiUrl,
chatUrl: minted.chatUrl,
onTokenExpiring: async () => {
const fresh = await fetch('/chat-token', { method: 'POST' }).then((r) => r.json());
return fresh.token;
},
});
await chat.connect({ room: minted.conversations[0] });
chat.on('message', (message) => appendToUi(message));
await chat.sendMessage({
text: 'Hello everyone',
clientMessageId: crypto.randomUUID(),
});'use client';
import { LivqenoChat, useMessages, useTyping, usePresence } from '@livqeno/react/chat';
function Panel({ minted }) {
return (
<LivqenoChat token={minted.token} apiUrl={minted.apiUrl} room={minted.conversations[0]}>
<Messages />
<Composer />
</LivqenoChat>
);
}
function Messages() {
const { messages, loadMore, hasMore } = useMessages();
return (
<>
{hasMore && <button onClick={loadMore}>Load earlier</button>}
{messages.map((m) => (
<p key={m.id}>
<b>{m.senderId}</b> {m.text}
</p>
))}
</>
);
}final chat = LivqenoChat(token: minted.token, apiUrl: minted.apiUrl);
await chat.connect(minted.conversations.first);
chat.messages.listen((m) => setState(() => _messages.add(m)));
await chat.send('Hello everyone');You should now see "Hello everyone" arrive in your message handler. Open
a second tab as a different user and it appears there too.
4. Load older messages
When someone opens a conversation, show the latest messages, then load older ones as they scroll up. History comes in pages: you ask for 50, and get a cursor (a bookmark) to ask for the 50 before those:
const page = await chat.messages.list({ limit: 50 });
// newest first
const older = await chat.messages.list({ before: page.nextCursor, limit: 50 });- Messages come newest first.
- Pass
page.nextCursorto get the next older page. - Don't build or read cursors yourself. A bad cursor returns
INVALID_CURSORinstead of quietly starting over, so retrying is safe.
5. Add typing and online status
"Bob is typing…" and a green "online" dot make chat feel alive. In your app, send typing signals as the user types, and listen for other people's typing and presence (online status):
composer.addEventListener('input', () => void chat.startTyping());
composer.addEventListener('blur', () => void chat.stopTyping());
chat.on('typing', (e) => showTyping(e.userId, e.isTyping));
chat.on('presence', (e) => setOnline(e.userId, e.status === 'online'));
await chat.setPresence('online');Both are temporary. They are not saved, and they expire on their own:
| Expires after | |
|---|---|
| Typing | 7 seconds |
| Presence (online) | 45 seconds |
Just call startTyping() on each input and let it expire. You don't need
your own timer.
How it works
- The server decides message order. When
sendMessage()finishes, the message is saved. The sender also gets themessageevent, like everyone else. Draw messages from the event, not fromsendMessage()'s result. Then everyone sees the same order, even if their clocks differ. - A message may arrive more than once, so send an id. On a bad network,
the app may not know whether a send got through, so it retries. Livqeno
ignores repeats with the same
clientMessageIdfrom the same sender in the same conversation, so a retry never posts the message twice. If a retry was a repeat, the result hasdeduplicated: true. This is called being idempotent: doing it twice has the same effect as doing it once. - Two checks decide what a user can do: the token's scopes and the user's role in the conversation. The token can only narrow the role. See Permissions.
- "Offline" means presence expired. There's no "went offline" message. If a user's heartbeat stops for 45 seconds, they show as offline. This also works when a browser crashes.
Production tips
- Refresh tokens. Set
onTokenExpiring, or chat stops after an hour. This is the most common chat bug. - Expect duplicates in your own database too. If you also save messages from webhooks, skip ones whose message id you already have.
- Limits: text up to 4000 characters, metadata up to 4096 bytes. Check these in your text box so users know before they press send.
- Attachments need file storage set up. Without it, the API returns
RAVEN_NOT_CONFIGURED. Check Known limitations before you promise attachments. - Add your site to Allowed Origins (Settings → Security → Allowed
Origins) before launch. Once the list has entries, a page from any other
site is rejected with close code
4403.localhostworks by default. See Browser security & CORS. - Choose how long to keep messages. By default they're kept forever. You can set retention per conversation; see Conversations.
Next steps
- Handle webhooks: let your backend react to new messages, for example to send push notifications.
- Chat overview · Messages · Chat events
- WebSocket protocol: if you need to write your own client.