Reference
Error codes
Every error code the API and CLI can return, what it means, and how to handle it.
Every 4xx response carries {ok: false, error: {code, message, details?}}. Branch on code (stable), not message (free-text, may change).
The CLI maps each HTTP status family to a documented exit code (see CLI reference). The mapping below summarizes the canonical pairings.
This page is generated. To add a new code, edit the canonical error-code list in the VibeHost source and run
pnpm errors:generate.
Auth & sessions
| Code | HTTP | CLI exit | Meaning |
|---|---|---|---|
UNAUTHENTICATED | 401 | 2 | — |
EMAIL_NOT_VERIFIED | 403 | 2 | — |
TOKEN_NOT_FOUND | 400 | 3 | Single-use token failure modes — shared between email-verification (#125) and password-reset (#126) by design. Both flows use the same hash-then-claim shape, both surface "expired / used / not found" as the only end-user-actionable failure modes, and the dashboard renders the sam |
TOKEN_EXPIRED | 400 | 3 | — |
TOKEN_ALREADY_USED | 400 | 3 | — |
IDENTITY_PROVIDER_MISMATCH | 409 | 3 | OAuth identity collision: the user already has an identity for this provider but with a different sub. Surfaces when, e.g., a user signs in via Google with a different Google account than the one previously linked to the same email. Caller should ask the user to unlink the exis |
TURNSTILE_TOKEN_MISSING | 400 | 3 | Cloudflare Turnstile gate rejected the signup attempt. Two codes so the HTTP status is consistent with the wire-level meaning: TURNSTILE_TOKEN_MISSING — request didn't include a token (400). TURNSTILE_FAILED — token was rejected by siteverify (403). The CLI matches on either to s |
TURNSTILE_FAILED | 403 | 2 | — |
REAUTH_REQUIRED | 401 | 2 | Sensitive auth-change endpoint (e.g. POST /account/set-password) requires a recent re-authentication on the current session within REAUTH_WINDOW_MS (5 min). Outside the window the route returns 401 with this code so the dashboard can prompt for a fresh login and the CLI can exit |
PASSWORD_ALREADY_SET | 409 | 3 | POST /account/set-password called on a user whose passwordHash is already set. Use /account/change-password instead. 409. |
INVALID_CREDENTIALS | 401 | 2 | POST /account/change-password supplied wrong oldPassword. 401. Distinct from UNAUTHENTICATED (which means "no/expired session") so the dashboard can show "wrong current password" without bouncing the user to /login. Same code path as login's bad-creds — also bumps auth.login.fail |
PASSWORD_SAME_AS_OLD | 409 | 3 | POST /account/change-password where newPassword === oldPassword. Cheap defence against accidental no-op submissions (and against a confused user thinking the form took it but it actually didn't change anything). 409. |
WEBAUTHN_REGISTRATION_FAILED | 400 | 3 | WebAuthn / passkey error codes (#193). The shapes split by HTTP status so the dashboard can distinguish "your authenticator gave a bad signature" from "we never registered that credential". 400 — registration response failed structural / cryptographic verification (bad attestatio |
WEBAUTHN_VERIFICATION_FAILED | 401 | 2 | 401 — login response failed verification (bad signature, challenge mismatch, counter regression). Also returned for the silent "credential was revoked" case, since revealing "this credential exists but we revoked it" is a useful enumeration signal we'd rather not give an attacker |
WEBAUTHN_CREDENTIAL_NOT_FOUND | 404 | 3 | 404 — credentialId in the assertion is unknown to us. The lookup in finishLogin filters revoked_at IS NULL, so two cases collapse here: credential never registered AND credential revoked. This is intentional — both should look identical to the caller so an attacker can't distin |
RECOVERY_CODE_INVALID | 401 | 2 | 401 — recovery code didn't match any active row for the asserted email, OR the email itself doesn't exist. Both branches return the same code (and matched-work latency in the service) so the attacker can't enumerate users via the recovery-code endpoint. |
RBAC & grants
| Code | HTTP | CLI exit | Meaning |
|---|---|---|---|
FORBIDDEN | 403 | 2 | — |
INVALID_GRANT_TARGET | 400 | 3 | Workspace / RBAC-related. |
CROSS_WORKSPACE_GRANT | 400 | 3 | — |
LAST_OWNER | 409 | 3 | — |
SELF_GRANT_FORBIDDEN | 403 | 2 | — |
TOKEN_SCOPE | 403 | 2 | — |
TOKEN_WORKSPACE_MISMATCH | 403 | 2 | Token's workspaceId doesn't match the target workspace. Distinct from TOKEN_SCOPE (wrong permission level) so callers can fail loud rather than silently retrying when the user's config points at the wrong host. |
TOKEN_TEAM_MISMATCH | 403 | 2 | SP-2.5 analogue: token's teamId doesn't match the target team. PATs are minted with a specific teamId; using one against a different team (even one the holder is a member of) is a scope-broadening attempt and gets 403 rather than silently widening. Browser sessions are exempt — t |
NOT_PLATFORM_ADMIN | 403 | 2 | Platform-admin gate (apps/admin / /admin/v1/*). Distinct from the generic FORBIDDEN so the admin frontend can render /forbidden with copy pointing at the platform:grant CLI, and audit dashboards can isolate "non-admin tried to hit the admin namespace" from ordinary cross-team acc |
PLATFORM_VIEWER_READONLY | 403 | 2 | Platform admin with role='viewer' attempted a non-GET on /admin/v1/*. Phase 1 has no writes so this is mostly defensive, but the middleware enforces it now so adding a write endpoint later doesn't accidentally let viewers through. |
ACCESS_REQUEST_PENDING | 409 | 3 | Request-access flow (Google-Drive-style 403 → "Request access"). 409 — a pending request from this requester for this app already exists; the partial unique on (app_id, requester_user_id) WHERE status='pending' is the source of truth. Service surfaces this so the dashboard can di |
ACCESS_REQUEST_ALREADY_GRANTED | 409 | 3 | 409 — requester already has a grant on this app (most-recent request was approved). Distinguishes the "you already have access" path from the throttle errors so the UI can reason about it. |
ACCESS_REQUEST_COOLDOWN | 429 | 6 | 429 — most-recent request was denied within DENY_COOLDOWN_MS (24h). Body includes details.retryAt so the UI can render a countdown without parsing the message. |
BROWSER_SESSION_REQUIRED | 403 | 2 | ── Browser-session gate (deepsec #389, epic C) ────────────────── 403: route is dashboard-only ("authorized apps" settings page, session-management endpoints, etc.) and must reject every non-browser credential class — CLI / device-flow tokens, MCP OAuth bearers, PATs — even when |
USER_NOT_FOUND | 404 | 3 | 404: CLI's --user-email flag couldn't resolve the email to a user id in the target workspace. Distinct from generic NOT_FOUND so the CLI can render a precise hint ("That email isn't a member of <workspace>.") without string-matching. |
Personal access tokens
| Code | HTTP | CLI exit | Meaning |
|---|---|---|---|
PAT_NOT_ALLOWED | 401 | 2 | ── Personal Access Tokens (#PAT) ───────────────────────────────── 401: route doesn't accept PATs (e.g. the PAT management endpoints themselves — preventing privilege escalation where a leaked read-only PAT mints a full-access one). |
PAT_SCOPE_INSUFFICIENT | 403 | 2 | 403: PAT presented a valid token but lacks the required action scope. details: { required: PatScope }. |
PAT_RESOURCE_NOT_ALLOWED | 403 | 2 | 403: PAT is restricted to specific resource IDs and the target isn't one of them (or the underlying resource was deleted after the PAT was issued). details: { resourceType, resourceId, allowed }. |
PAT_CAP_REACHED | 409 | 3 | 409: user already has the max active PATs (20) in this workspace. |
PAT_INVALID_SCOPE | 400 | 3 | 400: scope string in create request isn't a member of PAT_SCOPES. Reached only when a malformed client bypasses the typed enum; normal callers fail at Zod parse. |
PAT_INVALID_RESOURCE_CONSTRAINT | 400 | 3 | 400: resources references a resource type without the matching action scope, or full-access PAT (scopes: null) tried to set resources. |
PAT_INVALID_RESOURCE_ID | 400 | 3 | 400: resources.apps[i] is not an app in this workspace. |
Resources & quota
| Code | HTTP | CLI exit | Meaning |
|---|---|---|---|
NOT_FOUND | 404 | 3 | — |
CONFLICT | 409 | 3 | — |
RATE_LIMITED | 429 | 6 | — |
QUOTA_EXCEEDED | 402 | 3 | — |
PLAN_LIMIT_EXCEEDED | 402 | 3 | — |
OG_WATERMARK_TOGGLE_REQUIRES_PAID | 402 | 3 | Removing the OG watermark (disabled=true) requires a paid plan. Setting disabled=false (re-enabling the watermark) is always allowed. |
SERVICE_UNAVAILABLE | 503 | 4 | 503 — a stack-level secret the route depends on is missing or in a transitional state, so the request would fail in a way that retrying later (once the operator finishes configuring it) succeeds. Examples: env-var writes when ENV_VAR_ENCRYPTION_KEY hasn't been set yet (pre-rollou |
INTERNAL | 500 | 1 | — |
CONCURRENT_MODIFICATION | 409 | 3 | Your request raced with another writer on the same record. Retry the request — the conflict is transient. |
INVALID_CURSOR | 400 | 3 | Cursor-paginated list endpoints: the cursor query param could not be decoded or its sort key doesn't match the current sort param. 400 — caller should discard the cursor and start from page 1. |
EXPORT_LIMIT_EXCEEDED | 400 | 3 | 400: audit-log export filter matched more rows than the export endpoint will stream in a single response (default 10k). The caller should narrow the date range (since/until) and retry. Distinct from VALIDATION_FAILED so the dashboard/CLI can render a precise hint ("Filter matches |
Deploy & runtime
| Code | HTTP | CLI exit | Meaning |
|---|---|---|---|
VALIDATION_FAILED | 400 | 3 | — |
TARBALL_INVALID | 400 | 3 | — |
UPSTREAM_FAILED | 502 | 4 | — |
UNSUPPORTED_RUNTIME | 400 | 3 | — |
RELEASE_UNAVAILABLE | 503 | 4 | — |
RELEASE_GONE | 410 | 4 | — |
DRAFT_FILE_NOT_FOUND | 404 | 3 | MCP server-side draft file tools. |
EDIT_NO_MATCH | 400 | 3 | edit_file: oldString not present in the file. |
EDIT_NOT_UNIQUE | 400 | 3 | edit_file: oldString occurs >1 time and replaceAll was false. |
CONTENT_TOO_LARGE | 400 | 3 | create_file/edit_file: content exceeds the 1 MB per-file soft cap. |
Custom domains
| Code | HTTP | CLI exit | Meaning |
|---|---|---|---|
RECLAIM_LOST_RACE | 409 | 3 | Custom-domain reclaim race: two workspaces both passed DNS verification for the same hostname concurrently and the loser's commitReclaim hit the partial unique index. Distinct from a plain CONFLICT so CLI / agents can recognise it ("the other side just won; refetching will show y |
DOMAIN_VERIFY_RECORD_NOT_FOUND | 400 | 3 | Custom-domain verify() failure granularity. Pre-#866 these all collapsed into VALIDATION_FAILED with the same human message, which made the "why isn't this working?" customer-support surface bigger than it needed to be. Each code maps to one distinct root cause + actionable next |
DOMAIN_VERIFY_RECORD_MISMATCH | 400 | 3 | — |
DOMAIN_VERIFY_DNS_TIMEOUT | 400 | 3 | — |
Skill registry
| Code | HTTP | CLI exit | Meaning |
|---|---|---|---|
INSTALL_TOKEN_NOT_FOUND | 404 | 3 | Skill install-token failure modes. The revoked/expired split is deliberate — the CLI's native fetcher silently re-mints on INSTALL_TOKEN_EXPIRED but fails loudly on INSTALL_TOKEN_REVOKED. See spec docs/superpowers/specs/2026-05-13-vibehost-skill-registry-design.md for the retry-v |
INSTALL_TOKEN_REVOKED | 403 | 2 | — |
INSTALL_TOKEN_EXPIRED | 410 | 4 | — |
INSTALL_TOKEN_WORKSPACE_MISMATCH | 403 | 2 | Token's stored workspaceId doesn't match the request's host → someone tried to replay the token at a different workspace's hostname. Distinct from REVOKED / NOT_FOUND so CLI retry logic can fail loud (a host mismatch is not an "out-of-date hostname, refresh me" condition; the use |
INSTALL_TOKEN_QUOTA_EXCEEDED | 402 | 3 | — |
INSTALL_URL_ADMIN_ONLY | 403 | 2 | POST /install-tokens called with forEmail by a non-admin. |
INSTALL_URL_FOR_NON_MEMBER | 400 | 3 | forEmail resolved to no workspace-member user. Admin-on-behalf-of can only mint for someone already in the workspace; minting for an outsider would create an orphan token. |
Redeem & referral
| Code | HTTP | CLI exit | Meaning |
|---|---|---|---|
REDEEM_INVALID | 400 | 3 | Redeem code (#553). Distinct codes so the CLI / dashboard can show a precise message and so funnel analytics can bucket failure reasons per batch. |
REDEEM_NOT_YET_VALID | 400 | 3 | — |
REDEEM_EXPIRED | 410 | 4 | — |
REDEEM_EXHAUSTED | 409 | 3 | — |
REDEEM_DUPLICATE | 409 | 3 | — |
REDEEM_RATE_LIMITED | 429 | 6 | — |
REDEEM_NOT_FOUND_OR_REVOKED | 404 | 3 | Admin revoke: code not found or already soft-deleted. |
REFERRAL_INVALID | 400 | 3 | Referral binding (#548). Split so the bind endpoint can return a precise reason (invalid code / self-referral / already bound / rate limited) for funnel analytics, mirroring the REDEEM_* split. |
REFERRAL_SELF | 400 | 3 | — |
REFERRAL_ALREADY_BOUND | 409 | 3 | — |
REFERRAL_RATE_LIMITED | 429 | 6 | — |
REFERRAL_CODE_TAKEN | 409 | 3 | — |
REFERRAL_CODE_INVALID | 400 | 3 | — |
REFERRAL_CODE_LIMIT | 409 | 3 | — |
REFERRAL_RENAME_RATE_LIMITED | 429 | 6 | — |
REFERRAL_CREDIT_NOT_FOUND | 404 | 3 | Referral credit claim (#referral-claim). Split so the claim endpoint returns a precise reason for the dashboard / CLI. |
REFERRAL_CREDIT_ALREADY_CLAIMED | 409 | 3 | — |
REFERRAL_CREDIT_REVOKED | 410 | 4 | — |
REFERRAL_CLAIM_FORBIDDEN | 403 | 2 | — |
REFERRAL_REWARD_APPLY_FAILED | 500 | 1 | Referral reward coupon could not be applied to Stripe during claim (Stripe upstream error). 502 — claim CAS compensates; caller retries. |
Generic
| Code | HTTP | CLI exit | Meaning |
|---|---|---|---|
ROOT_NOT_AN_ENDPOINT | 404 | 3 | Distinct from generic NOT_FOUND so agents that hit the api host root (GET /, POST /, …) can tell "you reached the right host, but / is not an endpoint" apart from "this specific resource doesn't exist". The error.details payload doubles as an agent-discovery signpost (docs |
WORKSPACE_NOT_FOUND | 404 | 3 | Workspace-specific not-found, distinct from generic NOT_FOUND so the admin MCP onboarding tools can tell "no workspace matched this ref/id" apart from other not-found resources (the composite onboard flow branches on it to prompt the customer to sign up). |
SECRET_DETECTED | 400 | 3 | A deploy upload was blocked because the secret scanner (issue #1407) found a high-confidence leaked credential (API key / private key) in the files that would be publicly served. 400. The deployer can re-run with --allow-secrets (header x-allow-secrets: true / manifest body ` |
FROZEN | 403 | 2 | Workspace is frozen by a platform admin — deploys are refused (403). |
GRANT_REQUIRES_WORKSPACE_MEMBERSHIP | 400 | 3 | A deployer/admin email grant was attempted for someone who is not a member of the app's workspace. Those roles are inert for non-members (every management route is workspace-membership-gated), so we reject rather than silently create a dead grant. Viewer grants are allowed for an |
PIN_LIMIT_REACHED | 409 | 3 | A user can PIN (folder-file) at most PIN_LIMIT apps across all their workspaces; pinning beyond the cap returns this 409. Re-pinning an already-pinned app, and MOVING a pin between folders, are idempotent and never trip it. Replaces the retired STAR_LIMIT_REACHED. |
FOLDER_LIMIT_REACHED | 409 | 3 | A user can have at most FOLDER_LIMIT folders per (user, workspace); creating beyond the cap returns this 409. |
FOLDER_NOT_EMPTY | 409 | 3 | A DELETE was attempted on a workspace-shared folder that still has member apps (apps.folderId points at it). RESTRICT semantics — move the apps to another folder first. Surfaced before the DB-level ON DELETE RESTRICT FK so callers get a clean, actionable error instead of an opaqu |
COMMENTS_DISABLED | 404 | 3 | ── Comment mode (Phase 1) ────────────────────────────────────── Master env switch (COMMENTS_ENABLED) is off, or the per-app toggle write was attempted while the feature is globally disabled. |
COMMENT_THREAD_NOT_FOUND | 404 | 3 | Unified not-found for thread lookups (Hard Rule #8 fail-mode convention — cross-app probing must be indistinguishable). |
COMMENT_BODY_TOO_LONG | 400 | 3 | — |
COMMENT_RATE_LIMITED | 429 | 6 | — |
COMMENT_MESSAGE_NOT_FOUND | 404 | 3 | — |
COMMENT_NOT_AUTHOR | 403 | 2 | — |
COMMENT_ROOT_DELETE | 409 | 3 | Per-message DELETE was aimed at the thread's root message — delete the whole thread instead. |
COMMENT_ACCESS_DENIED | 403 | 2 | Signed-in but no comment access (spec decision 3: commenting needs a membership/grant path, not just view access). Carries no info the caller can't already see in the pill state. |
CAMPAIGN_INACTIVE | 410 | 4 | Campaign deploy challenge (#campaign). Split so the claim endpoint returns a precise reason for the dashboard / CLI. |
CAMPAIGN_NOT_ENROLLED | 403 | 2 | — |
CAMPAIGN_CODE_REVOKED | 410 | 4 | — |
CAMPAIGN_NOT_QUALIFIED | 409 | 3 | — |
CAMPAIGN_ALREADY_CLAIMED | 409 | 3 | — |
CAMPAIGN_REDEEM_CODE_NOT_FOUND | 400 | 3 | Admin campaign create/update: the enroll-gate value matched neither an existing redeem-code id nor a plaintext code. |
WORKSPACE_PROVISIONED_COMP_FAILED | 409 | 3 | admin_provision_customer_workspace created the workspace but the follow-up Business comp failed (the two are not one transaction). Distinct from CONFLICT (a slug clash → retry with a new slug) because the recovery action is the OPPOSITE: do NOT re-run the tool (the slug now exist |
DB_PROVISION_FAILED | 502 | 4 | Managed-database (Neon) provisioning failed: the provider API errored, or NEON_API_KEY is unset (fail closed). 502. |
DB_ALREADY_EXISTS | 409 | 3 | The app already has a live managed database. Phase 1 allows one database per app. 409. |
DB_NOT_FOUND | 404 | 3 | No managed database is bound to this app (status/claim/delete). 404. |
DB_ENV_VAR_CONFLICT | 409 | 3 | db create found a user-set DATABASE_URL / DATABASE_URL_UNPOOLED that provisioning would clobber. The user must remove it first. 409. |
DB_NOT_READY | 409 | 3 | The managed database exists but isn't claimable yet (still provisioning, or errored mid-provision). 409. |
ENV_VAR_MANAGED | 409 | 3 | A vibehost env set/rm targeted a platform-managed env var (managed_by != 'user', e.g. the injected DATABASE_URL). Users can't edit these directly. 409. |
STORAGE_PROVISION_FAILED | 502 | 4 | Provider-agnostic storage codes (db/kv/blob). The DB_* codes above are retained for back-compat but new routes emit these. Provisioning failed: provider API errored, or the provider key is unset (fail closed). 502. |
STORAGE_ALREADY_EXISTS | 409 | 3 | The app already has a live resource for this (provider, prefix). 409. |
STORAGE_NOT_FOUND | 404 | 3 | No matching managed resource on this app (status/claim/delete). 404. |
STORAGE_ENV_VAR_CONFLICT | 409 | 3 | create found a user-set env var the resource would inject. 409. |
STORAGE_NOT_READY | 409 | 3 | The resource exists but isn't claimable yet (provisioning/errored). 409. |
STORAGE_NOT_CLAIMABLE | 409 | 3 | The provider doesn't support claiming (Upstash api-key, R2). 409. |
STORAGE_DISABLED | 404 | 3 | Managed-storage feature is gated off (STORAGE_ENABLED unset/false). The whole surface returns this as a 404 so a disabled feature is indistinguishable from "not a thing" (no oracle). 404. |
SHEET_DB_DISABLED | 404 | 3 | Sheet-db Phase 1 — Google Sheets as a read-only queryable provider. Feature gate: SHEET_DB_ENABLED. 404 (oracle-safe, same pattern as STORAGE_DISABLED). |
SHEET_CONNECTION_NOT_FOUND | 404 | 3 | The referenced Google Sheets connection (by id) doesn't exist for this workspace. 404. |
SHEET_NOT_FOUND | 404 | 3 | The referenced sheet (spreadsheet) was not found (deleted on Google's side, or the connection lost access). 404. |
SHEET_TOO_LARGE | 422 | 3 | The spreadsheet exceeds the size limit that can be safely cached / queried in a single request. 422. |
SHEET_TAB_NOT_FOUND | 404 | 3 | The named tab (sheet within a spreadsheet) doesn't exist. 404. |
SHEET_TAB_NOT_EXPOSED | 404 | 3 | The tab exists but has not been exposed via the connection config. 404. |
SHEET_COLUMN_NOT_FOUND | 422 | 3 | A column referenced in the query doesn't exist in the tab's schema. 422. |
SHEET_ROW_NOT_FOUND | 404 | 3 | Phase 2 writes: where-clause row location outcomes. |
SHEET_ROW_NOT_UNIQUE | 409 | 3 | — |
SHEET_COLUMN_NOT_EXPOSED | 422 | 3 | Body or where referenced a column that isn't exposed on the tab. |
SHEET_DUPLICATE_HEADER | 422 | 3 | tabs expose: the tab's header row repeats a column name. columnMap is keyed by name, so a duplicate would silently shadow the earlier same-named column (unreachable for read/filter/where). Reject rather than store ambiguity. |
SQL_UNSUPPORTED | 422 | 3 | The SQL dialect feature used is not supported by the GViz translator (e.g. subqueries, CTEs). 422. |
QUERY_INVALID | 422 | 3 | The query could not be parsed or is structurally invalid. 422. |
GOOGLE_AUTH_DENIED | 403 | 2 | The user declined the Google OAuth consent screen. 403. |
GOOGLE_AUTH_EXPIRED | 401 | 2 | The stored Google OAuth refresh token has expired or been revoked. 401. |
GOOGLE_SCOPE_INSUFFICIENT | 403 | 2 | The token's granted scopes don't cover the required Google Sheets / Drive scope. 403. |
GOOGLE_RATE_LIMITED | 503 | 4 | Google Sheets API returned 429 (rate limit / quota). 429. |
SHEET_API_KEY_INVALID | 401 | 2 | The provided API key is malformed or does not match the stored hash. 401. |
SHEET_API_KEY_NOT_FOUND | 404 | 3 | No API key exists for this connection. 404. |
SCHEMA_DRIFT | 409 | 3 | The cached column schema no longer matches the live sheet (structural change detected). Callers should prompt a re-sync. 409. |