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