npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/sdk

Live 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, onInboxActivity covers message and read-position activity; see Inbox.
  • Message.clientMessageId identifies one logical send across REST, history and live rows. sendMessage() generates it when omitted. For a custom optimistic UI, call createClientMessageId() before inserting the pending bubble and pass that value to sendMessage({ 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 })
  : null

Message.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 validate

The 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.