@convokitapp/sdk
v1.1.0
Published
Official JavaScript and TypeScript SDK for ConvoKit realtime chat.
Readme
ConvoKit JavaScript SDK
The official JavaScript and TypeScript SDK for ConvoKit realtime chat. It works in modern browsers and Node.js 18+, ships ESM and CommonJS builds, and includes first-class TypeScript declarations.
Install
npm install @convokitapp/sdkLive inbox and send correlation (0.4+)
These features shipped in 0.4.0 and need the matching backend deployment.
client.realtime.onInboxChanged(client.clientId, handler)requests an authorized inbox refresh after room/membership/profile/cascade changes and every verified app-channel join/rejoin. The signal contains no room data. Since 0.6,onInboxActivitycovers message and read-position activity; see Inbox.Message.clientMessageIdidentifies one logical send across REST, history and live rows.sendMessage()generates it when omitted. For a custom optimistic UI, callcreateClientMessageId()before inserting the pending bubble and pass that value tosendMessage({ conversationId, text, clientMessageId }). Match the room and sender as well; never match by text.- Reuse an ID only for retries of that same send. The backend returns the existing message for an identical retry, or 409 for conflicting content. Network failures still do not trigger automatic mutation retries.
The paired UI packages handle these mechanics automatically through their default adapters. Base-SDK consumers still own their application's view state.
Optional UI packages
Build directly on the SDK or add the framework-native UI layer for your web application. Both UI packages include SDK-backed and controlled components, realtime state, pagination, media, read receipts, theming, and deep replacement points while keeping the client secret on your backend.
| Framework | Package | Documentation | Runnable examples |
| --- | --- | --- | --- |
| React | @convokitapp/react-ui | React UI guide | Public React examples |
| Vue 3 | @convokitapp/vue-ui | Vue UI guide | Public Vue examples |
Client setup
Create one client for each ConvoKit application. The token provider calls your own backend; the ConvoKit client secret must never be included in browser code.
import { ConvoKitClient } from '@convokitapp/sdk'
const chat = new ConvoKitClient({
clientId: 'your-public-client-id',
tokenProvider: async (appUserId) => {
const response = await fetch('/api/chat/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appUserId }),
})
if (!response.ok) throw new Error('Could not issue chat token')
return (await response.json()).token
},
})
await chat.connectUser(currentUser.id)Session renewal (0.3+)
The token provider is called at sign-in and again to renew the session. It must return a fresh user token for the requested user, after authenticating that user in your own application. Do not cache and return an expired token. The SDK discovers the Supabase URL and publishable key automatically; never configure Supabase credentials in your application or return a secret key to the client.
The SDK renews before the earlier user/Realtime JWT expiry (up to five minutes early) and updates existing private subscriptions in place. Concurrent renewal requests share one token exchange. A backend request receiving HTTP 401 can renew and retry once; network failures, 403s and server errors are not automatically retried. Storage uploads are never replayed with ConvoKit tokens.
Transient background renewal failures retry with bounded backoff while the
current tokens remain valid. If renewal is explicitly denied or the session
expires, the SDK clears its credentials and closes its channels. Observe the
durable sessionState snapshot for authentication UI and subscribe to changes
with subscribeSession; requiresReauthentication distinguishes terminal
authentication loss from failures that only require reconnecting:
const unsubscribeSession = chat.subscribeSession(() => {
const state = chat.sessionState
if (state.status === 'disconnected' && state.requiresReauthentication) {
showSignIn()
}
})Each connection attempt has a new sessionId, including reconnection as the
same user. Snapshot references remain stable between transitions, making this
API suitable for external-store bindings. Realtime channel joins and temporary
interruptions remain available separately through onConnectionEvent.
Use onRealtimeError for sanitized diagnostics (SESSION_REFRESH_FAILED,
SESSION_EXPIRED, or SESSION_CLOSE_FAILED); transient refresh failures do not
change sessionState while the current credentials remain valid.
Call disconnectUser() on logout. Pending operations from a previous sign-in
cannot restore that session or continue under a new user; they fail with
SESSION_CHANGED. Obtain chat.realtime again after reconnecting instead of
reusing a disposed Realtime object. Reconnection is also required if the
discovered Supabase project/key configuration changes.
Conversations and messages
const conversation = await chat.createConversation({
participants: [currentUser.id, teammate.id],
title: 'Launch room',
})
await chat.sendMessage({
conversationId: conversation.id,
text: 'The release is ready.',
})
const conversations = await chat.getConversations({ limit: 25 })
const messagePage = await chat.listMessages({
conversationId: conversation.id,
limit: 20,
})
const olderMessages = messagePage.nextCursor === null
? null
: await chat.listMessages({
conversationId: conversation.id,
limit: 20,
cursor: messagePage.nextCursor,
})Conversation operations also include getConversation, leaveConversation,
archiveConversation, unarchiveConversation, updateConversationTitle,
markConversationRead, markConversationUnread, and clearConversationUnread.
User and message lookup are available through getUsers, getUser, and
getMessage; a user's own messages can be changed with editMessage and
deleteMessage (see Edit and delete your own messages).
Quoting a message and navigating to one use getReplyPreviews and
getMessageContext (see
Quoted replies and jump to message).
For an activity-ordered list with previews and unread counts, see
Inbox; for the private unread marker, see Mark unread.
listMessages() returns newest-first pages and an opaque nextCursor. Pass
that cursor back to load older history until it is null. limit controls the
page size; it does not identify the page. The cursor remains valid if its
message is deleted. The older getMessages() array API remains available for
compatibility, but new integrations should use listMessages() so inbox and
message history follow the same paging contract. listMessages() requires the
coordinated backend release that returns nextCursor.
Edit and delete your own messages
Since 0.8, a user can change the text of a message they sent, or delete it.
These are the author operations, editMessage and deleteMessage on
ConvoKitClient; the administrative ConvoKitServerClient.updateMessage and
deleteMessage keep their semantics (see
Server-only operations). Both author calls require
the 0.8 backend: against an older backend they match no route and fail with
status 404 and HTTP_ERROR (an HTML body, no JSON code). Never treat that
404 as a deleted message; only a 404 whose code is MESSAGE_NOT_FOUND means
the message is inaccessible or gone.
import { ConvoKitError, isEditedMessage } from '@convokitapp/sdk'
// Edit with the revision of the row as the user saw it.
try {
const edited = await chat.editMessage(message.id, {
text: 'The release is ready on Tuesday.',
revision: message.revision,
})
render(edited, isEditedMessage(edited)) // true: revision advanced
} catch (error) {
if (error instanceof ConvoKitError && error.code === 'REVISION_CONFLICT') {
// Another device or an administrator changed it first: show the current
// text and retry with its revision.
const current = await chat.getMessage(message.id)
}
}
// Clear the caption of a message with attachments; the attachments stay.
await chat.editMessage(message.id, { text: null, revision: message.revision })
// Delete for every member; other devices learn through onMessageDeleted.
await chat.deleteMessage(message.id)Every Message carries revision: 0 when sent and +1 on every edit, whether
by the author or by an administrator (media-only administrative edits count).
isEditedMessage(message) (revision > 0) is the only "edited" signal; never
derive it from updatedAt, which is set on creation and by other writes. A
0.7 backend sends no revision and the parser defaults it to 0; a present
value that is not a non-negative integer rejects with a TypeError.
Message.revision is a required member, so consumer-built literals (fixtures,
controlled views) must add it.
editMessage(messageId, { text, revision }) sends both keys on every call
(text: null is serialised as JSON null, never omitted). text must be a
string or null and revision an integer in 0..2147483647; anything else,
including a missing text, rejects with INVALID_ARGUMENT before any request.
The backend trims the text and treats an empty string as null; null is
accepted only when the message has at least one attachment (caption clearing),
otherwise the edit fails with status 400, the message A message must have
text or at least one media item and no backend code, so ConvoKitError.code
is HTTP_ERROR. An author edit never changes the attachments. The returned
row carries the new text, revision + 1 and the unchanged media. Every room
subscriber receives an update row image with the same text and revision but
no media (a Postgres row image has no attachment rows, so it parses with
media: []); keep the attachments you already hold for that id rather than
replacing them. onInboxActivity fires.
Failures: status 409 with code === 'REVISION_CONFLICT' when the stored
revision no longer matches (nothing was written; reload the row with
getMessage(), show the current content and retry with its revision); 403
for another member's message or a READ role; 404 with code ===
'MESSAGE_NOT_FOUND' for a message the caller cannot see, whether unknown,
deleted, in another app or in a room the caller is not a member of.
deleteMessage(messageId) resolves with no value and is unconditional (no
revision is compared). The message and its attachments leave the conversation
for every member and the deletion cannot be undone: onMessageDeleted fires
for the room and onInboxChanged for the app, and the inbox preview moves to
the previous surviving message. Files already received or downloaded by other
members cannot be retracted, and stored files are reclaimed by the existing
user or app deletion cleanup, not by the message deletion. An already-deleted
message answers 404 MESSAGE_NOT_FOUND.
If you merge rows from several sources (REST responses, history pages and live
update row images), apply the precedence rule the paired UI packages use:
when both rows carry a usable revision (both present and at least one greater
than 0), the higher revision wins and a lower one never overwrites it; on
equal revisions, or when neither is usable (a 0.7 backend, pending rows), fall
back to updatedAt ?? createdAt, and on a tie there keep the more complete
row: the edit response and its update row image share one revision and one
updatedAt, so a row image with media: [] must never overwrite a row that
has attachments. Keep a deletion marker so that a late edit response or row
image cannot restore a deleted message.
Quoted replies and jump to message
Since 0.9, a message can quote another message in the same room and a client
can load a window of history around any message. Both need the 0.9 backend:
against an older one getReplyPreviews and getMessageContext match no route
and fail with status 404 and HTTP_ERROR (an HTML body, no JSON code).
Never read that as "the message is gone" or as an empty window; only a 404
whose code is MESSAGE_NOT_FOUND means the message is inaccessible.
// Quote a message. The key is omitted entirely when you do not pass it.
const reply = await chat.sendMessage({
conversationId: conversation.id,
text: 'Agreed, Tuesday works.',
replyToMessageId: original.id,
})
// Resolve the quoted parents of a whole page in one round trip. A page with
// no replies yields no IDs, and an empty list rejects with INVALID_ARGUMENT,
// so skip the call instead of making it.
const ids = page.flatMap(message => message.replyToMessageId ?? [])
const previews = ids.length === 0 ? [] : await chat.getReplyPreviews(conversation.id, ids)
const quoted = new Map(previews.map(preview => [preview.id, preview]))
for (const message of page) {
if (message.replyToMessageId === undefined) continue
const preview = quoted.get(message.replyToMessageId)
// Resolved and absent: the quoted message is gone. Keep the reply.
renderQuote(preview ? preview.text : 'Original message unavailable')
}
// Jump to a message that is not in the loaded window.
const window = await chat.getMessageContext(conversation.id, {
messageId: message.replyToMessageId!,
})
render(window.messages) // newest-first, centred on the target
const older = window.olderCursor
? await chat.getMessageContext(conversation.id, { olderCursor: window.olderCursor })
: nullMessage.replyToMessageId is an OPTIONAL member: it is absent when the row is
not a reply, when the backend is older than 0.9, and on surfaces that do not
carry it. A 0.9 backend sends replyToMessageId: null on every non-reply row
and the parser maps both the missing key and the explicit null to the same
absent state, so message.replyToMessageId === undefined is the single "not a
reply" test. A present value is always a non-empty string. The reference is
write-once: an author or administrative edit never changes it, and it survives
the deletion of the quoted message — the quote degrades, the reply does not.
Because the member is optional the compiler cannot flag code that rebuilds a
Message field by field; every such site must copy it or the quote silently
disappears from that row.
sendMessage({ ..., replyToMessageId }) omits the key when it is unset, so a
send without a quote is byte-identical to 0.8. The target must be a message in
the same conversation: an unknown, deleted, cross-room or cross-app id fails
with 404 MESSAGE_NOT_FOUND, and a blank id rejects with INVALID_ARGUMENT
before any request. The backend bounds the id to 64 characters. A retry that
reuses the same clientMessageId must repeat the same target: an identical
retry returns the original row with 200 even if the quoted message has since
been deleted, while a retry with a different target fails with 409.
getReplyPreviews(conversationId, messageIds) resolves a whole page of replies
in one round trip — never one lookup per rendered row. Pass every
replyToMessageId the page renders; the SDK trims the ids, drops duplicates
keeping the first occurrence, and splits the distinct list into requests of 50,
merging the results in that order, so callers never chunk themselves. An empty
list, a blank id or an id longer than 64 characters rejects with
INVALID_ARGUMENT and issues no request at all. The chunking is invisible and
all-or-nothing: if any chunk fails the call rejects and returns no partial
result, so a failure says nothing about which ids exist. Only absence from a
RESOLVED result is the deletion signal, and it is terminal — a deleted message
cannot come back, so cache that id as unavailable and never re-request it.
Each ReplyPreview carries { id, conversationId, senderId, text,
textTruncated, createdAt, revision, mediaCount }. text is the first 500
characters of the quoted message with textTruncated set when it was cut, and
mediaCount is how many attachments it has; the attachments themselves are
not carried. appUserId is the author, the same value Message.senderId
holds. A preview is derived when it is read, so a parent edit shows up on the
next read (with the new revision) — a reply row's own revision never moves
when its parent changes, so no row-merge rule will refresh a preview for you.
getMessageContext(conversationId, options) returns one MessageContextPage
{ messages, olderCursor, newerCursor }. messages is newest-first, like
getMessages. Pass exactly one of messageId (centre the window on it),
olderCursor or newerCursor (continue from a previous window), plus an
optional limit, an integer in 1..100 that defaults to 30; zero or several
selectors, or a limit outside the range, reject with INVALID_ARGUMENT before
any request, so a caller never believes it holds a complete window. With
messageId the window is centred and as long as the room allows, even when the
target sits near an end. Both cursors are always present and nullable: null
means that side of the history is exhausted, and a null newerCursor means the
window touched the live tail when it was read. An unknown, deleted or
out-of-room messageId answers 404 MESSAGE_NOT_FOUND; a malformed or stale
cursor answers 400 INVALID_CURSOR. Cursors are opaque, so send them back
verbatim and do not parse them.
Both endpoints are conversation-scoped reads authorized on membership before
any message id is read: a non-member, a departed member or a room in another
app answers 404 Conversation not found, and a READ member may call both.
Neither ever answers 403. Ids in getReplyPreviews that do not exist, were
deleted, or belong to another room are simply absent from the result rather
than an error, so the batch never reveals whether an id exists elsewhere.
Emoji reactions
Reactions use one exact Unicode emoji sequence. 👍 and 👍🏽 are separate reactions; keep the string returned by the server when removing one. Active READ members can read summaries and reactor pages, while READ_WRITE members can add or remove their own reactions.
await chat.addReaction(messageId, '👍🏽')
const summaries = await chat.getReactionSummaries(conversationId, [messageId])
const firstPage = await chat.listReactionUsers(messageId, '👍🏽', { limit: 30 })
const nextPage = firstPage.nextCursor
? await chat.listReactionUsers(messageId, '👍🏽', { cursor: firstPage.nextCursor })
: null
await chat.removeReaction(messageId, '👍🏽')getReactionSummaries deduplicates IDs and batches at 50. The private chat.realtime.onReactionChanged(conversationId, …) event contains only the changed message ID; refetch that summary after the event and refetch visible summaries after reconnect. The cursor for listReactionUsers is opaque and tied to one message and emoji.
Inbox
Since 0.6, listInbox() returns the caller's rooms in activity order with the
data an inbox row needs, so a list never has to fetch messages per room.
getConversations() is unchanged (creation order, offset pagination).
const page = await chat.listInbox({ limit: 30 })
for (const { conversation, latestMessage, unreadCount, unreadCountCapped, activityAt } of page.entries) {
renderRow(conversation.displayTitle, latestMessage?.text, unreadCountCapped || unreadCount > 99 ? '99+' : unreadCount, activityAt)
}
// Walk older rooms with the opaque cursor until it is null.
const older = await chat.listInbox({ limit: 30, cursor: page.nextCursor })
// Archived rooms are a separate list.
const archived = await chat.listInbox({ archived: true })Each InboxEntry carries conversation (the getConversation shape with
participants bounded to 10: you, the latest sender, then members by id),
latestMessage (the newest surviving message with at most its first 4 media
items, or null for an empty room), the caller's readPosition and
lastReadAt, and activityAt, the ordering key: the newest surviving message
time, or the room's creation time when it has no messages. Edits never move a
room; deleting the newest message recalculates the preview and the key.
unreadCount counts messages from other users after your read position, using
the same (createdAt, id) rule as readThrough. It is computed over the first
1,000 messages after the position: when unreadCountCapped is true, more than
1,000 messages follow and the count is a lower bound. Your own messages never
count, so a long room you authored but never acknowledged can report 0 with
unreadCountCapped: true. Acknowledge with markConversationRead() to clear it.
limit must be an integer in 1..100 (default 30) and is validated before any
request (ConvoKitError.code === 'INVALID_ARGUMENT'). Pass nextCursor back
verbatim as cursor; a cursor stays valid after its room moves or is deleted.
The backend answers a malformed cursor with 400 INVALID_CURSOR, and offset
is not accepted. Each page is internally consistent; across pages, merge by
conversation.id (a later entry replaces an earlier one) and re-sort by
(activityAt desc, id desc). Against a backend without the inbox route,
listInbox() fails with status 404 and HTTP_ERROR.
Two private app-scope signals keep a list fresh. Neither carries room data:
const structural = chat.realtime.onInboxChanged(chat.clientId, () => refetchInbox())
const activity = chat.realtime.onInboxActivity(chat.clientId, () => scheduleRefetch())onInboxChanged fires on room, membership, profile and deletion changes and on
every verified join/rejoin (refetch immediately). onInboxActivity fires on
every message insert or edit, on every read-position advance in your app and,
since 0.7, when your own unread marker changes (mark, clear, or an
acknowledgement that clears it), for every connected client, and never on join.
It is delivered to every listener synchronously with no coalescing, so throttle
the refetch it triggers. The paired UI packages do this in their list stores.
Mark unread
Since 0.7, a user can flag a room to come back to. The marker is private: other
members, webhooks and read events never see it, and unreadCount is not
changed. It requires the 0.7 backend.
// Flag the room; the caller's inbox entry now reports isUnread: true.
const state = await chat.markConversationUnread(conversation.id)
// state: { conversationId, unreadMarkedAt: Date, privateStateVersion }
// Remove the flag without acknowledging anything.
const { cleared } = await chat.clearConversationUnread(conversation.id)Each InboxEntry carries isUnread (unreadCount > 0 || unreadCountCapped ||
unreadMarkedAt !== null), unreadMarkedAt (null while nothing is marked) and
privateStateVersion. Render the numeric badge from unreadCount as before;
when isUnread is true while the count is 0 and not capped, render a
numberless dot (accessible name "Unread"), never an invented count. A 0.6
backend sends none of the three fields; the SDK then derives isUnread from
the count and the cap, so a list keeps working across the upgrade.
privateStateVersion advances on every mark and on every effective clear, and it is how
a delayed acknowledgement stays harmless. Capture the version when a room
opens, from conversation.membership?.privateStateVersion on the
getConversation() result, and send it with every acknowledgement of that
open:
const conversation = await chat.getConversation(roomId)
const opened = conversation.membership?.privateStateVersion // undefined on a 0.6 backend
await chat.markConversationRead(roomId, {
throughMessageId: newestRendered.id,
...(opened === undefined ? {} : { privateStateVersion: opened }),
})The backend clears the marker only when the sent version still equals the
current one; an acknowledgement without a version, or with an older one, still
advances the read position but leaves the marker in place. So a "mark unread"
issued after the room was opened survives the acknowledgements that open
still sends, and the marker is only ever removed by an action the user took
afterwards. Do not re-read the version from a later refresh of the same open.
An empty room has nothing to acknowledge; send { privateStateVersion } alone
to clear its marker (in a room with messages that body still acknowledges
through the newest message on the server, as without a version), or call
clearConversationUnread(roomId, { ifVersion: opened }), which clears only
while the marker still has that version and answers cleared: false (with the
current state) otherwise. Both privateStateVersion and ifVersion must be
integers in 0..2147483647 (INVALID_ARGUMENT before any request).
Conversation.membership is your own row (role, lastReadAt,
readPosition, unreadMarkedAt, privateStateVersion), present only on
getConversation() results from a 0.7 backend. It is never part of
participants, list rows or inbox entries, and other members never receive
it. A marker change reaches your other devices through onInboxActivity;
refetch the inbox from it. A rejoin after leaving starts without a marker.
Realtime
Each subscription returns an independently disposable handle. The typed row/deletion and connection-observer APIs below require SDK/UI 0.3+. Earlier pre-release versions are unsupported after the coordinated backend cutover. Private room/app topics are discovered and rotated automatically when membership changes. Customers configure neither topics nor Supabase credentials. Remaining listeners rejoin the current scope; removed users cannot join it or receive later activity on their retired topic.
// Subscribe to lifecycle before data. The topic is logical, e.g. messages:<id>.
const connection = chat.realtime.onConnectionEvent({
onEvent: ({ topic, status }) => {
if (status === 'SUBSCRIBED') reconcileHistory(topic)
},
onSessionEnded: () => clearSessionCaches(),
})
const messages = chat.realtime.onMessage(conversation.id, ({ type, message }) => {
// type is insert or update; merge the canonical message by ID, keeping the
// higher revision (see Edit and delete your own messages).
upsertMessage(message, type)
})
const deletions = chat.realtime.onMessageDeleted(conversation.id, ({ id, conversationId }) => {
removeMessage(conversationId, id)
// Retain a deletion marker so an older HTTP/Realtime row cannot restore it.
})
const typing = chat.realtime.onTyping(conversation.id, {
onEvent: ({ userId, isTyping }) => updateTyping(userId, isTyping),
onError: console.error,
})
const reads = chat.realtime.onReadReceipt(conversation.id, ({ userId, readAt, readPosition }) => {
updateReadPosition(userId, readPosition, readAt)
})
const presence = chat.realtime.onPresence(chat.clientId, ({ userId, isOnline }) => {
updatePresence(userId, isOnline)
})
await messages.unsubscribe()
await typing.unsubscribe()
await reads.unsubscribe()
await presence.unsubscribe()
await deletions.unsubscribe()
await connection.unsubscribe()Call disconnectUser() when switching users or closing the signed-in session;
it disposes all active realtime channels.
Message INSERT/UPDATE rows are separate from ID-only deletion broadcasts. There is no raw Postgres DELETE fallback or fabricated old Message record. Deletions share the private typing/read room channel. Payload room IDs are validated and callbacks from a replaced channel cannot enter the new room hub.
Observe connection state to refetch missed history after rejoin; events are not
replayed. A channel CLOSED event is not the same as onSessionEnded, which
signals logout, replacement or expiry of the owning session. A join acknowledgement
is not proof of successful asynchronous replication or message delivery.
Deletion notifications cover explicit message deletion, by the author
(deleteMessage, 0.8+) or by an administrator, not user/room/app cascades.
Refresh/reopen to discover cascade changes and new rooms.
Read receipts
Since 0.5, a read acknowledgement records a precise position: the (createdAt,
id) of a concrete message, separate from the acknowledgement time. Pass the
newest message your interface has actually rendered as throughMessageId. A
request that arrives after a newer message no longer marks that message read.
Omit the option to acknowledge through the newest message on the server (the
0.4 behavior); on an empty conversation only lastReadAt advances.
await chat.markConversationRead(conversation.id, { throughMessageId: newestRendered.id })Positions are resolved on the server and never move backward, so an older
target after a newer one is a no-op. A target the server cannot find in that
conversation fails with ConvoKitError.code === 'MESSAGE_NOT_FOUND'; a
membership failure is a plain 404 with HTTP_ERROR.
Since 0.7, the options may also carry privateStateVersion, the version
captured when the room opened, so that the acknowledgement clears the caller's
private unread marker when it is still current. It is sent only when present;
{} and { throughMessageId } are unchanged. See Mark unread.
Conversation participants and read events carry both lastReadAt (the
acknowledgement time) and readPosition: ReadPosition | null. The position is
null on rows acknowledged before the backend upgrade, on empty-room
acknowledgements, and on events from a 0.4 backend. Use the exported helpers
instead of comparing timestamps: covers(position, message) is true when the
position is at or after the message in (createdAt, id) order (ties compare
ids as code units), and readThrough(reader, message) applies the unified rule
(position when present, otherwise lastReadAt >= message.createdAt).
import { covers, readThrough } from '@convokitapp/sdk'
const readStateByUserId = new Map(
conversation.participants.map((participant) => [
participant.appUserId,
{ readPosition: participant.readPosition, lastReadAt: participant.lastReadAt },
]),
)
chat.realtime.onReadReceipt(conversation.id, ({ userId, readAt, readPosition }) => {
const current = readStateByUserId.get(userId)?.readPosition ?? null
// Keep the newest position; an event without one only refreshes the time.
const advanced = readPosition !== null && (current === null ||
covers(readPosition, { id: current.messageId, createdAt: current.createdAt }))
readStateByUserId.set(userId, { readPosition: advanced ? readPosition : current, lastReadAt: readAt })
})
const readers = conversation.participants.filter((participant) =>
participant.appUserId !== message.senderId &&
readThrough(readStateByUserId.get(participant.appUserId) ?? {}, message),
)Precise receipts need the sender and the reader on 0.5 with the matching
backend. A 0.4 reader still parses the additive payload and keeps timestamp
semantics; a 0.5 SDK against a 0.4 backend sees readPosition === null and
falls back to lastReadAt through readThrough.
Images and files
Uploads use a signed Cloudflare R2 URL, then confirm the object with ConvoKit before returning its public URL.
const fileUrl = await chat.uploadMessageMedia({
conversationId: conversation.id,
bytes: selectedFile,
fileName: selectedFile.name,
contentType: selectedFile.type,
})
await chat.sendMessage({
conversationId: conversation.id,
text: 'Attached for review',
media: [
{
type: selectedFile.type.startsWith('image/') ? 'image' : 'file',
url: fileUrl,
name: selectedFile.name,
size: selectedFile.size,
},
],
})The SDK also provides uploadUserAvatar, uploadConversationImage,
deleteUserAvatar, deleteConversationImage, and downloadMedia. Message
media supports images, files, locations, and contacts.
Server-only operations
Use ConvoKitServerClient only in a trusted backend. It holds the client secret
and provides user management, token issuance, room membership, conversation
administration, and message administration.
import { ConvoKitServerClient } from '@convokitapp/sdk'
const admin = new ConvoKitServerClient({
clientId: process.env.CONVOKIT_CLIENT_ID!,
clientSecret: process.env.CONVOKIT_CLIENT_SECRET!,
})
await admin.upsertUser({ id: appUser.id, name: appUser.name })
const { token } = await admin.issueUserToken(appUser.id)Never serialize this client, its options, or the client secret into a browser bundle. Your token endpoint should authenticate the host application's user before issuing their ConvoKit token.
Message administration is separate from the author operations in
Edit and delete your own messages.
updateMessage(id, { text?, media?, revision? }) replaces the text and/or the
media of any message unconditionally and, since 0.8, also increments
Message.revision (media-only edits included). Pass revision to apply the
change only while the stored value still matches; a mismatch fails with status
409 and REVISION_CONFLICT. It is validated as an integer in 0..2147483647
before the request and sent only when present, so existing calls send
byte-identical bodies. deleteMessage(id) returns the deleted id; like the
author delete it does not retract files that were already received.
API endpoint
Both clients use ConvoKit's managed https://api.convokit.app endpoint by
default. Most applications should not set an endpoint. For local development,
testing, or a self-hosted deployment only, pass backendUrl to either client;
CONVOKIT_API_URL exports the managed default.
Room IDs
Joining a room remains an application policy. Validate the room ID in your
backend, add the user with addConversationMember, issue or refresh the user
token if needed, and then load the room through getConversation(roomId).
Errors
Failed operations throw ConvoKitError, which includes status, code, and
responseBody when available. When the backend returns a machine-readable
code in a JSON error body (for example MESSAGE_NOT_FOUND, INVALID_CURSOR,
INVALID_ARGUMENT or, on a stale message edit, REVISION_CONFLICT with status
409), it becomes ConvoKitError.code; other HTTP failures use HTTP_ERROR,
including the uncoded 404 an older backend answers for a route it lacks. Network failures use NETWORK_ERROR; realtime parse and channel
failures use REALTIME_ERROR. Arguments rejected before a request use
INVALID_ARGUMENT. A response the SDK cannot parse (for example an inbox page
without entries) rejects with a plain TypeError. Realtime diagnostics omit
raw provider/parser causes and message payloads.
Development and parity
npm ci
npm run validateThe release gate type-checks the source and tests, runs the full suite with coverage thresholds, builds ESM/CommonJS/type declarations, verifies Flutter SDK parity, validates package exports, and inspects the npm tarball. The public JavaScript SDK documentation contains the capability matrix.
