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 }
GET/conversations?limit=&cursor={ conversations, nextCursor }
GET/conversations/:id-{ conversation }
POST/conversations/:id/messages{ body, role?, metadata? }{ message } (201)
GET/conversations/:id/messages?limit=&cursor={ messages, nextCursor } - newest first
POST/conversations/:id/read{ messageId }{ ok: true }
PATCH/messages/:id{ body }{ message }
DELETE/messages/:id-{ message } (soft-deleted)
GET/streamSSE; auto Last-Event-ID on reconnecttext/event-stream

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 } } }

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.
  • Message lists are newest-first; reverse for a chronological transcript. Paginate by passing nextCursor back as ?cursor=; nextCursor: null means no more results.
  • The message text field is body (not text / content).
  • role is "user" | "assistant" | "system" (default "user") - a stored label; core never branches on it. Anything else is a 400.
  • otherUserId is not validated to exist (Chatpack never owns a users table) - validate recipients in your app.
  • 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,
    "metadata": {}
  }
}

Find-or-create a conversation:

curl -X POST /api/chat/conversations \
  -H 'content-type: application/json' \
  -d '{"otherUserId": "bob"}'
{
  "conversation": {
    "id": "conv_1",
    "pairKey": "alice:bob",
    "createdAt": "2026-07-22T19:47:47.945Z",
    "metadata": {},
    "participants": [
      { "conversationId": "conv_1", "userId": "alice", "joinedAt": "…", "lastReadMessageId": null },
      { "conversationId": "conv_1", "userId": "bob", "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",
      "pairKey": "alice:bob",
      "createdAt": "2026-07-22T19:47:47.945Z",
      "metadata": {},
      "participants": [
        {
          "conversationId": "conv_1",
          "userId": "alice",
          "joinedAt": "…",
          "lastReadMessageId": null
        },
        { "conversationId": "conv_1", "userId": "bob", "joinedAt": "…", "lastReadMessageId": null }
      ],
      "unreadCount": 2
    }
  ],
  "nextCursor": null
}

unreadCount is viewer-relative: the same conversation fetched as alice (the sender) shows 0.

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
404CONVERSATION_NOT_FOUND, MESSAGE_NOT_FOUND, NOT_FOUNDmissing resource/route
409MESSAGE_DELETEDediting a deleted message
500INTERNAL_ERRORunexpected server error (opaque)

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)

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