Server API
Call the chat engine directly from server code with chat.api.* - every method, and when to use it over HTTP.
chat.api.* is the same domain logic the HTTP routes use, callable directly
from your server code. Every method takes an explicit userId and
enforces the same permissions.
These are the only methods on chat.api - do not invent others. In particular there is no
chat.api.getOrCreateDirectConversation: that name is a low-level storage adapter method. Never
call the adapter directly.
Methods
| Method | What it does |
|---|---|
api.getOrCreateConversation | Find or create the 1:1 conversation for a user pair |
api.createGroupConversation | Create a group with the caller as its first admin - always a new one, never find-or-create |
api.listConversations | List a user's conversations (DMs and groups), most recent first |
api.getConversation | Fetch one conversation (read-permission checked) |
api.updateConversation | Rename a group (or clear its name with null), and/or set its visibility / joinPolicy (admin only) |
api.addParticipants | Add members to a group as member (admin only); idempotent |
api.removeParticipant | Remove a member, or leave by passing your own id (admin, or self); idempotent |
api.setParticipantRole | Promote to admin or demote to member (admin only) |
api.sendMessage | Send a text message, optionally quote-replying to another or mentioning participants (write-permission checked; message hooks apply) |
api.listMessages | Paginate history, newest-first |
api.searchMessages | Search the caller's conversations, relevance-ranked (throws SEARCH_UNSUPPORTED when storage has no search capability) |
api.editMessage | Edit your own message |
api.deleteMessage | Soft-delete your own message |
api.forwardMessage | Copy a message into another conversation (read on the source, write on the target); the copy is a new message, sent by you |
api.addReaction | React as userId (write-permission checked); idempotent |
api.removeReaction | Remove one of your own reactions; idempotent |
api.markRead | Update durable read-state (lastReadMessageId); monotonic - marking an older message is a silent no-op |
api.listMessagesAfter | Messages after a seq (SSE reconnect gap-fill) |
api.createInvite | Mint a shareable invite link for a group (canInvite, admin by default) |
api.listInvites | A group's invites, newest-first, spent ones included (admin only) |
api.revokeInvite | Delete an invite; revoking an unknown code is a silent no-op (admin only) |
api.getInvitePreview | What a link admits you to - an InvitePreview, not a conversation |
api.acceptInvite | Redeem a link: joins, or files a request when the link requires approval |
api.requestToJoin | Ask to join a group by id; no permission needed, but not if you're already in |
api.listJoinRequests | The moderation queue, pending by default (admin only) |
api.resolveJoinRequest | Approve or deny one user's request (admin only) |
api.listPublicConversations | Browse the public-channel directory as ChannelPreviews, most recently active first |
api.joinConversation | Join a public channel by id: admitted instantly, or filed as a request |
Moderation lives in its own namespace, chat.api.moderation.*:
| Method | What it does |
|---|---|
moderation.blockUser | Block another user; idempotent, self-service |
moderation.unblockUser | Lift your own block; idempotent |
moderation.listBlockedUsers | Who you've blocked, cursor-paginated |
moderation.muteConversation | Mute a conversation for yourself - a UI hint, not a server-side filter |
moderation.unmuteConversation | Unmute; idempotent |
moderation.listMutedConversations | Your mutes, cursor-paginated |
moderation.report | Report a user, message, or conversation; reuses an open/triaged report for the same target |
moderation.listReports | The report queue, filterable by status and target type (canModerate) |
moderation.getReport | One report with its immutable evidence (canModerate) |
moderation.updateReport | Move a report to triaged / resolved / dismissed, with a note (canModerate) |
moderation.listBans | Bans, active-only or the whole audit history (canModerate) |
moderation.banUser | Ban a user, permanently or until expiresAt; returns the existing active ban if there is one (canModerate) |
moderation.unbanUser | Revoke a ban, keeping the row for audit (canModerate) |
The four group-management methods work on type: "group" conversations only;
calling one with a DM's id throws NOT_GROUP_CONVERSATION. All four return the
full updated conversation.
The eight invite methods need an optional storage capability and throw
INVITES_UNSUPPORTED when the configured adapter lacks it - both first-party
adapters have it, a custom one may not. They are group-only too.
The two channel methods need a second optional capability and throw
CHANNELS_UNSUPPORTED without it - as does any attempt to set a non-default
visibility or joinPolicy through createGroupConversation /
updateConversation. That last part is deliberate: dropping the field silently
would create a channel no directory could ever find. Passing the defaults
explicitly still works on an adapter without the capability. An "approval"
channel routes its joiners into the invite queue, so it needs the invites
capability too.
The thirteen moderation methods need a third optional capability and throw
MODERATION_UNSUPPORTED without it. The seven moderator-only ones additionally
need chatpack({ moderation: { canModerate } }) and throw NOT_MODERATOR
otherwise. Configuring moderation at all is also what switches on ban
enforcement - an active ban then throws USER_BANNED from every chat.api.*
call and 403s every route; see
Permissions.
Which API do I call?
The same task, from both sides - chat.api.* in server code, the REST route
from a browser or HTTP client:
| I want to... | Server (chat.api.*) | HTTP |
|---|---|---|
| Start a chat with someone | getOrCreateConversation | POST /conversations |
| Start a group | createGroupConversation | POST /conversations/group |
| Show the inbox / sidebar | listConversations | GET /conversations |
| Open one conversation | getConversation | GET /conversations/:id |
| Rename a group | updateConversation | PATCH /conversations/:id |
| Add members | addParticipants | POST /conversations/:id/participants |
| Remove a member / leave | removeParticipant | DELETE /conversations/:id/participants |
| Promote or demote | setParticipantRole | PATCH /conversations/:id/participants |
| Load history / scroll back | listMessages | GET /conversations/:id/messages |
| Search messages | searchMessages | GET /search/messages?q=... |
| Send a message | sendMessage | POST /conversations/:id/messages |
| Edit / delete my message | editMessage, deleteMessage | PATCH / DELETE /messages/:id |
| Forward a message | forwardMessage | POST /messages/:id/forward |
| React / un-react | addReaction, removeReaction | POST / DELETE /messages/:id/reactions |
| Mark a conversation read | markRead | POST /conversations/:id/read |
| Mint an invite link | createInvite | POST /conversations/:id/invites |
| List / revoke invites | listInvites, revokeInvite | GET / DELETE /conversations/:id/invites |
| Show a link's landing page | getInvitePreview | GET /invites/:code |
| Join via a link | acceptInvite | POST /invites/:code/accept |
| Ask to join a group | requestToJoin | POST /conversations/:id/join-requests |
| Work the approval queue | listJoinRequests, resolveJoinRequest | GET / PATCH /conversations/:id/join-requests |
| Publish a group as a channel | updateConversation | PATCH /conversations/:id |
| Browse public channels | listPublicConversations | GET /channels |
| Join a channel | joinConversation | POST /conversations/:id/join |
| Block / unblock a user | moderation.blockUser, unblockUser | POST / DELETE /moderation/blocks |
| Mute / unmute a conversation | moderation.muteConversation, unmuteConversation | POST / DELETE /moderation/mutes |
| Report abuse | moderation.report | POST /moderation/reports |
| Work the report queue | moderation.listReports, updateReport | GET /moderation/reports, PATCH /moderation/reports/:id |
| Ban / unban a user | moderation.banUser, unbanUser | POST /moderation/bans, DELETE /moderation/bans/:id |
| Get live updates in the browser | - (server-sent events) | GET /stream via EventSource |
Usage
// find-or-create a 1:1 conversation between two users
const conversation = await chat.api.getOrCreateConversation({
userId: "alice",
otherUserId: "bob",
});
// send a message
const message = await chat.api.sendMessage({
userId: "alice",
conversationId: conversation.id,
body: "hey bob!",
});
// quote-reply to it - a flat pointer, not a thread
const reply = await chat.api.sendMessage({
userId: "bob",
conversationId: conversation.id,
body: "hey alice!",
replyToMessageId: message.id,
});
reply.replyTo; // { id, senderId, excerpt: "hey bob!", deleted: false }
// react (idempotent both ways; returns the message with its whole set)
const reacted = await chat.api.addReaction({
userId: "bob",
messageId: message.id,
emoji: "👍",
});
reacted.reactions; // [{ emoji: "👍", count: 1, userIds: ["bob"] }]
await chat.api.removeReaction({ userId: "bob", messageId: message.id, emoji: "👍" });
// mention participants - ids you supply, never parsed out of the body
const mentioning = await chat.api.sendMessage({
userId: "alice",
conversationId: conversation.id,
body: "@bob can you look?",
mentions: ["bob"], // must be current participants, else MENTION_NOT_PARTICIPANT
});
mentioning.mentions; // ["bob"] - a set; treat the order as unspecified
// forward it somewhere else - a COPY, with you as sender
const forwarded = await chat.api.forwardMessage({
userId: "alice",
messageId: message.id,
toConversationId: "conv_7", // any conversation alice can write to
});
forwarded.forwardedFrom; // { messageId, conversationId, senderId } - frozen, three ids
// Editing or deleting `message` now changes nothing about `forwarded`.
// search participant conversations (case-insensitive, canonical token matching, ranked)
const search = await chat.api.searchMessages({
userId: "bob",
query: "hello",
limit: 50,
});
// Throws SEARCH_UNSUPPORTED when storage adapter has no search capability.
// paginate history (newest first)
const { messages, nextCursor } = await chat.api.listMessages({
userId: "bob",
conversationId: conversation.id,
limit: 50,
});
// durable read-state
await chat.api.markRead({
userId: "bob",
conversationId: conversation.id,
messageId: message.id,
});Groups differ only in how they are created and how membership changes; messages, read-state, search and reactions are identical:
// alice becomes the group's first admin; bob and carol join as members.
// Calling this twice creates TWO groups - store the id you get back.
const group = await chat.api.createGroupConversation({
userId: "alice",
userIds: ["bob", "carol"],
name: "Launch",
});
group.type; // "group" - and pairKey is null, since only DMs have one
// Membership writes are admin-only and idempotent; each returns the whole
// conversation, so overwrite your cached copy rather than merging.
await chat.api.addParticipants({
userId: "alice",
conversationId: group.id,
userIds: ["dave"],
});
await chat.api.setParticipantRole({
userId: "alice",
conversationId: group.id,
targetUserId: "bob",
role: "admin",
});
// Anyone can remove themselves - that is "leave", and needs no admin rights.
await chat.api.removeParticipant({
userId: "dave",
conversationId: group.id,
targetUserId: "dave",
});
// Rename, or pass null to clear the title.
await chat.api.updateConversation({
userId: "alice",
conversationId: group.id,
name: "Launch week",
});A group always keeps at least one admin: removing or demoting the last one
throws LAST_ADMIN_REMAINING rather than silently promoting someone, because
choosing a successor is a product decision Chatpack shouldn't make for you.
Invite links are the way to add someone whose user id you don't have - or who doesn't have an account yet:
// Mint a link. Every option is optional: no arguments beyond the ids means a
// link that never expires and admits unlimited people.
const invite = await chat.api.createInvite({
userId: "alice",
conversationId: group.id,
expiresInSeconds: 86_400,
maxUses: 5,
});
invite.code; // 43 URL-safe chars - build your own /join/:code page around it
// The landing page. Deliberately NOT getConversation: a non-member may call
// this, so it carries a participant count and never any user ids.
const preview = await chat.api.getInvitePreview({ userId: "erin", code: invite.code });
preview.alreadyParticipant; // false - render "Join", not "Open"
// Redeeming. Branch on `status`, not on which field came back null.
const result = await chat.api.acceptInvite({ userId: "erin", code: invite.code });
if (result.status === "joined") {
result.conversation; // the group, now including erin
} else {
result.joinRequest; // requiresApproval was true - an admin must resolve it
}
// Clicking the link twice is harmless and costs the invite nothing: erin gets
// the conversation back, even once the link is out of uses.An approval-gated link (requiresApproval: true) routes through the same queue
as a user asking directly - which anyone may do for a group id they know:
await chat.api.requestToJoin({
userId: "frank",
conversationId: group.id,
message: "I'm on the design team", // optional note for the admins
});
// Already a participant? Throws ALREADY_PARTICIPANT - there is no join request
// that honestly represents "you're already in".
// The queue defaults to pending. No event fires when a request arrives, so
// admins poll this.
const queue = await chat.api.listJoinRequests({ userId: "alice", conversationId: group.id });
// Resolved by user id, not request id: one request per user per group.
const { joinRequest, conversation } = await chat.api.resolveJoinRequest({
userId: "alice",
conversationId: group.id,
targetUserId: "frank",
decision: "approve", // "deny" leaves conversation null and keeps the row
});Approving publishes the same participant.added event an admin-initiated
addParticipants does, so existing subscribers need no new code. A denied user
may ask again - denial records a decision, it is not a block.
A channel is the third way in: a group with visibility: "public", listed
in a directory anyone signed in can browse. There is no third conversation type -
the same Conversation, two extra fields.
// Publish an existing group, or pass the same two fields to
// createGroupConversation. Admin-only (canManage, not canInvite) - loosening
// who may invite must not also decide who may expose the group to everyone.
const channel = await chat.api.updateConversation({
userId: "alice",
conversationId: group.id,
visibility: "public",
joinPolicy: "open", // omit and it stays "approval" - the recoverable default
});
// The directory. Thin previews, like an invite preview and for the same
// reason: strangers read this, so it carries a participant COUNT, never ids.
const { channels, nextCursor } = await chat.api.listPublicConversations({
userId: "grace",
limit: 20, // most-recently-active first, same keyset paging as listConversations
});
channels[0]?.alreadyParticipant; // render "Open" / "Pending" / "Join" from these
channels[0]?.requestPending;
// Joining. Same discriminated union as acceptInvite - branch on `status`.
const result = await chat.api.joinConversation({
userId: "grace",
conversationId: channel.id,
message: "found you in the directory", // only used on an "approval" channel
});
if (result.status === "joined") result.conversation;
else result.joinRequest; // lands in the same queue, with inviteCode: nullPublic means discoverable, not readable: browsing grants no read access, so
getConversation and listMessages still throw FORBIDDEN_READ for a
non-member. The permission layer is untouched by this feature - to read a channel
you join it. An invite still overrides the channel's policy, because the policy
lives on whatever the joiner presents: a link minted without requiresApproval
walks straight into an "approval" channel.
First-party adapters use the same Unicode NFKC, case-insensitive,
punctuation-separated token matching. All unique query terms must be present;
term occurrence count determines relevance before createdAt and message id
tie-breaks. Tombstones are excluded. Core still applies canRead after the
participant scope, so non-participant dynamic access is not supported yet.
Validating that a user exists
Chatpack never owns a users table, so by default otherUserId and group
userIds are opaque strings it stores without question. That is usually what
you want - until a typo or a stale id creates a conversation with somebody who
does not exist, which nobody can ever open.
Pass userExists to close that gap without handing Chatpack your identity
data:
export const chat = chatpack({
storage,
auth,
// Your identity store, your query. Return false for "no such user".
userExists: async (userId) =>
Boolean(
await db.query.users.findFirst({
where: eq(users.id, userId),
}),
),
});Core calls it before creating a direct conversation, before seeding a group,
and before adding participants. A false answer throws USER_NOT_FOUND
(HTTP 404). Omit the option and nothing changes from previous versions.
Three things are worth knowing, because they decide how hard this hits your database:
- The acting user is never checked. Your
authhook already vouched for them; re-querying would be a second lookup for an answer you have. - Ids are deduplicated first, and the acting user's own id is dropped from a group's member list, so a repeated id is never a repeated query.
- Checks run in bounded batches, not one after another. Seeding a 50-member group costs a handful of round trips rather than 50, and a 256-member group still cannot open 256 simultaneous connections. If you want one query for many ids, do the batching inside your own hook.
This validates existence, not permission. "Bob exists" and "Alice is allowed to message Bob" are separate questions - the second one belongs to permissions and moderation.
Return shapes: bare objects, no envelopes
Server-side methods return the bare object (Conversation, Message, ...).
The HTTP layer wraps responses in envelopes ({ conversation },
{ message }, { messages, nextCursor }) - that envelope is HTTP-only and
intentional (room to add sibling fields without breaking clients). Don't
reuse HTTP-response types for chat.api.* calls or vice versa.
Conversation-returning methods (getOrCreateConversation,
listConversations, getConversation) return ConversationWithUnread -
the conversation plus the calling user's unreadCount (messages newer than
their read-state, excluding their own). See
Conversations & messages.
Every message-returning method (sendMessage, listMessages, searchMessages,
editMessage, deleteMessage, addReaction, removeReaction,
forwardMessage, listMessagesAfter) returns
MessageWithDetails - the stored Message plus decorations:
replyTo (the quoted parent's preview, or null), reactions (grouped by
emoji), mentions (the ids named in it), and forwardedFrom (three ids, or
null). replyTo and reactions are not stored at all: core computes them from
batched adapter calls, one per page, which is why an edited parent's excerpt is
never stale. mentions comes from its own batched call over stored rows, and
forwardedFrom is assembled from three columns on the message itself - both are
durable, so unlike replyTo they cannot change after the fact. Storage adapters
and permission hooks only ever see the bare Message.
Timestamps are real Date instances on the server, ISO strings over HTTP.
Errors: thrown, never null
All failures throw ChatpackError with a stable code - methods never
return null for missing resources. api.getConversation throws
CONVERSATION_NOT_FOUND; don't confuse it with the storage adapter's
getConversation, which returns Conversation | null (core is the layer
that turns a null into the domain error).
import { ChatpackError } from "@chatpack/core";
try {
await chat.api.getConversation({ userId, conversationId });
} catch (err) {
if (err instanceof ChatpackError && err.code === "CONVERSATION_NOT_FOUND") {
// handle the missing conversation
} else {
throw err;
}
}See Error handling for the full code list.