Skip to main content
Send your key in the x-api-key header:
Public endpoints (GET /v1/health, POST /v1/onboarding, GET /v1/availability) take no key — see docs/api/openapi.yaml.

Key format

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: 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.
⚠️ 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 trackinglast_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).