Skip to content

Errors

Every JSON error uses one shape:

{ "error": { "code": "VALIDATION", "message": "title is required" } }

Always content-type: application/json.

Thrown internally Status code
ValidationError 400 VALIDATION
ForbiddenError 403 FORBIDDEN
NotFoundError 404 NOT_FOUND
ConflictError 409 CONFLICT
RateLimitError 429 RATE_LIMITED
UpstreamError 502 UPSTREAM_FAILED
VectorSearchUnavailableError 409 VECTOR_SEARCH_UNAVAILABLE
anything else 500 INTERNAL, message fixed to internal server error

A 500 never leaks details — the real error is in the server log, not the response.

Code Status Meaning
EMAIL_TAKEN 409 Registration with an address that already exists.
WEAK_PASSWORD 400 Below the minimum length.
INVALID_CREDENTIALS 401 Wrong email or password. Deliberately identical for both.
USER_NOT_ACTIVE 403 The account is disabled or blocked.
EMAIL_NOT_VERIFIED 403 Verification required and not done.
INVALID_SESSION 401 The refresh token is unknown or already used.
INVALID_TOKEN 401 Malformed, expired, or wrong audience.
Code Status Usual cause
MISSING_TOKEN 401 No Authorization header on a route that needs one.
INVALID_TOKEN 401 Bad or expired token, or the wrong audience for the route.
INSUFFICIENT_SCOPE 403 The API key lacks the scope this route needs.
PROJECT_MISMATCH 400 An x-groveback-project header disagreeing with the token’s pid. (403 on the per-project spec route.)
MISSING_PROJECT 400 Multi-project deployment, no project named.
INVALID_BODY 400 Unparseable JSON, or $ keys in a PUT.
INVALID_FILTER 400 The filter param is not a JSON object.
INVALID_PARAM 400 A negative or non-integer limit/skip.
INVALID_REDIRECT 400 An OAuth redirect origin that is not allow-listed.
INVALID_STATE 400 The OAuth state expired (10 min) or was tampered with.
INVALID_SIGNATURE 400 A bad signed-URL or webhook signature.
RATE_LIMITED 429 Comes with a retry-after header.
BILLING_DISABLED 400 A billing route on a deployment without Stripe.
METHOD_NOT_ALLOWED 405 Right path, wrong verb.

Delivered as { "type": "error", "code": … } frames, not HTTP:

INVALID_MESSAGE · AUTH_FAILED · UNAUTHENTICATED · DUPLICATE_ID · UNKNOWN_SUBSCRIPTION · NOT_FOUND · FORBIDDEN · TOKEN_EXPIRED · CONNECTION_LIMIT

HTML pages. /auth/login, /reset-password, and OAuth start failures when the client accepts text/html render an error page instead of the envelope. API clients still get JSON.

The OAuth callback never returns an error status. It 302s back to your app with #mb_error=<reason>provider_denied, exchange_failed, or a lowercased auth code.

404 is used for three distinct situations, on purpose:

  1. The thing does not exist.
  2. It exists but you may not see it — a policy filter excluded it, or you are not a project member.
  3. The feature is not wired on this deployment — storage, functions, audit, billing, introspection and the whole control plane all 404 when absent.

A 403 in case 2 would confirm the row exists, so the API declines to distinguish.

Every denial caused by a plan limit has a message starting with plan limit: , whether it arrives as a 403, a 429, a run-log entry or a WebSocket error. That prefix is the reliable way to detect a quota problem across surfaces.

Situation Status
Handler threw 500 { "error": "function error" } — the message is in the run log, never in the response.
Timed out 504 { "error": "function timed out" }
Worker pool saturated 503 — retryable.
Pre-hook aborted a write 400 VALIDATION, with the hook’s reason as the message.