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.
| 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. |
| 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. |
| 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. |
SCOPED_PAT_DISABLED | 409 | 3 | 409: scoped PAT issuance is disabled server-side. Operator must set SCOPED_PAT_ENFORCEMENT_ENABLED=true once all PAT-callable routes have been retrofitted with requirePatScope (Dashboard PR 3 prereq). Until then, full-access PATs (scopes: null / scopes omitted) still work; only r |
| 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 |
| 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 | — |
| 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 | — |
| 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. |
| 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 | — |