Chatpack
Core Concepts

Error Handling

Stable machine-readable error codes, HTTP status mapping, and how to branch on them.

Every failure carries a stable, machine-readable code - as a thrown ChatpackError from chat.api.*, and as a JSON body over HTTP.

Over HTTP

Errors are JSON with the shape:

{ "error": { "code": "FORBIDDEN_READ", "message": "…" } }

Statuses are mapped from the code:

StatusCode(s)When
401UNAUTHENTICATEDauth returned null (or a non-ChatpackUser)
400INVALID_INPUTbad body/query params
400MENTION_NOT_PARTICIPANTa mention named someone outside the conversation - nothing is stored
403FORBIDDEN_READ, FORBIDDEN_WRITE, NOT_MESSAGE_SENDER, NOT_CONVERSATION_ADMINnot allowed
403NOT_PUBLIC_CONVERSATIONjoining a group whose visibility is still private
403USER_BANNED, NOT_MODERATOR, DIRECT_INTERACTION_BLOCKEDmoderation said no
404USER_NOT_FOUND, CONVERSATION_NOT_FOUND, MESSAGE_NOT_FOUND, INVITE_NOT_FOUND, JOIN_REQUEST_NOT_FOUND, REPORT_NOT_FOUND, BAN_NOT_FOUND, NOT_FOUNDmissing user/resource/route
409MESSAGE_DELETED, NOT_GROUP_CONVERSATION, LAST_ADMIN_REMAINING, ALREADY_PARTICIPANTthe resource is in the wrong state for the operation
410INVITE_EXPIREDthe link existed but is past expiresAt or out of uses - ask for a new one
422MESSAGE_REJECTEDa beforeMessageSend hook refused the message
422GROUP_LIMIT_EXCEEDED, INVITE_LIMIT_EXCEEDEDthe group would exceed 256 participants / the group already has 50 invites
500INTERNAL_ERRORunexpected server error (opaque)
501SEARCH_UNSUPPORTED, INVITES_UNSUPPORTED, CHANNELS_UNSUPPORTED, MODERATION_UNSUPPORTEDthe configured storage adapter lacks that optional capability

The three group-specific codes are worth knowing before you hit them: NOT_CONVERSATION_ADMIN means canManage said no (default: you're not an admin); NOT_GROUP_CONVERSATION means a group-only route was called with a DM's id; LAST_ADMIN_REMAINING means the write would leave the group with zero admins, which Chatpack refuses rather than auto-promoting someone. See Permissions.

The three moderation 403s are three different scopes, easy to confuse: USER_BANNED is platform-wide and checked before routing, so it answers every route including /stream; NOT_MODERATOR means your canModerate hook said no (or you never configured one); DIRECT_INTERACTION_BLOCKED is the narrowest - one of two users blocked the other, so new DMs and direct writes are refused while their existing history stays readable.

The four 501s are per-capability and independent: an adapter can support search and invites but not channels. Check once at startup, not per call - a 501 never becomes a 200 without a code change.

The 401 body's message says why auth failed - bad hook return shape vs. missing cookie vs. unparsed cookie. Read it before changing code. And since 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 or basePath is wrong.

In server code

chat.api.* methods throw ChatpackError - they never return null for missing resources:

import { ChatpackError } from "@chatpack/core";

try {
  const message = await chat.api.editMessage({ userId, messageId, body });
} catch (err) {
  if (!(err instanceof ChatpackError)) throw err;

  switch (err.code) {
    case "MESSAGE_NOT_FOUND":
      // nothing to edit
      break;
    case "NOT_MESSAGE_SENDER":
      // only the sender may edit
      break;
    case "MESSAGE_DELETED":
      // can't edit a tombstone
      break;
    default:
      throw err;
  }
}

In the browser

@chatpack/client unwraps successful envelopes and returns expected HTTP, malformed-response, and network failures as { data: null, error }; branch on error.code instead of catching expected API failures.

const result = await chatClient.messages.send({ conversationId, body });
if (result.error?.code === "UNAUTHENTICATED") redirectToLogin();
if (result.error?.code === "FORBIDDEN_WRITE") showReadOnlyNotice();

Branch on error.code, not on the message text (messages may change; codes are stable):

const res = await fetch(`/api/chat/conversations/${id}/messages`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ body: text }),
});

if (!res.ok) {
  const { error } = await res.json();
  if (error.code === "UNAUTHENTICATED") return redirectToLogin();
  if (error.code === "FORBIDDEN_WRITE") return showReadOnlyNotice();
  throw new Error(`${error.code}: ${error.message}`);
}

const { message } = await res.json();

SSE errors

A fatal error on the stream (e.g. a 401 from your auth hook) closes the EventSource permanently - the browser will not retry. Distinguish it from a dropped connection:

events.onerror = () => {
  if (events.readyState === EventSource.CLOSED) {
    // Fatal: re-authenticate, then create a new EventSource.
  }
  // Otherwise: dropped connection - EventSource retries automatically.
};

On this page