API keys, RBAC scopes, and key management for the 9thSense platform
Authentication
Every request to the 9thSense API must carry an API key in the X-Api-Key header. The platform uses a split-key scheme that enables fast database lookups without storing secrets in plaintext.
Key format
All new keys follow the pattern zk_<env>_<prefix>_<secret>:
zk— fixed sentinel identifying a 9thSense key.<env>— the environment the key is bound to (e.g.live,test). Keys created before environments existed omit it:zk_<prefix>_<secret>.<prefix>— 12 hex characters (6 random bytes). Stored in plaintext in the database and used for an indexed lookup.<secret>— 32 hex characters (16 random bytes). Never stored. The full key is bcrypt-hashed and the hash is stored.
On each request, the middleware extracts the prefix, fetches the matching row by prefix, then calls bcrypt.checkpw(presented_key, stored_hash) to verify. Legacy keys (SHA-256 hash stored without a prefix) are still accepted for backward compatibility.
The raw key is returned once at creation time and cannot be retrieved again. Store it immediately.
Sending the key
curl https://api.9thsense.ai/v1/cases \
-H "X-Api-Key: zk_live_a1b2c3d4e5f6_0123456789abcdef0123456789abcdef"
import httpx
client = httpx.Client(
base_url="https://api.9thsense.ai",
headers={"X-Api-Key": "zk_live_a1b2c3d4e5f6_0123456789abcdef0123456789abcdef"},
)
resp = client.get("/v1/cases")
resp.raise_for_status()
The middleware returns 401 Missing X-Api-Key header if the header is absent, and 401 API key invalid if the key is unknown or revoked. Expired keys get 401 API key expired; a key awaiting approval gets 401 API key pending approval, and a rejected one 401 API key rejected.
RBAC scopes
Every API key carries a list of scopes from a 19-scope catalog
(tenant:read, case:write, document:write, tool:invoke, …; see the
scope table). Endpoints enforce the required scope via the
require_scope() dependency. If your key lacks it the API returns
403 Insufficient permissions: requires '<scope>' scope.
Request exactly what the service needs. A backend that creates cases and uploads
documents needs ["case:read", "case:write", "document:write"]; a reporting job
needs only read scopes such as ["case:read", "report:read"]. A :write scope
does not include the matching :read. The legacy read, write and admin
names are rejected with 422.
Org admins' keys work immediately. When a tenant member requests a privileged
scope (any :write, :delete, agent:run, tool:invoke or tenant:admin)
the key is created pending_approval and returns
401 API key pending approval until an org admin approves it.
Creating a key
Key management uses a dashboard session (Authorization: Bearer <jwt>), not an
API key: an API key calling these routes gets 403. Org admins create org-owned
keys with POST /v1/admin/keys; members (and admins, for personal keys) use
POST /v1/keys.
curl -X POST https://api.9thsense.ai/v1/admin/keys \
-H "Authorization: Bearer $DASHBOARD_JWT" \
-H "Content-Type: application/json" \
-d '{
"name": "production-backend",
"environment": "live",
"scopes": ["case:read", "case:write", "document:write"],
"expires_in_days": 90
}'
Response — 201 Created:
{
"key": {
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "production-backend",
"prefix": "a1b2c3d4e5f6",
"environment": "live",
"scopes": ["case:read", "case:write", "document:write"],
"status": "active",
"owner": null,
"created_at": "2026-03-25T09:00:00Z",
"expires_at": "2026-06-23T09:00:00Z",
"last_used_at": null,
"revoked_at": null,
"purge_at": null,
"approval": null
},
"secret": "zk_live_a1b2c3d4e5f6_0123456789abcdef0123456789abcdef"
}
secret is the only time the key is visible. Store it in your secrets manager immediately.
Listing keys
curl "https://api.9thsense.ai/v1/admin/keys?status=active" \
-H "Authorization: Bearer $DASHBOARD_JWT"
The response is an array of key resources (the key object above) and never
includes the secret. last_used_at is updated on every authenticated request
(fire-and-forget, never blocks the response).
Revoking a key
curl -X POST https://api.9thsense.ai/v1/admin/keys/3fa85f64-5717-4562-b3fc-2c963f66afa6/revoke \
-H "Authorization: Bearer $DASHBOARD_JWT"
Returns 200 with the key ("status": "revoked"). Revoked keys are rejected
immediately on the next request. To remove a revoked, expired or rejected key
permanently, call DELETE /v1/admin/keys/{key_id} (204).
Open paths
The following paths never require authentication: /healthz, /docs, /openapi.json, /redoc, /metrics. All other paths enforce key validation when auth is enabled.