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

> Send your key in the x-api-key header:

Send your key in the `x-api-key` header:

```bash theme={null}
curl "https://.../functions/v1/public-api/v1/leads" \
  -H "x-api-key: f9_live_<32 hex chars>"
```

Public endpoints (`GET /v1/health`, `POST /v1/onboarding`, `GET /v1/availability`)
take no key — see `docs/api/openapi.yaml`.

## Key format

| Prefix                      | Meaning                                    |
| --------------------------- | ------------------------------------------ |
| `f9_live_`                  | Current, live traffic                      |
| `f9_test_`                  | Current, test traffic                      |
| `wcrm_live_` / `wcrm_test_` | **Legacy — still valid**, no longer issued |

A key is the prefix plus 32 lowercase hex characters (128 bits of entropy).

**Keys are shown once, at creation.** Only a SHA-256 hash is stored — we cannot
recover a lost key, and neither can support. Lost it? Create a new one and revoke the
old.

### `wcrm_` deprecation

Legacy `wcrm_` keys **continue to authenticate and will not be switched off without
notice.** Integrators cannot rotate on our schedule, and silently rejecting a working
key is an outage on their side, not ours.

New keys are always issued as `f9_`. To migrate: create a new key with the scopes you
need, deploy it, confirm traffic on the new key, then revoke the old one. No
coordinated cutover required — both families work simultaneously.

## Scopes

Scopes use the same `module:action` model as in-app permissions (`leads:view`,
`customers:create`, and `object:<slug>:create` for tenant-defined objects). One model
means a key can never quietly exceed what the equivalent role could do.

**Legacy scopes still authorise.** A key issued with `leads:read` satisfies a
`leads:view` requirement, and vice versa — the bridge is in
`supabase/functions/_shared/scope-vocab.ts`.

The mapping is deliberately **least privilege** — a legacy scope grants only what it
authorised at migration time:

| Legacy              | Grants                                    |
| ------------------- | ----------------------------------------- |
| `leads:read`        | `leads:view`                              |
| `leads:write`       | `leads:create` *(not `leads:edit`)*       |
| `customers:read`    | `customers:view`                          |
| `customers:write`   | `customers:create`                        |
| `activities:read`   | `activities:view`                         |
| `activities:write`  | `activities:create`                       |
| `activities:update` | `activities:edit`                         |
| `onboarding`        | *(platform scope — no module equivalent)* |

`leads:write` maps to `create` only, on purpose. Mapping it to `edit` as well would
silently grant edit rights the moment an edit route ships — privilege escalation via a
lookup table is not a migration.

Wildcards still work: `*` grants everything, `leads:*` grants every action on leads.

## Failure responses

Every failure uses the standard envelope — see `docs/api/errors.md`.

| Situation                               | Status  | Code               |
| --------------------------------------- | ------- | ------------------ |
| No `x-api-key` header                   | 401     | `MISSING_API_KEY`  |
| Malformed / unknown key                 | 401     | `AUTH_INVALID_KEY` |
| **Revoked key**                         | **401** | `API_KEY_REVOKED`  |
| **Expired key**                         | **401** | `API_KEY_EXPIRED`  |
| Key valid, scope missing                | 403     | `FORBIDDEN_SCOPE`  |
| Scope valid, module disabled for tenant | 403     | `MODULE_DISABLED`  |
| Caller IP not on the allowlist          | 403     | `IP_NOT_ALLOWED`   |

> ⚠️ **Behaviour change.** Revoked and expired keys previously returned
> **403**; they now return **401**. The credential itself is no longer valid, which is
> an authentication failure — a 403 sends integrators to check their scopes when the
> real problem is the key. If your error handling branches on the status, treat 401 as
> "re-authenticate / rotate the key".

The 401/403 split is the useful signal: **401 means fix the key, 403 means fix the
key's permissions (or the tenant's plan).**

`MODULE_DISABLED` is not a scope problem — 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 don't delete the key.

## Lifecycle

* **Expiry** — optional `expires_at`. After it passes the key returns 401; nothing is
  deleted, so you can inspect it in Settings › API Keys.
* **Revocation** — immediate and irreversible. `revoked_at`, `revoked_by` and
  `revoke_reason` are recorded for the audit trail.
* **Usage tracking** — `last_used_at` and `total_requests` are updated by a single
  atomic statement (`increment_api_key_usage`). The counter is incremented in the
  database rather than read-modify-written by the edge function, which previously lost
  increments whenever requests overlapped; `last_used_at` is refreshed at most once
  every 5 minutes rather than on every request.

## Notes for the docs' own accuracy

The acceptance criteria for this story name `AUTH_INVALID_KEY` and `FORBIDDEN_SCOPE`.
Those codes do not exist — the real ones are `AUTH_INVALID_KEY` and
`FORBIDDEN_SCOPE`, which are what the API returns and what the frozen catalogue in
`docs/api/openapi.yaml` documents (enforced by `src/test/api/error-catalogue.test.ts`).
The catalogue wins; the AC prose was written before it was frozen.

## OAuth 2.1 Bearer tokens (Epic 4 — )

The API accepts a second credential type alongside `x-api-key`: an **OAuth 2.1
access token** in `Authorization: Bearer <jwt>`. This is what MCP clients and
third-party apps use. Once authenticated, a token behaves **identically** to an
API key — same `ApiContext`, same scope checks, same tenant isolation, same
403/404 — because both resolve to the same `AuthenticatedKey` shape before any
route runs. Services never learn which credential type was used.

**Selection:** if `x-api-key` is present it wins; otherwise a Bearer token is
tried; a request with neither is `401` with a `WWW-Authenticate: Bearer` header.

**Validation** (`_shared/auth-provider.ts`, issuer-agnostic so the provider is a
config swap):

* signature verified against the issuer's JWKS (`ES256`/`RS256`), with kid-rotation
  handled by a cached resolver;
* `iss` must equal the configured issuer; `aud` is checked only when
  `OAUTH_EXPECTED_AUDIENCE` is set; `exp`/`nbf` within a ±60s clock skew.
* Any failure is `401` with `WWW-Authenticate` — never 403 (an invalid credential
  is an authentication failure, matching the API-key rule above).

**Tenant + actor:** the tenant is resolved from the token `sub` (the Supabase
user id == `User.id`) — never from the request. The actor id is
`oauth_<client_id|sub>` (not an api\_key UUID), so OAuth calls are logged with a
null `api_key_id`.

**Scopes:** standard OIDC scopes (`openid`/`profile`/…) are dropped; any
`module:action` scopes the token carries are honoured. Per the spike the
Supabase beta issues OIDC-only scopes, so a token's *permissions* come from the
tenant's grant store, resolved server-side by the token proves identity,
not authority.

**Config (env):** `OAUTH_ISSUER` (defaults to `${SUPABASE_URL}/auth/v1`),
`OAUTH_JWKS_URI` (defaults to the issuer's `/.well-known/jwks.json`),
`OAUTH_EXPECTED_AUDIENCE` (optional).
