Authentication
API keys, header format, WebSocket auth via query string, and organization scoping.
zoxaAI uses API keys for server-to-server requests and JWTs for dashboard sessions. Most integrations use API keys.
API key format
zsk_<urlsafe-base64-32-bytes>Generated server-side as f"zsk_{secrets.token_urlsafe(32)}". The full key is shown only at creation time — after that, only the first 8 characters (key_prefix) are visible in any list response.
HTTP authentication
Send the key on every request:
X-API-Key: zsk_a1b2c3d4...curl https://dashboard.zoxa.ai/api/v1/agents \
-H "X-API-Key: zsk_..."await fetch("https://dashboard.zoxa.ai/api/v1/agents", {
headers: { "X-API-Key": "zsk_..." },
});import httpx
client = httpx.Client(
base_url="https://dashboard.zoxa.ai/api/v1",
headers={"X-API-Key": "zsk_..."},
)
client.get("/agents")WebSocket authentication
Browser WebSocket handshakes can't carry custom headers, so the key goes in the query string:
wss://dashboard.zoxa.ai/api/v1/ws/audio/{callId}?api_key=zsk_...Server-side WebSocket clients (Node.js ws, Python websockets, Go) may send X-API-Key as a custom header instead — both forms are accepted. The query-string form works everywhere.
Don't put the key in client-side JavaScript
Keep zsk_... keys server-side only — shipping one to end-users gives them full org control. Browser calls belong behind your own backend, which holds the key and brokers the call.
Dashboard JWT (alternative)
The dashboard itself authenticates with a JWT obtained from POST /api/v1/auth/login. Server-side integrations should never use this path — use an API key.
Authorization: Bearer eyJhbGciOi...Organization scoping
Every key is bound to one organization at creation time. All requests made with that key operate within that organization — you never pass organization_id anywhere.
| Behavior | Rule |
|---|---|
| Scope | Single org per key. |
| Cross-org access | Impossible. A key only reads/writes within its own org. |
| Multiple orgs | Create a separate key in each org. |
| Listing | List endpoints automatically filter to the key's org. |
| Mutations | Resource ids you can't access return 404 (not 403) to avoid leaking ids across orgs. |
Managing keys via API
API keys can be managed through the same API.
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/user/api-keys | List active keys (full key value never returned). Add ?include_archived=true to include revoked keys. |
POST | /api/v1/user/api-keys | Create a new key. Response includes the full key — store it now. |
DELETE | /api/v1/user/api-keys/{api_key_id} | Revoke a key. Effective immediately. |
PUT | /api/v1/user/api-keys/{api_key_id}/reactivate | Restore a revoked key. |
Full request/response details on the API Keys section.
Failure modes
| Status | detail | When |
|---|---|---|
401 | "Authorization header required" | No X-API-Key header (or Authorization: Bearer ... for JWT). |
401 | "Invalid or expired API key" | Key not found, or has been revoked. |
401 | "Invalid or expired token" | JWT expired or signature invalid. |
403 | "Access denied" | Auth succeeded but the user lacks scope (e.g., non-superuser hitting superuser endpoints). |
Best practices
| Practice | Why |
|---|---|
| One key per environment | Separate prod / staging / CI keys so a leak is scoped. |
| Store in a secrets manager, never in source control | The key gives full org control until revoked. |
| Rotate periodically | Create the new key, deploy it everywhere, then revoke the old one. |
Use key_prefix for in-product display | All list responses include key_prefix (zsk_a1b2...) safe to render in admin dashboards. |
| Revoke before deleting | If you suspect a leak, revoke immediately — even if you can't track down where it was used. |