Error codes
Every error code, its status and meaning, for the management, login and data APIs.
Every error response is JSON with the same outer shape:
{ "error": { "code": "name_taken", "message": "a branch with this name exists" } }code is stable and snake_case; match on it. message is for people and can change. A few codes add keys inside error (listed below). The HTTP status says the class of failure; the code says which one.
The data API adds detail, hint and sqlstate to errors that come from Postgres. See Data API errors.
Management, login and gateway codes
These come from the management API, the login API (the dashboard sign-in and app users) and the gateway. Where a code has two statuses, each row says when.
Request and authentication
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | A path id isn't a valid UUID, the body isn't well-formed JSON, or the query string is malformed. |
| 415 | invalid_request | The request isn't Content-Type: application/json. |
| 422 | invalid_request | The body is JSON but has the wrong fields, a field is missing or out of range, or a name is invalid. |
| 401 | unauthorized | No credential, or a bad token or key. |
| 409 | tenant_required | Sign-in (/auth/v1/sign-in) for a member of several tenants with no ?tenant=. Sent only after correct credentials; error.tenants lists the choices. |
| 403 | forbidden | The tenant_id in the body isn't the caller's tenant. |
| 404 | not_found | The resource doesn't exist, or belongs to another tenant (both answer the same). The gateway also answers not_found for an unknown host or route. |
| 405 | method_not_allowed | The gateway has no handler for this method on this path; the Allow header lists the methods it has. |
| 413 | too_large | The body is over the limit (2 MB on the management API, 8 KiB on the dashboard login endpoints, 64 KiB on the app-user endpoints). |
| 429 | rate_limited | Too many attempts on a login endpoint. |
| 500 | internal | Unexpected; details are only in the server logs. |
| 502 | unavailable | A service behind the API answered something unexpected, or failed. |
| 503 | unavailable | A service behind the API (the compute, the user service, the identity service) can't be reached right now. |
| 504 | timeout | A service didn't answer in time. The gateway also answers timeout with 408 when a request times out. |
Members, access and invites
Any project route can answer the first four rows. See Access per route for which level each route needs.
| Status | Code | Meaning |
|---|---|---|
| 403 | forbidden | The caller's grant is too low, or the Postgres role asked for isn't in their effective set. error.required names what is needed: a tenant, project or branch level (tenant admin, developer, admin), instance admin, owner, role:<name>, or connect (the member's database role may not connect to that Postgres database). A project the caller has no grant on answers 404 not_found instead. |
| 403 | no_access_yet | The caller is a member with no grants anywhere. A tenant admin must grant them a project. |
| 403 | pending_approval | The caller signed up and waits for a tenant admin to approve them. |
| 403 | member_suspended | A tenant admin suspended the caller's membership. Every route refuses them except GET /v1/me; a tenant admin can reinstate them. |
| 403 | member_offboarded | The caller was offboarded: removed or declined, which ends their sessions and bans their account. Every route refuses them except GET /v1/me, until they accept a new invite. |
| 409 | personal_login_exists | The caller already has a live personal login on this database. Reset its password, or delete it and create a new one to change the name. |
| 403 | not_available_in_platform_mode | The feature (or self-sign-up) isn't designed for platform mode yet. |
| 403 | sign_up_closed | Open sign-up is off and the tenant already has members. |
| 403 | setup_code_invalid | The instance has no member yet and its first sign-up needs the setup code that chelabase-init secrets printed; it was missing or wrong. No account is created. |
| 409 | last_tenant_admin | The change would leave the tenant without an active tenant admin (demoting or removing the last one). |
| 409 | link_not_allowed | A link invite (delivery: "link") for someone who already has a password or a verified email, or whom another tenant holds. Send it by email instead; error.email_available says whether email delivery works now. |
| 409 | signup_pending | The person signed up and waits for approval. Approve or decline the sign-up instead of inviting them; error.member_id names the member. |
| 409 | invite_closed | The invite was revoked, accepted or replaced since. Reload the list. |
| 410 | invite_expired | The invite's code has expired. Ask for a new invite. |
| 410 | invite_revoked | The invite was revoked. |
| 503 | invite_incomplete | The code was accepted but a later step failed. error.accept_ticket lets the page retry without the spent code. |
| 503 | member_role_reconcile_failed | The caller's Postgres role on this branch couldn't be prepared right now. Comes with Retry-After; try again. |
| 503 | compute_tls_failed | Chelabase couldn't open a verified TLS connection to this branch's compute, or the compute refused the connection's address or client certificate. Comes with Retry-After; try again. If it persists, the operator should check the compute CA and the orchestrator's client certificate. |
Two more refusals use general codes. A grant or override below Admin that lists an owner-level, LOGIN or reserved role, or a role whose chain reaches one, is 422 invalid_request, and so is a grant at any level (Admin too) whose role chain holds ADMIN on another role; the message names the role, its chain and the database (error.role, error.chain, error.database_id), and invites and approvals also name the grant's project (error.project_id). A Developer who acts as such a role, or creates a data key bound to one, gets 403 forbidden with required: "admin".
Projects and databases
| Status | Code | Meaning |
|---|---|---|
| 409 | name_taken | A database or branch with this name exists. |
| 409 | project_deleting | The project is being deleted. |
| 409 | database_not_ready | The database isn't ready, or is being deleted. |
| 409 | already_deleting | The database is already being deleted. |
| 409 | data_api_disabled | The Postgres database isn't served by the data API; enable it first. |
| 409 | data_api_not_enabled | The data API is off for the Postgres database on a branch. |
| 409 | conflict | The change conflicts with the current state (for example the main database can't be removed from the data API, or the worker refused the change; the message says why). |
| 502 | unavailable | The worker failed to apply the change. |
| 503 | worker_unavailable | The worker can't be reached. |
| 503 | not_applied | The change is stored but not applied yet; it will be retried. |
Branches
| Status | Code | Meaning |
|---|---|---|
| 409 | branch_not_ready | The branch isn't ready, or is busy or being deleted. |
| 409 | backup_branch | A restore backup can't take this operation (no compute, can't be protected or rewritten). |
| 409 | main_branch | Main can't be deleted and has no parent to reset to. |
| 409 | has_children | The branch (or one of its backups) has child branches. |
| 409 | branch_protected | The branch is protected. On a branch route (reset, restore, delete): unprotect it first. From the table API, and the SQL editor's guard refusal on a protected branch (Postgres error 42501 with hint chelabase:branch_protected): schema changes there need an Admin; ask one, or make the change on a branch of your own and merge it. |
| 409 | branch_cap_reached | The database has max_branches. |
| 409 | credentials_unavailable | The branch has no endpoint, or its owner credentials are missing. |
| 500 | credentials_unavailable | Stored credentials can't be opened with the current secrets key. |
| 409 | owner_password_unknown | The owner's password was set in SQL; reset it first. |
| 409 | branch_merge_busy | A schema merge holds this branch as its source or target (it is rehearsing, applying, finishing, or failed half applied in its online phase), or a rehearsal's throwaway branch still hangs off it, so reset, restore and delete are refused. error.merge_id names the merge; error.rehearsal_branch_id names the throwaway branch. |
| 409 | review_changed | The branch's review (code owned by roles other than the owner or a superuser that can run in someone else's session) differs from the hash the request carries: protecting without review_hash while the list isn't empty, or with a stale one, and a stale hash on acknowledge or fix. error.review carries the current review (its items and hash). |
Table API
| Status | Code | Meaning |
|---|---|---|
| 403 | permission_denied | Postgres refused a statement for the role you act as (SQLSTATE 42501); the same code as the data API's. |
| 409 | has_dependents | A drop has dependents and the request doesn't confirm a cascade (dry run or not); nothing was changed. error.dependents lists what a cascade would also drop and its hash, to send as cascade.dependents_hash. |
| 409 | dependents_changed | The objects a cascade would drop changed since the dry run. |
| 409 | index_build_failed | A concurrent index build failed after creating its index; that invalid index was dropped. |
| 409 | lock_timeout | A lock the change needs was held past lock_timeout_ms. |
| 409 | conflict | Existing rows break a unique, foreign-key or exclusion constraint (23505, 23503, 23P01), or a deadlock or serialization failure (40P01, 40001; safe to retry). |
| 400 | constraint_violation | Rows break NOT NULL or a check, or a value is out of range or can't be read as its type (23502, 23514, class 22). Not the app users' 422 constraint_violation, which is another endpoint's. |
| 400 | schema_change_failed | Postgres refused a table API statement (any SQLSTATE not mapped to another code); the error carries sqlstate, message, detail, hint and position. |
| 409 | privileges_not_applied | A privilege change ran, but the privileges read back afterwards differ from the requested end state (for example a grant Postgres skipped because the role running it couldn't give it). Nothing of the change was kept. |
| 409 | privileges_changed_since_read | An object's privileges, or a function's signature, changed since the privileges read the request was built from. Read them again and retry. |
| 422 | exposes_data_api | "All rows" was refused because the data API can reach the table through that role; error.chain shows how. |
Schema merges
Merge routes also answer the general codes above: not_found for an unknown merge (or another tenant's, or one of another database), branch_not_ready on a start, rehearse, apply and retry (a branch of the merge isn't ready), backup_branch and invalid_request on a start (a step id with :, ., a control character or phase1 is a 422 invalid_request on a save), and conflict with 409 when the merge is in a state that can't take the request (for example editing a merge that is applying, or exporting a plan before there is one). See Merging schema between branches.
| Status | Code | Meaning |
|---|---|---|
| 409 | merge_open_exists | The branch and its parent already have an open merge for this Postgres database. Raised on start, and on recompute when a newer open merge exists. error.merge_id names it. |
| 409 | merge_busy | Another merge is changing the target branch: applying, online, finishing, or failed half applied on it (error.merge_id). Raised on start, apply and retry. A start also answers it, with Retry-After: 1, when the pair's open merge finished a moment earlier: try again. |
| 409 | merge_version_conflict | version in a save, a rehearse or an apply isn't the merge's. error.current carries the current merge. |
| 409 | merge_not_rehearsed | Apply needs a rehearsed merge, and this one is in another state, or changed while the request ran. |
| 409 | merge_stale | The merge is stale: a schema changed since the merge was computed (found by the rehearsal, or by the apply's check of the target). Recompute it. |
| 409 | merge_conflicts_open | A conflict has no resolution; the message lists their ids. Raised on rehearse and apply. |
| 409 | merge_drops_unacknowledged | A step that loses data, gives a role BYPASSRLS or owner-level, or renames a role, has no acknowledgement; the message lists the items. |
| 409 | merge_confirm_required | The target is protected, and apply needs confirm: true. |
| 409 | branch_merge_busy | See Branches. |
| none | merge_base_unavailable | Not an HTTP error. It is the start of the error of a merge left as a draft with no analysis because its base can't be read (outside the history window and not a branch point the storage layer keeps, or stored by another model version): the text is merge_base_unavailable: followed by the reason. Recompute with rebaseline: true. |
A merge that fails while running (failed, stale) shows its reason in the merge's own error and failed_phase, not as an HTTP error. The worker starts the error with a code where it knows the cause: merge_stale: (the target changed; nothing of the plan's transaction was applied), merge_target_changed: and merge_target_gone: (the plan's transaction is no longer on the target, or the target is gone: failed with no phase), merge_half_applied: (a recompute found the plan's transaction on the target: failed in online), and merge_resolution_refused: (a saved choice was refused: a draft). An apply interrupted before its transaction committed returns to rehearsed with an error that starts "interrupted before the plan's transaction committed". Merging schema lists them.
Login roles and data API keys
| Status | Code | Meaning |
|---|---|---|
| 409 | role_exists | A login role with this name exists. |
| 409 | role_cap_reached | The branch has its maximum number of login roles. |
| 409 | role_in_use | Data API keys still use the role. error.keys lists them as {id, name}. |
| 422 | invalid_role_name | The name is empty, too long (over 63 bytes), has a NUL byte, or doesn't fit the naming rules. |
| 422 | reserved_role_name | The name is reserved, or the role is the owner, which can't be deleted. |
| 422 | role_attribute_not_allowed | An attribute the roles API doesn't take: a connection limit under -1. Other attributes outside the allowed set (LOGIN, owner-level, INHERIT, CONNECTION LIMIT, VALID UNTIL, BYPASSRLS, memberships) are refused as invalid_request unknown fields. |
| 422 | role_membership_not_allowed | A membership in a role Chelabase keeps for itself (the owner, authenticator, anon, authenticated, neon_superuser, cloud_admin, chelabase_…, pg_…), in the role itself, in a role that doesn't exist or the owner can't administer, or in a group that is itself a member of one of those, at any depth. |
| 422 | invalid_successor | A role delete's successor can't receive the role's objects: it's the role itself, the owner, a reserved role (pg_…, cb_m_…, chelabase_… other than chelabase_orphaned, the platform's own), a personal login, owner-level (directly or through a membership), a role the owner can't SET ROLE to, or one that doesn't exist. Omit successor to hand them to chelabase_orphaned. |
| 409 | exposure_unconfirmed | A role delete to a named successor would leave SECURITY DEFINER routines or views without security_invoker, granted to other roles, running with the successor's rights, and confirm isn't the current token. error.exposures lists them as {database, kind, identity, grantees} and error.confirm carries the current token (also answered by GET …/roles/{role}/delete-preview): review them, then delete again with ?confirm=<token>. chelabase_orphaned never needs one. |
| 403 | role_refused | The worker refused the role (on the SQL editor and console routes). |
| 422 | role_refused | The worker refused the role when a key is created (it doesn't exist, or it's owner-level: superuser, CREATEROLE, CREATEDB, REPLICATION or a neon_superuser member; the message says which). BYPASSRLS alone is allowed. |
| 500 | role_privilege_mismatch | The role's privileges in the database don't match what Chelabase recorded. |
| 409 | public_key_exists | The branch already has a public key. |
| 409 | public_key_not_revocable | A public key can only be rotated. |
App users
| Status | Code | Meaning |
|---|---|---|
| 404 | app_users_disabled | App users were never enabled for the project. |
| 409 | app_users_disabled | App users aren't enabled (management API and login API). |
| 409 | app_users_has_users | The users table has users, so app users can't be disabled. |
| 409 | app_users_table_exists | The users table exists already. |
| 409 | app_users_location_fixed | The users table's location can't change. |
| 409 | email_taken | Another user has this email. On sign-up this happens only when email verification is off. |
| 401 | invalid_credentials | Wrong email or password, or the current password is wrong. |
| 401 | refresh_token_invalid | The refresh token is invalid. |
| 401 | refresh_token_reused | The refresh token was used already; the session is revoked. |
| 401 | user_token_invalid | No user token, or an invalid one. |
| 403 | email_not_verified | The email isn't verified. |
| 403 | user_inactive | The user is deactivated or locked. |
| 410 | sign_in_expired | Studio's sign-in (not an app-user flow): the sign-in has expired or is unknown. |
| 400, 422 | invalid_code | The verification or reset code is wrong or expired. The dashboard login API answers 400; the app-user API answers 422. |
| 400, 422 | weak_password | The password doesn't meet the policy. Same split: 400 on the dashboard login API, 422 on the app-user API and the management API. |
| 422 | unknown_column | Sign-up data names a column the users table doesn't have or sign-up can't set. |
| 422 | constraint_violation | The users table refused the row. error.constraint names the constraint. |
| 503 | identity_unavailable | The identity service that holds the project's users can't be reached. Try again. When a deactivate or delete answers this, the user's sessions were already revoked. |
SQL editor
| Status | Code | Meaning |
|---|---|---|
| 409 | sql_confirm_required | The guard held statements back. error.statements lists them; send again with confirm: true to run them. |
| 409 | run_id_in_use | You have a run with this id in flight. |
| 429 | sql_run_limit | You have 4 runs in flight, or the Postgres database has 4. Comes with Retry-After. |
| 422 | schema_too_large | The schema is over 8 MiB; use keyword completion. |
| 409 | saved_query_name_taken | You have a saved query with that name. |
| 409 | saved_query_conflict | version is stale. error.current carries the current row. |
| 409 | saved_query_cap | You already keep 500 saved queries (the cap is per user and database). |
Data API codes on the gateway
The gateway authenticates data API requests before they reach the data API, and answers with the data API's error body. These codes come from the gateway, with the statuses below.
| Status | Code | Meaning |
|---|---|---|
| 401 | unauthorized | The key is missing, invalid or revoked, or an endpoint that needs a secret key got another kind (or a public one got a secret key). |
| 401 | user_token_invalid | The Chelabase-User token is missing, malformed or invalid. |
| 401 | user_token_expired | The Chelabase-User token has expired. |
| 401 | user_session_revoked | The user's session was revoked. |
| 400 | user_token_with_secret_key | A Chelabase-User token came with a secret key. |
| 429 | rate_limited | More than 20 lookups per second of keys the gateway doesn't know, from one IP address. |
A bad user token never falls back to the key's own role.
Data API errors
The data API (POST /data/v1/query, GET /data/v1/schema) answers with:
{
"error": {
"code": "conflict",
"message": "duplicate key value violates unique constraint \"users_email_key\"",
"detail": "Key (email)=(a@example.com) already exists.",
"sqlstate": "23505"
}
}detail, hint and sqlstate are present only on a database error that Chelabase maps to a code, and carry Postgres's own text. Everything else has code and message only. A 503 and a 500 never show Postgres's text; the 500 message carries a request id and the details go to the log with it.
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_query | The body isn't a valid query or doesn't plan: syntax error, bad filter, wrong types, empty select or returning, an update or delete with neither where nor all. |
| 400 | unknown_column | A column that doesn't exist or that the key's role can't reach. |
| 400 | invalid_value | Postgres rejected a value: not-null (23502) or check (23514) violation, or any type or value error (class 22). |
| 400 | raised | A function raised an exception (RAISE EXCEPTION, P0001). |
| 400 | user_token_with_secret_key | A Chelabase-User token came with a secret key. |
| 401 | unauthorized | The key is missing, unknown or revoked, or its branch is gone. |
| 401 | user_token_invalid | The Chelabase-User token is invalid. |
| 401 | user_token_expired | The Chelabase-User token has expired. |
| 401 | user_session_revoked | The user's session was revoked. |
| 403 | permission_denied | Postgres refused the role (42501); the same code as the table API's. |
| 403 | role_not_allowed | A secret key's role is owner-level or has system privileges: superuser, CREATEROLE, CREATEDB, REPLICATION, or a member (at any depth) of a superuser role, neon_superuser, chelabase_admin or the pg_*_server_* roles: what key creation refuses. Take those away, or use another role (BYPASSRLS alone is allowed). |
| 404 | unknown_table | A table or view that doesn't exist or that the key's role can't reach. |
| 404 | unknown_function | A function that doesn't exist, can't be called through the API, or that the key's role can't execute. |
| 404 | unknown_database | The database doesn't exist, isn't served, or isn't on the key's allow-list. One text for all three, so a key can't discover database names. |
| 404 | unknown_role | Studio's console only: the chosen role doesn't exist (any more). |
| 409 | conflict | A unique (23505), foreign-key (23503) or exclusion (23P01) violation. |
| 409 | retry | A serialization failure or deadlock (40001, 40P01). Safe to run again. |
| 413 | too_large | The body is over 1 MiB, or the response is over the response limit (CHELABASE_REST_MAX_RESPONSE_BYTES, default 16 MiB). |
| 429 | rate_limited | See the gateway table above. |
| 500 | internal | Unexpected. The message includes a request id; check the logs. |
| 503 | unavailable | The database can't be reached right now. See below. |
| 504 | timeout | A statement ran past its timeout (57014), or the transaction timeout passed (25P04). Both follow statement_timeout_ms. |
On a data API route the gateway also answers an unknown path (404) or a wrong method (405) with invalid_query, and the 405 carries Allow.
503 and Retry-After
Every 503 from the data API carries Retry-After: 1 when the gateway answers it, 2 when the data API service does. Causes:
- the branch's database can't be reached, refuses the connection, or is out of connections or resources (Postgres classes 08, 28 and 53, and 57P01 to 57P03);
- no connection to the branch in time, or too many connections in use;
- the service can't look up the branch, or the branch isn't ready;
- the key's role can't be used right now (dropped, or its grant is gone until the bootstrap runs again);
- the data API's schema is missing; it will be reinstalled;
- the gateway can't check the key or the user's session.
The 503 message is fixed text.
A response that didn't come from the data API (a proxy error page) has no error body from it. The gateway then builds one from the failure: 502 is unavailable, 504 and 408 are timeout, 400 is invalid_request, 413 is too_large, anything else is internal.
For how to send queries and handle these errors, see the Data API guide.