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. 200 for a payload, 201 for a created resource, 204 for 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. Bot or Bearer).

  • 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

CodeStatusCause
invalid_token401Token missing, malformed, expired, or doesn't match the prefix used.
unauthorized401Endpoint requires an authenticated user and there isn't one on the context.
account_not_active403The user is unverified, suspended, disabled, or soft-deleted.
token_type_not_permitted401Token is valid but the wrong class for this route (e.g. bot token on a user-only route).
insufficient_permissions403can(actor, action) returned false. See Permissions.
insufficient_scope403Bearer token is missing an OAuth scope the route requires.
not_found404Fallback for unknown routes and resources you can't see.
invalid_body400Zod rejected the request body.
invalid_query400Zod rejected the query string.
cannot_revoke_current_session400DELETE /sessions/:id was called with the caller's own session id — use /auth/logout instead.
internal500Unhandled 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/login always succeeds with { otp_id } shape — even for unknown emails.
  • POST /v1/auth/verify-otp returns invalid_token for 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 generic 500 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.

Was this page helpful?