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:
| Status | Code(s) | When |
|---|---|---|
| 401 | UNAUTHENTICATED | auth returned null (or a non-ChatpackUser) |
| 400 | INVALID_INPUT | bad body/query params |
| 400 | MENTION_NOT_PARTICIPANT | a mention named someone outside the conversation - nothing is stored |
| 403 | FORBIDDEN_READ, FORBIDDEN_WRITE, NOT_MESSAGE_SENDER, NOT_CONVERSATION_ADMIN | not allowed |
| 403 | NOT_PUBLIC_CONVERSATION | joining a group whose visibility is still private |
| 403 | USER_BANNED, NOT_MODERATOR, DIRECT_INTERACTION_BLOCKED | moderation said no |
| 404 | USER_NOT_FOUND, CONVERSATION_NOT_FOUND, MESSAGE_NOT_FOUND, INVITE_NOT_FOUND, JOIN_REQUEST_NOT_FOUND, REPORT_NOT_FOUND, BAN_NOT_FOUND, NOT_FOUND | missing user/resource/route |
| 409 | MESSAGE_DELETED, NOT_GROUP_CONVERSATION, LAST_ADMIN_REMAINING, ALREADY_PARTICIPANT | the resource is in the wrong state for the operation |
| 410 | INVITE_EXPIRED | the link existed but is past expiresAt or out of uses - ask for a new one |
| 422 | MESSAGE_REJECTED | a beforeMessageSend hook refused the message |
| 422 | GROUP_LIMIT_EXCEEDED, INVITE_LIMIT_EXCEEDED | the group would exceed 256 participants / the group already has 50 invites |
| 500 | INTERNAL_ERROR | unexpected server error (opaque) |
| 501 | SEARCH_UNSUPPORTED, INVITES_UNSUPPORTED, CHANNELS_UNSUPPORTED, MODERATION_UNSUPPORTED | the 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.
};