> ## Documentation Index
> Fetch the complete documentation index at: https://flow9.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# API error handling

> Every /v1 response — success or failure — uses the same shape:

> **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:

```json theme={null}
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "first_name is required",
    "details": { "field": "first_name" }
  },
  "meta": { "request_id": "uuid", "timestamp": "ISO-8601" }
}
```

`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.

| Class | Codes | Typical status |
| - | - | - |
| **Auth** | `MISSING_API_KEY`, `AUTH_INVALID_KEY`, `API_KEY_EXPIRED`, `API_KEY_REVOKED` | 401 |
| **Authorization** | `FORBIDDEN_SCOPE`, `IP_NOT_ALLOWED`, `MODULE_DISABLED` | 403 |
| **Rate / quota** | `RATE_LIMIT_EXCEEDED`, `QUOTA_EXCEEDED` | 429 |
| **Quota unavailable** | `QUOTA_UNAVAILABLE` | 503 |
| **Idempotency** | `IDEMPOTENCY_KEY_REQUIRED`, `IDEMPOTENCY_CONFLICT`, `IDEMPOTENCY_IN_PROGRESS` | 428 / 422 / 409 |
| **Validation** | `VALIDATION_ERROR`, `MISSING_REQUIRED_FIELD`, `INVALID_FIELD_FORMAT` | 400 |
| **Resources** | `NOT_FOUND`, `ALREADY_EXISTS`, `CONFLICT` | 404 / 409 |
| **Subscription changes** | `AGREEMENT_ACTIVE` — the tenant is on a negotiated agreement, plan/seat/module changes go through the account manager (409) · `CARD_CHARGE_FAILED` — Stripe could not charge the card; update it via the portal and retry (402) · `NO_ACTIVE_SUBSCRIPTION` — nothing to change yet; subscribe first (409) | 409 / 402 / 409 |
| **Billing portal** | `NO_BILLING_CUSTOMER` — no Stripe customer yet; run Checkout (subscribe or top up) before opening the portal | 409 |
| **Seats** | `SEAT_LIMIT_REACHED` — creating a user would exceed the plan's seats; `details.hint` says how many seats to buy (`set_seats`) | 409 |
| **Server / protocol** | `INTERNAL_ERROR`, `SERVICE_UNAVAILABLE`, `METHOD_NOT_ALLOWED` | 500 / 503 / 405 |

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](/api-reference/idempotency).

`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](/api-reference/rate-limits).

`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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.