Errors
The API returns errors as a small JSON envelope with a stable machine-readable code. There is no message field for clients to display — codes are mapped to UX strings client-side so we never accidentally ship server English into a localized UI.
Wire format
Every non-2xx response shares this shape:
Error response
{
"error": {
"code": "invalid_credentials"
}
}
That's it. The HTTP status carries the error class, the code carries the specific cause. Both are stable: codes never change spelling once they ship, and the status for a given code never moves between 4xx classes.
Status classes
- Name
2xx- Description
Success.
200for a payload,201for a created resource,204for a successful mutation with no body.
- Name
400- Description
Bad request. The body, query, or path is malformed. Always your problem.
- Name
401- Description
Unauthorized. Missing, malformed, expired, or revoked token. Also returned for a user token sent with the wrong prefix (e.g.
BotorBearer).
- Name
403- Description
Forbidden. The token is valid but isn't allowed to do this. Account state (suspended, unverified) and resource permissions (
MANAGE_MESSAGES) both surface here.
- Name
404- Description
Not found, or the resource exists but isn't visible to you. We don't distinguish — 403 would leak existence.
- Name
409- Description
Conflict. Two writes raced, or a uniqueness constraint rejected the input (username taken, etc.).
- Name
429- Description
Too many requests. The rate limiter cut you off. Back off and retry.
- Name
500- Description
The server hit something it didn't expect. These get reported to Sentry; not your problem.
Common codes
| Code | Status | Cause |
|---|---|---|
invalid_token | 401 | Token missing, malformed, expired, or doesn't match the prefix used. |
unauthorized | 401 | Endpoint requires an authenticated user and there isn't one on the context. |
account_not_active | 403 | The user is unverified, suspended, disabled, or soft-deleted. |
token_type_not_permitted | 401 | Token is valid but the wrong class for this route (e.g. bot token on a user-only route). |
insufficient_permissions | 403 | can(actor, action) returned false. See Permissions. |
insufficient_scope | 403 | Bearer token is missing an OAuth scope the route requires. |
not_found | 404 | Fallback for unknown routes and resources you can't see. |
invalid_body | 400 | Zod rejected the request body. |
invalid_query | 400 | Zod rejected the query string. |
cannot_revoke_current_session | 400 | DELETE /sessions/:id was called with the caller's own session id — use /auth/logout instead. |
internal | 500 | Unhandled exception. Already in Sentry. |
Route-specific codes (e.g. avatar_not_png, asset_too_large, invitation_expired) follow the same <resource>_<reason> convention — they're documented on each resource page.
Authentication errors are intentionally vague
Endpoints that take an email return the same response whether the email matches a user or not:
POST /v1/auth/loginalways succeeds with{ otp_id }shape — even for unknown emails.POST /v1/auth/verify-otpreturnsinvalid_tokenfor either "wrong code" or "no such account".
This is deliberate. Telling an attacker "this email exists but the code is wrong" leaks the user list one probe at a time. The cost is that you can't show "no account with that email" to legitimate users — but the OTP itself is the friendly path, since real users get a code in their inbox.
Operational vs. unexpected
The server distinguishes between two error classes internally, even though they look identical on the wire:
- Name
Operational- Description
Expected 4xx outcomes. Validation failures, missing resources, rate-limit hits, permission denials. Logged but not paged. The whole
ApiError.badRequest()/ApiError.notFound()/ApiError.forbidden()family is operational.
- Name
Non-operational- Description
Anything that escaped a
try/catch— unhandled exceptions,ApiError.internal()thrown deliberately for a known-broken codepath. These are routed to Sentry and the response is a generic500 internal.
If you're seeing repeated internal codes for the same request shape, that's a real bug — open an issue with the request id from the x-request-id response header.