Chatpack
Reference

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

MethodPathRequest body / queryResponse (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/streamSSE; auto Last-Event-ID on reconnecttext/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.

MethodPathRequest bodyWhoResponse
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.

MethodPathRequest body / queryWhoResponse
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/:code returns an InvitePreview, 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. Use alreadyParticipant to render "Open" instead of "Join".
  • Accepting is a discriminated union - branch on status. An open invite gives { status: "joined", conversation, joinRequest: null }; one created with requiresApproval: true gives { status: "pending", conversation: null, joinRequest }. Read status, 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_EXCEEDED with 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 is 410 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 in listInvites, inert, until revoked.
  • Requesting to join needs no permission, but you can't ask twice. Any signed-in user may POST /conversations/:id/join-requests for a group id they know. Asking about a group you are already in is 409 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. PATCH takes { userId, decision } because (conversation, user) is already unique and an admin working a queue has the user in hand. GET defaults to ?status=pending - the moderation queue; pass approved or denied for history. Resolving an already-resolved request is 404 JOIN_REQUEST_NOT_FOUND.
  • Joining publishes the existing participant.added event - 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 poll GET /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.

MethodPathRequest body / queryWhoResponse
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 with PATCH /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 loosening canInvite to "any member" doesn't also let a member expose the group to everyone. On a DM it is 409 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 setting visibility without thinking about policy gets you the safer of the two.
  • GET /channels returns ChannelPreviews, not conversations - { conversationId, name, participantCount, joinPolicy, createdAt, metadata, alreadyParticipant, requestPending }, most-recently-active first, paginated with the same ?limit=&cursor= keyset as GET /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. alreadyParticipant and requestPending are viewer-relative, so render "Open", "Pending", or "Join" straight from them.
  • Public means discoverable, not readable. Browsing grants no read access: GET /conversations/:id and the message routes still answer 403 FORBIDDEN_READ to a non-member. The permission layer is untouched by this feature - to read a channel you join it. And /channels is not anonymous: auth runs before routing, so no session is still 401.
  • Joining is the same discriminated union as accepting an invite. POST /conversations/:id/join gives { status: "joined", conversation, joinRequest: null } on an "open" channel and { status: "pending", conversation: null, joinRequest } on an "approval" one - both 200, so branch on status. 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 is 409 ALREADY_PARTICIPANT; a group that isn't public is 403 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 same GET /conversations/:id/join-requests queue, and an "approval" channel therefore needs the invites capability too - without it, joining is 501 INVITES_UNSUPPORTED.
  • An invite always overrides the channel's policy. The policy lives on whatever the joiner presents: a link minted without requiresApproval walks 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 publishes conversation.updated - both existing events, with the joiner as their own actorId. 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:

MethodPathPluginRequest body / queryResponse
POST/conversations/:id/typingtyping(){ isTyping?: boolean }{ ok: true }
GET/presencepresence()?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.

MethodPathRequest body / queryResponse
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-side chat.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, and name. A DM is type: "direct" with a pairKey and name: null; a group is type: "group" with pairKey: null and an optional name. Each participant carries role: "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 /conversations twice for the same pair returns the same conversation. POST /conversations/group twice creates two groups even with identical members, so persist the returned id. POST /conversations/group needs 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/:id with { "name": null } clears it; omitting name entirely is a 400, because an accidental clear is worse than an error. A group holds at most 256 participants; going over is 422 GROUP_LIMIT_EXCEEDED.
  • Participants come back in a stable order (join order, userId breaking ties) so a client can diff the list positionally.
  • Message lists are newest-first; reverse for a chronological transcript. Paginate by passing nextCursor back as ?cursor=; nextCursor: null means 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 canRead for 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 returns 501 with SEARCH_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 (not text / content), and it must be a non-empty string after trimming on both send and edit - whitespace-only is 400 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 reactions and its reply fields. reactions is grouped: [{ emoji, count, userIds }], userIds earliest-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). replyToMessageId is the stored pointer; replyTo is 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. excerpt is 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 auth hook, so a caller can only ever add or remove reactions attributed to themselves. Reacting needs write permission (like editing - other participants see it). The emoji travels in the request body on DELETE too, 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 are 400 INVALID_INPUT. It is not validated as a Unicode emoji.
  • A reaction is not a message. It gets no seq, never bumps unreadCount, and never reorders the conversation list.
  • Quote replies are flat pointers. replyToMessageId must name a message in the same conversation (else 404 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 with replyTo.deleted: true; a reply to a reply is still one hop; and the pointer is immutable, since PATCH /messages/:id only ever changes the body.
  • Thread replies use a root id. Set threads: { enabled: true } in the installation and send with threadRootMessageId. The root must be a main conversation message. A thread reply stays out of the main message page unless alsoSendToMain is true. 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 parses body, 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 is 400 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 mentions leaves 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.
  • mentions is a set, not a sequence. It reads back sorted rather than in the order you sent it. A mention is also not a message: no seq, no unreadCount, no reordering, and no SSE event of its own. Chatpack does not notify anyone and keeps no mention inbox; afterMessageMutation hands you mentions next to recipientIds so you can.
  • Forwarding copies the message. POST /messages/:id/forward writes a new message into conversationId: you as sender, the body copied verbatim, its own seq, 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 is 409 MESSAGE_DELETED.
  • forwardedFrom is 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, and role do not travel - pass fresh ones if you want them, and mentions is validated against the target.
  • role is "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 return 404 USER_NOT_FOUND; omitting the hook preserves opaque-id behavior.
  • Timestamps are Date in 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_FOUND means 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": "…" } }
StatusCode(s)When
401UNAUTHENTICATEDauth returned null (or a non-ChatpackUser)
400INVALID_INPUTbad body/query params
403FORBIDDEN_READ, FORBIDDEN_WRITE, NOT_MESSAGE_SENDERnot allowed
403NOT_CONVERSATION_ADMINa group management call by a non-admin
403NOT_PUBLIC_CONVERSATIONjoining a group that is not a public channel
404CONVERSATION_NOT_FOUND, MESSAGE_NOT_FOUND, NOT_FOUNDmissing resource/route
404INVITE_NOT_FOUND, JOIN_REQUEST_NOT_FOUNDunknown or revoked code; no such pending request
409MESSAGE_DELETEDediting a deleted message
409NOT_GROUP_CONVERSATIONa group-only call on a DM
409LAST_ADMIN_REMAININGremoving or demoting a group's only admin
409ALREADY_PARTICIPANTasking to join a group you are already in
410INVITE_EXPIREDthe link is past its expiry, or out of uses
422MESSAGE_REJECTEDa beforeMessageSend hook refused the message
422GROUP_LIMIT_EXCEEDEDa group would exceed 256 participants
422INVITE_LIMIT_EXCEEDEDthe group already holds 50 invites
500INTERNAL_ERRORunexpected server error (opaque)
501SEARCH_UNSUPPORTED, INVITES_UNSUPPORTED, CHANNELS_UNSUPPORTEDthe 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):

EventDataWhen
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:

EventDataWhen
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:

EventDataWhen
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):

EventPlugin
typing.started / typing.stoppedtyping()
presence.online / presence.offlinepresence()
receipt.delivered / receipt.readreceipts()

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.

On this page