HTTP API
Error Codes
Every code value httpapi/errors.go's mapError can return, its HTTP status, and what it means. Branch on code, never on message.
Every error response is {"error": {"code": "...", "message": "..."}}. Every code below comes from mapError, with exactly two exceptions written directly: bad_request (request parsing, before any engine call) and db_unreachable (/v1/health only).
| HTTP | Code | Meaning |
|---|
| 401 | missing_auth_header | No Authorization header, or it isn't Bearer ... |
| 401 | invalid_access_token | Access token invalid or expired |
| 401 | invalid_token | Refresh token invalid |
| 401 | token_reused | Reuse detected — the whole session family is already revoked server-side. Discard every stored token and force a full login; retrying is pointless. |
| 403 | not_operator | The token is valid but carries no role: "admin" claim |
| 501 | not_implemented_on_sqlite | This deployment runs on SQLite — the entire admin console is unavailable. Returned before the token is even examined, so it can't be confused with not_operator. |
| 429 | rate_limited | Either limiter — the engine's own per-user limiter or this API's per-IP edge limiter both surface this same code |
| 401 | invalid_api_key | Mapped, but no endpoint currently authenticates a request by API key, so nothing returns it yet — see Endpoints |
| HTTP | Code |
|---|
| 401 | invalid_credentials |
| 409 | user_exists |
| 403 | account_locked |
| 400 | password_policy_violation (carries error.details, an array of stable rule codes — the only error with a details array) |
| 400 | password_breached |
| 400 | verification_token_invalid |
| 400 | verification_token_expired |
| 403 | session_not_owned |
| 404 | not_found |
| HTTP | Code |
|---|
| 400 | totp_not_enabled |
| 409 | totp_already_enabled |
| 401 | invalid_totp_code |
| 401 | invalid_pending_login |
| 400 | no_passkeys_enrolled |
| 401 | invalid_passkey_response |
| 400 | invalid_ceremony_token |
| 404 | totp_not_configured |
| 404 | passkeys_not_configured |
| 404 | magic_link_not_configured |
| 404 | recovery_codes_not_configured |
| HTTP | Code |
|---|
| 404 | api_key_not_found — an unowned key, a nonexistent key, and an already-revoked key all answer this identically, so a caller can never learn whether someone else's key exists |
| 400 | invalid_api_key_scope |
| 400 | invalid_api_key_ttl |
| 404 | api_keys_not_configured |
| HTTP | Code |
|---|
| 404 | oauth_provider_not_configured |
| 400 | oauth_state_mismatch |
| 400 | oauth_email_not_available |
| 400 | oauth_identity_verification_failed |
| 500 | oauth_link_not_configured |
| 400 | oauth_link_session_missing |
| 409 | oauth_email_conflict — see OAuth for how to resolve it |
| 409 | oauth_identity_already_linked |
| HTTP | Code |
|---|
| 400 | reserved_metadata_key |
| 400 | invalid_metadata_key |
| 404 | metadata_key_not_found |
| 404 | audit_event_not_found |
| 400 | invalid_review_status |
| 400 | invalid_review_note |
| 400 | invalid_llm_provider |
| 400 | invalid_database_provider |
| 400 | invalid_ask_ai_widget |
| 400 | database_role_not_read_only — the supplied database credential could write; refused rather than stored |
| 400 | database_role_unverified — the write-check itself failed to reach a conclusion (timeout, connection error); treated as a failure, never as a pass |
| 409 | setting_undecryptable — the row exists but can't be decrypted with the current SETTINGS_ENCRYPTION_KEY, most likely because that key changed since it was written; clearing the setting still works without the old key |
| HTTP | Code |
|---|
| 404 | not_configured |
| 404 | ask_ai_widget_disabled |
| 403 | origin_not_allowed |
| 400 | invalid_question (empty, or over 500 characters) |
| 400 | question_not_answerable — covers two different internal causes (a scope the deployment configured, or the engine's own fail-closed default for an entity it won't bind to one user) and deliberately never says which, since naming either would describe this deployment's schema to whoever is asking |
| HTTP | Code | Notes |
|---|
| 400 | bad_request | Request parsing only, before any engine call |
| 404 | not_configured | Heavily overloaded on purpose — the three AI settings endpoints, the ask-ai widget, and the digest/webhook/logging history endpoints all use this same code when their underlying feature is simply off. The convention throughout this API: a feature the deployment hasn't enabled answers 404, never 500, so a client can hide the option instead of reporting a fault. |
| 503 | db_unreachable | /v1/health only |
| 500 | internal_error | Anything unmapped. Deliberately vague — the real error is logged server-side, never echoed to the caller |
token_reused — the session family is already gone. Clear every locally stored token and force a fresh login; there is no other token from that login that will still work.
account_locked — unlike invalid_credentials, this confirms the account exists. Accepted as a deliberate tradeoff for clearer lockout messaging; don't build a UI that treats this as equivalent to a wrong password.
not_operator vs not_implemented_on_sqlite — both mean "this admin request didn't work," but they mean different things. The second is returned before the token is even checked, so it can never be confused with the first: not_operator says this caller lacks access on a deployment that has a console; not_implemented_on_sqlite says the deployment has no console at all, regardless of who's asking.