REST API
Every HTTP route, request and response shape, semantics, and error code.
All routes are served by the one handler, relative to basePath (default
/api/chat). Your auth hook runs on every request - before routing.
Core routes
| Method | Path | Request body / query | Response (200/201) |
|---|---|---|---|
| POST | /conversations | { otherUserId, metadata? } | { conversation } - DM, find-or-create |
| GET | /conversations | ?limit=&cursor= | { conversations, nextCursor } |
| GET | /conversations/:id | - | { conversation } |
| POST | /conversations/:id/messages | { body, role?, replyToMessageId?, threadRootMessageId?, alsoSendToMain?, mentions?, metadata? } | { message } (201) |
| GET | /conversations/:id/messages | ?limit=&cursor= | { messages, nextCursor } - newest first |
| GET | /conversations/:id/messages/:messageId | - | { message } |
| GET | /conversations/:id/threads/:rootId/messages | ?limit=&cursor= | { messages, nextCursor } - newest first |
| GET | /search/messages | ?q=&limit=&cursor= | { messages, nextCursor } - ranked |
| POST | /conversations/:id/read | { messageId } | { ok: true } |
| PATCH | /messages/:id | { body, mentions? } | { message } |
| DELETE | /messages/:id | - | { message } (soft-deleted) |
| POST | /messages/:id/forward | { conversationId, role?, mentions?, metadata? } | { message } (201) - the copy |
| POST | /messages/:id/reactions | { emoji } | { message } (full reaction set) |
| DELETE | /messages/:id/reactions | { emoji } | { message } (full reaction set) |
| GET | /stream | SSE; auto Last-Event-ID on reconnect | text/event-stream |
Group routes
Groups and DMs are the same Conversation shape, told apart by type. These
routes exist only for type: "group" - calling one on a DM is
409 NOT_GROUP_CONVERSATION.
| Method | Path | Request body | Who | Response |
|---|---|---|---|---|
| POST | /conversations/group | { name?, userIds?, metadata?, visibility?, joinPolicy? } | any signed-in user | { conversation } (201) |
| PATCH | /conversations/:id | { name?, visibility?, joinPolicy? } | admin | { conversation } |
| POST | /conversations/:id/participants | { userIds } | admin | { conversation } |
| DELETE | /conversations/:id/participants | { userId } | admin, or self to leave | { conversation } |
| PATCH | /conversations/:id/participants | { userId, role } | admin | { conversation } |
Everything else - messages, read-state, search, reactions, the stream - is identical for both types.
Invite & join-request routes
Two ways into a group besides an admin knowing your user id: a shareable link, or asking to be let in. Group-only, like the routes above.
These need an optional storage capability. When the configured adapter does
not implement it, all eight return 501 INVITES_UNSUPPORTED - check once at
startup rather than per call. Both first-party adapters have it; a custom one
may not.
| Method | Path | Request body / query | Who | Response |
|---|---|---|---|---|
| POST | /conversations/:id/invites | { expiresInSeconds?, maxUses?, requiresApproval?, metadata? } | canInvite (admin by default) | { invite } (201) |
| GET | /conversations/:id/invites | - | admin | { invites } - newest first |
| DELETE | /conversations/:id/invites/:code | - | admin | { ok: true } |
| GET | /invites/:code | - | any signed-in user | { invite } - an InvitePreview |
| POST | /invites/:code/accept | { message? } | any signed-in user | { status, conversation, joinRequest } |
| POST | /conversations/:id/join-requests | { message? } | any signed-in user | { joinRequest } (201) |
| GET | /conversations/:id/join-requests | ?status=&limit= | admin | { joinRequests } - newest first |
| PATCH | /conversations/:id/join-requests | { userId, decision: "approve" | "deny" } | admin | { joinRequest, conversation } |
Semantics
- The code is a capability URL, not a credential. 43 URL-safe characters
from 256 bits of entropy, generated by Chatpack; possession is the
permission, the way a document share link works. It is stored in plaintext so
an admin can re-display a link they already handed out, and it travels in the
request path - so it will appear in ordinary HTTP access logs. Bound the blast
radius with
expiresInSeconds,maxUses, and revocation; set a short expiry if your logs are part of your threat model. A group holds at most 50 invites at once (422 INVITE_LIMIT_EXCEEDED); revoke spent ones. GET /invites/:codereturns anInvitePreview, not a conversation -{ conversationId, name, participantCount, requiresApproval, invitedBy, alreadyParticipant }. A count rather than a participant list, on purpose: this is the one route a non-member may call, and returning the conversation would hand every member's user id to anyone holding a link, whether or not they ever join. UsealreadyParticipantto render "Open" instead of "Join".- Accepting is a discriminated union - branch on
status. An open invite gives{ status: "joined", conversation, joinRequest: null }; one created withrequiresApproval: truegives{ status: "pending", conversation: null, joinRequest }. Readstatus, not which field came back null. - Redeeming is idempotent and never over-charges the link. A user who is
already a participant gets the conversation back and consumes no use - even
after the link is spent, so a double-clicked one-use link still answers
truthfully to the person it admitted. Same for a second click on an
approval-gated link: the existing pending request comes back. A redemption
that would push the group over 256 participants is
422 GROUP_LIMIT_EXCEEDEDwith the link intact. - 404 means "no such link", 410 means "this link is finished". Unknown or
revoked codes are
404 INVITE_NOT_FOUND- a revoked code is indistinguishable from one that never existed. Expired or use-exhausted is410 INVITE_EXPIRED; one code covers both because the client handling is the same ("ask for a new link"), and the message says which. Expired invites are not garbage-collected: they stay inlistInvites, inert, until revoked. - Requesting to join needs no permission, but you can't ask twice. Any
signed-in user may
POST /conversations/:id/join-requestsfor a group id they know. Asking about a group you are already in is409 ALREADY_PARTICIPANT- there is no join request that honestly represents "you're already in". At most one request per user per group: re-asking replaces the row, so a denial is not a block. To stop someone re-asking, ban them through the moderation routes - a user block only covers DMs. - Requests are resolved by user id, not request id.
PATCHtakes{ userId, decision }because(conversation, user)is already unique and an admin working a queue has the user in hand.GETdefaults to?status=pending- the moderation queue; passapprovedordeniedfor history. Resolving an already-resolved request is404 JOIN_REQUEST_NOT_FOUND. - Joining publishes the existing
participant.addedevent - no new SSE types. A redeemed link, an approved request, and an admin-initiated add are the same change to the same list, so existing subscribers get all three for free. Creating a join request publishes nothing: the requester isn't in the conversation and nothing on any screen is wrong, so admins pollGET /conversations/:id/join-requests.
Channel routes
A channel is a group with visibility: "public" - not a third conversation
type. Public groups appear in a browsable directory that any signed-in user can
read, and they can be joined without an invite.
These need their own optional storage capability, separate from invites. Without
it, both routes below - and any attempt to set a non-default visibility or
joinPolicy - return 501 CHANNELS_UNSUPPORTED. Explicitly sending the
defaults ("private" / "approval") still works, so an existing client is
unaffected. Both first-party adapters have the capability.
| Method | Path | Request body / query | Who | Response |
|---|---|---|---|---|
| GET | /channels | ?limit=&cursor= | any signed-in user | { channels, nextCursor } - previews |
| POST | /conversations/:id/join | { message? } | any signed-in user | { status, conversation, joinRequest } |
Semantics
- Two fields, both on every conversation.
visibility: "private" | "public"(default"private") decides whether the group is listed;joinPolicy: "open" | "approval"(default"approval") decides what happens when someone joins. Set them at creation or flip them later withPATCH /conversations/:id. They are independent - omitting one on a PATCH leaves it as it was, it is not a reset. - Publishing needs admin authority, not invite authority. The flip is
guarded by
canManage, so looseningcanInviteto "any member" doesn't also let a member expose the group to everyone. On a DM it is409 NOT_GROUP_CONVERSATION- a DM has no audience to open to. - A public group defaults to
"approval". Between "a stranger is in the room" and "a stranger is in a queue", only one is recoverable, so settingvisibilitywithout thinking about policy gets you the safer of the two. GET /channelsreturnsChannelPreviews, not conversations -{ conversationId, name, participantCount, joinPolicy, createdAt, metadata, alreadyParticipant, requestPending }, most-recently-active first, paginated with the same?limit=&cursor=keyset asGET /conversations. A count rather than a member list, for the same reason the invite preview is thin: this is a route strangers can read. Only public groups appear - never a DM, never a private group.alreadyParticipantandrequestPendingare viewer-relative, so render "Open", "Pending", or "Join" straight from them.- Public means discoverable, not readable. Browsing grants no read access:
GET /conversations/:idand the message routes still answer403 FORBIDDEN_READto a non-member. The permission layer is untouched by this feature - to read a channel you join it. And/channelsis not anonymous: auth runs before routing, so no session is still401. - Joining is the same discriminated union as accepting an invite.
POST /conversations/:id/joingives{ status: "joined", conversation, joinRequest: null }on an"open"channel and{ status: "pending", conversation: null, joinRequest }on an"approval"one - both 200, so branch onstatus. Re-asking while pending returns the same row (it can't be used to jump a newest-first queue); joining a channel you're in is409 ALREADY_PARTICIPANT; a group that isn't public is403 NOT_PUBLIC_CONVERSATION. - 403, not 404, for a private group. Core knows the row exists, and a lie it would then have to keep telling consistently is worse than a plain refusal. Use an unguessable conversation id if you need private groups to be unprobeable.
- A directory join has
inviteCode: null. That's how an admin working the queue tells "found us in the directory" from "someone handed them a link". The request lands in the sameGET /conversations/:id/join-requestsqueue, and an"approval"channel therefore needs the invites capability too - without it, joining is501 INVITES_UNSUPPORTED. - An invite always overrides the channel's policy. The policy lives on
whatever the joiner presents: a link minted without
requiresApprovalwalks straight into an"approval"channel (the admin who minted it vouched for the holder), and a link minted with it queues even in an"open"one. - Joining publishes
participant.added; a flip publishesconversation.updated- both existing events, with the joiner as their ownactorId. No new SSE types, so a client written for groups already handles it.
Plugin routes
Only present when the plugin is passed to chatpack({ plugins }) - consulted
after core routes miss, before the 404:
| Method | Path | Plugin | Request body / query | Response |
|---|---|---|---|---|
| POST | /conversations/:id/typing | typing() | { isTyping?: boolean } | { ok: true } |
| GET | /presence | presence() | ?userIds=a,b (max 50) | { presence: { [id]: { online, lastSeenAt } } } |
Moderation routes
Moderation routes use the optional StorageAdapter.moderation capability.
Self-service routes are available to signed-in users. Report queue and ban
routes require the host's moderation.canModerate hook.
| Method | Path | Request body / query | Response |
|---|---|---|---|
| POST | /moderation/blocks | { targetUserId } | { block } |
| DELETE | /moderation/blocks | { targetUserId } | { ok: true } |
| GET | /moderation/blocks | ?limit=&cursor= | { blocks, nextCursor } |
| POST | /moderation/mutes | { conversationId } | { mute } |
| DELETE | /moderation/mutes | { conversationId } | { ok: true } |
| GET | /moderation/mutes | ?limit=&cursor= | { mutes, nextCursor } |
| POST | /moderation/reports | { targetType, targetId, reason } | { report } |
| GET | /moderation/reports | ?status=&targetType=&limit=&cursor= | { reports, nextCursor } |
| GET | /moderation/reports/:id | - | { report } |
| PATCH | /moderation/reports/:id | { status, moderatorNote? } | { report } |
| GET | /moderation/bans | ?activeOnly=&limit=&cursor= | { bans, nextCursor } |
| POST | /moderation/bans | { targetUserId, reason?, expiresAt? } | { ban } |
| DELETE | /moderation/bans/:id | - | { ban } |
Blocks stop new direct conversations and direct message mutations, but keep
existing direct history readable. They do not affect shared groups. Mutes do
not change unreadCount or SSE delivery. Reports support user, message,
and conversation targets, with statuses open, triaged, resolved, and
dismissed.
Active bans return 403 USER_BANNED. Missing moderation persistence returns
501 MODERATION_UNSUPPORTED.
Ban enforcement follows your config, not your adapter. Bans are checked before
routing on every request and on every SSE heartbeat, but only when the
moderation option is configured - by default whenever canModerate is set,
since banUser is the only way to mint a ban. An app that never configures
moderation pays no ban lookups even on an adapter that supports them. Pass
moderation: { enforceBans: true } when ban rows are written outside Chatpack,
or false to keep the moderator tools without per-request enforcement. Blocks,
mutes, and reports are unaffected: they work off StorageAdapter.moderation
alone.
Semantics
These trip up hand-written and generated clients alike:
- Responses are enveloped -
{ conversation },{ message },{ messages, nextCursor }- unwrap them. The envelope is HTTP-only and intentional (room to add sibling fields without breaking clients); server-sidechat.api.*returns bare objects instead. Don't share types between the two. - Every conversation object carries the viewer's
unreadCount(create, list, get): messages newer than their read-state, excluding the viewer's own. Soft-deleted messages count - they render as tombstones. Read the badge from here instead of counting client-side. - Every conversation carries
type,pairKey, andname. A DM istype: "direct"with apairKeyandname: null; a group istype: "group"withpairKey: nulland an optionalname. Each participant carriesrole: "admin" | "member"- both DM participants are"admin", which keeps "can this user manage?" one role check on either type. - DMs are find-or-create; groups never are.
POST /conversationstwice for the same pair returns the same conversation.POST /conversations/grouptwice creates two groups even with identical members, so persist the returned id.POST /conversations/groupneeds no body at all - that creates an empty, unnamed group containing only the creator (an admin). - Group routes return the full conversation with its complete participant list; replace the cache entry rather than merging a delta. Membership writes are idempotent: adding an existing member is a no-op that never demotes an admin, removing a non-member succeeds silently, and setting a role someone already has does nothing.
- A group always keeps at least one admin. Removing or demoting the last one
is
409 LAST_ADMIN_REMAINING- Chatpack refuses instead of silently promoting someone, since picking a successor is a product decision. Promote first, then leave. - A group name is trimmed, 1-200 characters.
PATCH /conversations/:idwith{ "name": null }clears it; omittingnameentirely is a400, because an accidental clear is worse than an error. A group holds at most 256 participants; going over is422 GROUP_LIMIT_EXCEEDED. - Participants come back in a stable order (join order,
userIdbreaking ties) so a client can diff the list positionally. - Message lists are newest-first; reverse for a chronological transcript.
Paginate by passing
nextCursorback as?cursor=;nextCursor: nullmeans no more results. - Search is case-insensitive and relevance-ranked; creation time breaks
relevance ties. It searches the viewer's participant conversations, excludes
tombstones, and checks
canReadfor each result. Non-participant search is not supported yet. Search is an optional storage capability; when the configured adapter does not provide it, this route returns501withSEARCH_UNSUPPORTED. First-party adapters normalize with Unicode NFKC, lowercase, and treat punctuation as a separator; every unique query term is required and term occurrences determine relevance. - The message text field is
body(nottext/content), and it must be a non-empty string after trimming on both send and edit - whitespace-only is400 INVALID_INPUT. There are no body-less messages, so an attachment-only or sticker-only composer has to synthesize one (a file name, say) rather than send"". - Every message carries
reactionsand its reply fields.reactionsis grouped:[{ emoji, count, userIds }],userIdsearliest-first (at most two per emoji in a DM, up to the participant count in a group - enough to render "you and 4 others" without a second request).replyToMessageIdis the stored pointer;replyTois a read-only preview{ id, senderId, excerpt, deleted }hydrated per request, never stored, so it can't go stale when the parent is edited and renders even when the parent is far outside the loaded page.excerptis the parent's first 140 characters with"…"appended when truncated, and""when the parent is a tombstone. - Reaction routes are idempotent both ways and always return the message
with its complete reaction set - replace that cache entry rather than
merging a delta. The acting user comes from the
authhook, so a caller can only ever add or remove reactions attributed to themselves. Reacting needs write permission (like editing - other participants see it). Theemojitravels in the request body onDELETEtoo, because reaction keys can be arbitrary strings that mangle badly in a path segment. - A reaction key is any non-empty string, trimmed, up to 32 characters -
"👍",":shipit:","custom_1234"all work;""and 33+ characters are400 INVALID_INPUT. It is not validated as a Unicode emoji. - A reaction is not a message. It gets no
seq, never bumpsunreadCount, and never reorders the conversation list. - Quote replies are flat pointers.
replyToMessageIdmust name a message in the same conversation (else404 MESSAGE_NOT_FOUND- the same wording used for unknown ids, so a cross-conversation probe reveals nothing). Replying to a soft-deleted message is allowed (the parent can be deleted between render and send); deleting a parent leaves its replies intact withreplyTo.deleted: true; a reply to a reply is still one hop; and the pointer is immutable, sincePATCH /messages/:idonly ever changes the body. - Thread replies use a root id. Set
threads: { enabled: true }in the installation and send withthreadRootMessageId. The root must be a main conversation message. A thread reply stays out of the main message page unlessalsoSendToMainistrue. The same message id then appears in both pages. Reading one message by id requires access to its conversation. - Mentions are ids you supply. Send or edit with
mentions: ["user_1"]. Core never parsesbody, so the text and the array can legitimately disagree and your app owns keeping them in step: it renders the@name, it tells Chatpack which id that was. Every id must be a current participant, or the whole call is400 MENTION_NOT_PARTICIPANT- a mention is never silently dropped, because a drop nobody can see makes the sender believe a notification went out. Mentioning yourself is fine; the cap is 256 ids per message. - On edit, omitting
mentionsleaves the stored set alone;mentions: []clears it. That way a client written before mentions existed cannot erase them by editing a body. Ids already stored are re-accepted even if that person has since left the conversation (so fixing a typo still works) - a new id must still be a participant. mentionsis a set, not a sequence. It reads back sorted rather than in the order you sent it. A mention is also not a message: noseq, nounreadCount, no reordering, and no SSE event of its own. Chatpack does not notify anyone and keeps no mention inbox;afterMessageMutationhands youmentionsnext torecipientIdsso you can.- Forwarding copies the message.
POST /messages/:id/forwardwrites a new message intoconversationId: you as sender, the body copied verbatim, its ownseq, counting toward that conversation's unread. Nothing is a live pointer, so editing or deleting the original never changes the copy. You need read on the source and write on the target; forwarding a tombstone is409 MESSAGE_DELETED. forwardedFromis three ids, frozen.{ messageId, conversationId, senderId }, naming the immediate source (one hop, like replies). There is deliberately no excerpt and no source conversation name: the people reading the copy may have no access to where it came from, and a live field would let them watch it. Reactions, the reply pointer, mentions, metadata, androledo not travel - pass fresh ones if you want them, andmentionsis validated against the target.roleis"user" | "assistant" | "system"(default"user") - a stored label; core never branches on it. Anything else is a 400.- User ids stay host-owned. Configure
userExists(userId)to validate new direct-chat targets and group participants. Missing users return404 USER_NOT_FOUND; omitting the hook preserves opaque-id behavior. - Timestamps are
Datein server-side calls, ISO 8601 strings over HTTP. - Auth runs before routing - an unauthenticated request to a wrong path
still 401s. Fix auth first; then a lingering
404 NOT_FOUNDmeans your mount path/basePath is wrong.
Worked example
Send a message:
curl -X POST /api/chat/conversations/conv_1/messages \
-H 'content-type: application/json' \
-d '{"body": "hey bob!"}'{
"message": {
"id": "msg_1",
"conversationId": "conv_1",
"senderId": "alice",
"body": "hey bob!",
"role": "user",
"seq": 1,
"createdAt": "2026-07-22T19:48:06.416Z",
"editedAt": null,
"deletedAt": null,
"replyToMessageId": null,
"replyTo": null,
"reactions": [],
"mentions": [],
"forwardedFrom": null,
"metadata": {}
}
}Quote-reply to it, then react:
curl -X POST /api/chat/conversations/conv_1/messages \
-H 'content-type: application/json' \
-d '{"body": "hey alice!", "replyToMessageId": "msg_1"}'{
"message": {
"id": "msg_2",
"senderId": "bob",
"body": "hey alice!",
"seq": 2,
"replyToMessageId": "msg_1",
"replyTo": { "id": "msg_1", "senderId": "alice", "excerpt": "hey bob!", "deleted": false },
"reactions": []
}
}curl -X POST /api/chat/messages/msg_1/reactions \
-H 'content-type: application/json' \
-d '{"emoji": "👍"}'{
"message": {
"id": "msg_1",
"body": "hey bob!",
"seq": 1,
"reactions": [{ "emoji": "👍", "count": 1, "userIds": ["bob"] }]
}
}DELETE the same route with the same body removes it again. Both calls are
idempotent, and both return the message's whole reaction set - so the response
is what you write into the cache.
Mention a participant, then forward the message into another conversation:
curl -X POST /api/chat/conversations/conv_1/messages \
-H 'content-type: application/json' \
-d '{"body": "@bob can you look?", "mentions": ["bob"]}'
curl -X POST /api/chat/messages/msg_1/forward \
-H 'content-type: application/json' \
-d '{"conversationId": "conv_7"}'{
"message": {
"id": "msg_9",
"conversationId": "conv_7",
"senderId": "alice",
"body": "hey bob!",
"seq": 1,
"mentions": [],
"forwardedFrom": {
"messageId": "msg_1",
"conversationId": "conv_1",
"senderId": "alice"
}
}
}The forward is a new message in conv_7 - its own id, its own seq, alice as
sender because alice forwarded it. mentions is empty even though the source
had one: mentions name people in that conversation, and conv_7 is a
different room.
Find-or-create a conversation:
curl -X POST /api/chat/conversations \
-H 'content-type: application/json' \
-d '{"otherUserId": "bob"}'{
"conversation": {
"id": "conv_1",
"type": "direct",
"pairKey": "alice:bob",
"name": null,
"createdAt": "2026-07-22T19:47:47.945Z",
"metadata": {},
"participants": [
{
"conversationId": "conv_1",
"userId": "alice",
"role": "admin",
"joinedAt": "…",
"lastReadMessageId": null
},
{
"conversationId": "conv_1",
"userId": "bob",
"role": "admin",
"joinedAt": "…",
"lastReadMessageId": null
}
],
"unreadCount": 0
}
}List conversations (as bob, with two unread messages from alice):
curl '/api/chat/conversations?limit=50'{
"conversations": [
{
"id": "conv_1",
"type": "direct",
"pairKey": "alice:bob",
"name": null,
"createdAt": "2026-07-22T19:47:47.945Z",
"metadata": {},
"participants": [
{
"conversationId": "conv_1",
"userId": "alice",
"role": "admin",
"joinedAt": "…",
"lastReadMessageId": null
},
{
"conversationId": "conv_1",
"userId": "bob",
"role": "admin",
"joinedAt": "…",
"lastReadMessageId": null
}
],
"unreadCount": 2
}
],
"nextCursor": null
}unreadCount is viewer-relative: the same conversation fetched as alice
(the sender) shows 0.
Create a group (as alice), then add a member:
curl -X POST /api/chat/conversations/group \
-H 'content-type: application/json' \
-d '{"name": "Launch", "userIds": ["bob", "carol"]}'{
"conversation": {
"id": "conv_2",
"type": "group",
"pairKey": null,
"name": "Launch",
"createdAt": "2026-08-05T10:14:02.118Z",
"metadata": {},
"participants": [
{
"conversationId": "conv_2",
"userId": "alice",
"role": "admin",
"joinedAt": "…",
"lastReadMessageId": null
},
{
"conversationId": "conv_2",
"userId": "bob",
"role": "member",
"joinedAt": "…",
"lastReadMessageId": null
},
{
"conversationId": "conv_2",
"userId": "carol",
"role": "member",
"joinedAt": "…",
"lastReadMessageId": null
}
],
"unreadCount": 0
}
}curl -X POST /api/chat/conversations/conv_2/participants \
-H 'content-type: application/json' \
-d '{"userIds": ["dave"]}'The response is the whole conversation again, now with four participants. To
leave, DELETE the same path with your own id - no admin rights needed, unless
you are the last admin, in which case promote a successor first:
curl -X PATCH /api/chat/conversations/conv_2/participants \
-H 'content-type: application/json' \
-d '{"userId": "bob", "role": "admin"}'
curl -X DELETE /api/chat/conversations/conv_2/participants \
-H 'content-type: application/json' \
-d '{"userId": "alice"}'Mint an invite link for that group (as an admin), good for 24 hours and two people:
curl -X POST /api/chat/conversations/conv_2/invites \
-H 'content-type: application/json' \
-d '{"expiresInSeconds": 86400, "maxUses": 2}'{
"invite": {
"code": "kJ8pQ2mXvR7tN4wY6bL1cD3fH5gS9aZ0eU2iO8rT4nM",
"conversationId": "conv_2",
"createdBy": "alice",
"createdAt": "2026-08-09T09:12:44.301Z",
"expiresAt": "2026-08-10T09:12:44.301Z",
"maxUses": 2,
"uses": 0,
"requiresApproval": false,
"metadata": {}
}
}Build your share URL from code however your app routes -
https://yourapp.com/join/kJ8pQ2…. When someone opens it, preview first so you
know what to render:
curl /api/chat/invites/kJ8pQ2mXvR7tN4wY6bL1cD3fH5gS9aZ0eU2iO8rT4nM{
"invite": {
"conversationId": "conv_2",
"name": "Launch",
"participantCount": 4,
"requiresApproval": false,
"invitedBy": "alice",
"alreadyParticipant": false
}
}Then accept (as erin):
curl -X POST /api/chat/invites/kJ8pQ2mXvR7tN4wY6bL1cD3fH5gS9aZ0eU2iO8rT4nM/accept{
"status": "joined",
"conversation": {
"id": "conv_2",
"type": "group",
"name": "Launch",
"participants": ["… 5 now …"]
},
"joinRequest": null
}Every existing member gets a participant.added event. Had the invite been
minted with {"requiresApproval": true}, the same call would answer
{ "status": "pending", "conversation": null, "joinRequest": { … } } instead,
and an admin would work the queue:
curl '/api/chat/conversations/conv_2/join-requests' # defaults to ?status=pending{
"joinRequests": [
{
"id": "jr_1",
"conversationId": "conv_2",
"userId": "erin",
"status": "pending",
"message": "I'm on the design team",
"inviteCode": "kJ8pQ2mXvR7tN4wY6bL1cD3fH5gS9aZ0eU2iO8rT4nM",
"createdAt": "2026-08-09T09:20:11.882Z",
"resolvedAt": null,
"resolvedBy": null,
"metadata": {}
}
]
}curl -X PATCH /api/chat/conversations/conv_2/join-requests \
-H 'content-type: application/json' \
-d '{"userId": "erin", "decision": "approve"}'That returns { joinRequest, conversation } - the request now approved, and
the group including erin. Deny instead and conversation is null, the row
stays as a record, and erin may ask again later.
Errors
JSON with a stable machine-readable code and a mapped HTTP status:
{ "error": { "code": "FORBIDDEN_READ", "message": "…" } }| Status | Code(s) | When |
|---|---|---|
| 401 | UNAUTHENTICATED | auth returned null (or a non-ChatpackUser) |
| 400 | INVALID_INPUT | bad body/query params |
| 403 | FORBIDDEN_READ, FORBIDDEN_WRITE, NOT_MESSAGE_SENDER | not allowed |
| 403 | NOT_CONVERSATION_ADMIN | a group management call by a non-admin |
| 403 | NOT_PUBLIC_CONVERSATION | joining a group that is not a public channel |
| 404 | CONVERSATION_NOT_FOUND, MESSAGE_NOT_FOUND, NOT_FOUND | missing resource/route |
| 404 | INVITE_NOT_FOUND, JOIN_REQUEST_NOT_FOUND | unknown or revoked code; no such pending request |
| 409 | MESSAGE_DELETED | editing a deleted message |
| 409 | NOT_GROUP_CONVERSATION | a group-only call on a DM |
| 409 | LAST_ADMIN_REMAINING | removing or demoting a group's only admin |
| 409 | ALREADY_PARTICIPANT | asking to join a group you are already in |
| 410 | INVITE_EXPIRED | the link is past its expiry, or out of uses |
| 422 | MESSAGE_REJECTED | a beforeMessageSend hook refused the message |
| 422 | GROUP_LIMIT_EXCEEDED | a group would exceed 256 participants |
| 422 | INVITE_LIMIT_EXCEEDED | the group already holds 50 invites |
| 500 | INTERNAL_ERROR | unexpected server error (opaque) |
| 501 | SEARCH_UNSUPPORTED, INVITES_UNSUPPORTED, CHANNELS_UNSUPPORTED | the adapter lacks that optional capability |
More on branching by code in Error handling.
SSE events
Durable events on GET /stream (replayed from storage on reconnect via
Last-Event-ID; event id is conversationId:seq):
| Event | Data | When |
|---|---|---|
message.created | { message } | a message was sent to one of your conversations |
message.updated | { message } | a message was edited |
message.deleted | { message } | a message was soft-deleted (body "", deletedAt set) |
Reaction events are durable-backed but carry no id: field, because
Last-Event-ID means "the newest message seq I have seen" and a reaction
produces no new seq - an id: here would poison gap-fill:
| Event | Data | When |
|---|---|---|
reaction.added | { actorId, emoji, message } | someone added a reaction |
reaction.removed | { actorId, emoji, message } | someone removed one |
message holds the complete post-change reaction set, not a delta, so
applying the same event twice is harmless. The trade-off: reactions are not
gap-filled. One applied while a client was offline shows up on its next
refetch, so refetch cached message pages when the stream reopens after having
been open. @chatpack/client does that for you.
Membership events follow the same no-id: rule, for the same reason - a
membership change allocates no seq:
| Event | Data | When |
|---|---|---|
participant.added | { actorId, affectedUserIds, conversation } | members were added to a group |
participant.removed | { actorId, affectedUserIds, conversation } | a member was removed, or left |
conversation.updated | { actorId, affectedUserIds, conversation } | a group was renamed, a role changed, or visibility flipped |
conversation is the complete post-change snapshot, participants included -
render from it rather than patching. actorId is who did it (an admin, or the
leaver themselves). affectedUserIds names who was added, removed, or had their
role changed; it is empty for a rename, where the change is visible in
conversation.name.
participant.removed is delivered to the removed user too: it is the only
signal telling their client to drop the conversation, and the one place a
Chatpack event reaches a non-participant. Compare affectedUserIds against your
own id to tell "I was removed" from "someone else was".
Like reactions, these are not gap-filled - refetch the conversation list when the stream reopens.
@chatpack/client subscribes to the existing membership events and wraps the group, invite,
join-request, and channel routes. It does not add new React cache queries for the imperative
invite, queue, or directory actions.
Ephemeral plugin events (never stored, never replayed, no id: field):
| Event | Plugin |
|---|---|
typing.started / typing.stopped | typing() |
presence.online / presence.offline | presence() |
receipt.delivered / receipt.read | receipts() |
Ephemeral data payload:
{ type, ephemeral: true, conversationId?, senderId, payload, at }.
Handler options
chat.handler({
basePath: "/api/chat", // default
heartbeatIntervalMs: 15_000, // default - SSE keep-alive comment interval
});GET/POST/PATCH/DELETE/fetch on the returned handler are all the
same function - the method names only exist so they can be re-exported from
a Next.js route file. Any of them serves every route, including /stream.