Skip to main content
Branch on error.code, never on error.message. Codes are a frozen, documented catalogue. Messages are human-readable and may be reworded at any time.

The envelope

Every /v1 response — success or failure — uses the same shape:
meta.request_id is also returned as the X-Request-Id header. Quote it in support requests — it is how a specific call is found in the logs.
We deliberately kept this envelope for v1 rather than adopting RFC 9457 (application/problem+json): existing consumers depend on it, and it already carries a stable code plus a request id. Revisit at v2.

The code catalogue

Defined in supabase/functions/_shared/errors.ts and mirrored into the OpenAPI spec as the ErrorCode enum. A test (src/test/api/error-catalogue.test.ts) fails the build if the two ever disagree, so the spec cannot go stale. The IDEMPOTENCY_* codes distinguish three different situations that all look like “your retry did not go through”: the endpoint needs a key and you sent none (428), you reused a key with a different body (422 — a caller bug), or an identical request is still executing (409 — normal under concurrency, retry shortly). See idempotency.md. QUOTA_UNAVAILABLE means the quota engine could not be consulted — it is our fault, not the caller’s, and it is retryable. It is deliberately separate from QUOTA_EXCEEDED: one means “you have used your allowance”, the other means “we cannot tell”. The rate-limit layers fail OPEN when Redis is down, but the quota fails CLOSED, because serving requests we cannot account for gives away metered calls with no record. See rate-limits.md. MODULE_DISABLED is distinct from a scope failure: the key has the scope, but the tenant has that product module switched off. Grants stay inert and restore automatically when the module is re-enabled — so treat it as recoverable, not as a reason to delete the key.

Behaviours worth knowing

Unknown endpoint → 404 NOT_FOUND. Known endpoint, wrong verb → 405 METHOD_NOT_ALLOWED, with an Allow header and details.allowed_methods. Hono would otherwise answer 404 for this, which is misleading — the resource exists, the method does not. Malformed JSON body → 400 VALIDATION_ERROR. Handled centrally in app.onError, so it applies to every route, present and future, without each handler wrapping its own parse. Previously an unparseable body threw and surfaced as 500 INTERNAL_ERROR — a client mistake reported as a server fault, which also polluted error monitoring.
A malformed body and a body that failed field validation share one code. They are told apart by details: a field-validation failure carries details.fields naming what to fix, an unparseable body has no fields to name.
Signup is enumeration-safe. POST /v1/onboarding returns the same generic VALIDATION_ERROR for a bad payload and for an email that already exists, so the endpoint cannot be used to discover which addresses are registered. Do not “improve” this by returning ALREADY_EXISTS.

Rules when adding an error

  1. Never leak internals. message must not contain stack traces, SQL, or upstream provider text. Log the real error server-side; return something a caller can act on.
  2. Sanitise details. It is for actionable context (which field, which allowed values) — not raw exception payloads.
  3. Reuse an existing code where one fits. New codes are a contract change: add to errors.ts and the spec enum in the same PR, or the catalogue test fails.
  4. Use the shared helpers apiSuccess / apiError from _shared/response.ts. Raw new Response is justified only for CORS preflight (204, no body), an idempotency replay, or a 201 needing custom headers — and even then the body must still be the envelope.

⚠️ Testing gap to be aware of

The Deno tests under supabase/functions/tests/ (activities.test.ts, auth.test.ts, leads.test.ts, onboard.test.ts, …) are not executed by CI. ci.yml runs vitest (which excludes supabase/**) and pgTAP SQL tests — nothing runs deno test. That is why the tests live in the vitest suite (src/test/api/) and why the 405 matching logic was extracted into _shared/route-match.ts — a dependency-free module that can be imported outside Deno. Wiring the Deno suite into CI is worth its own ticket.