Authentication
Three credentials, three audiences
| Credential | Who holds it | Lifetime | Scope |
|---|---|---|---|
| Private API key | Your backend, CI, the CLI | Until revoked | Everything its scopes allow |
| Public key | A browser bundle | Until revoked | Exchange for a client token, nothing else |
| Client JWT | A browser, for one session | Minutes | One organization, one assistant, one session |
The control plane issues ca_sk_ and ca_pk_. Those are the prefixes you will actually see.
The Web SDK is more permissive on the way in, because it also has to reject keys pasted from elsewhere: it treats sk_ and ca_sk_ as private and refuses them, and accepts pk_, ca_pk_ and pub_ as public. A wider public prefix list is a design proposal, not something the platform mints.
Private keys
Either header is accepted, so pick whichever your HTTP client makes easy:
Authorization: Bearer <key>X-CallAgent-Key: <key>Stored as sha256(key) in api_keys.key_hash, so a database leak yields nothing replayable — and a lost key is rotated, never recovered. A key can be restricted by scope, by origin, by allowed assistant and by IP allowlist; all four are checked on every request by App\Http\Middleware\AuthenticateApiKey.
The organization is derived from the key. There is no organization_id parameter anywhere in the API, so there is nothing to tamper with; a resource in another organization returns 404, identical to one that never existed.
Requests are rate limited per key — 300 per minute by default, overridden by your plan's api_rate_limit_per_minute where one is set, and 60 per minute per IP when unauthenticated. Note the consequence: an organization with three keys has three separate buckets. See Rate limits.
GET /api-keys/scopes returns the full scope vocabulary. Scopes are <resource>.<verb> — call.read, recording.delete, assistant.publish — and <resource>.* grants the group.
Never put a private key in a browser. The Web SDK refuses one on sight:
new CallAgent({ publicKey: 'sk_…' });
// CallAgentError: private_key_rejected — "That looks like a private/secret key…"Public keys and client tokens
Browsers get a public key, which is useless on its own. The SDK exchanges it for a short-lived JWT:
POST /api/v1/client-tokens
Authorization: Bearer <public key>
Origin: https://example.com
{ "public_key": "<public key>", "assistant_id": "01JQ…", "ttl": 900 }
201 {
"data": {
"token": "eyJ…",
"token_type": "Bearer",
"expires_at": "2026-01-01T12:15:00+00:00",
"expires_in": 900,
"organization_id": "01J…",
"session_id": "01J…"
}
}The Origin header is checked against the key's allowed origins: a valid key presented from a site it does not name is refused. Tokens are signed HS256 with a key derived from CREDENTIAL_ENCRYPTION_KEY, so they survive an APP_KEY rotation and a leaked APP_KEY cannot mint them. Default lifetime 900 s, maximum 3600 s.
POST /api/v1/client-tokens/verify checks one, which is useful when developing an SDK.
The token's claims bound what the page can do:
organization which tenant
assistant which assistant may be called
user your opaque end-user id
session this session only
origins which sites may present it
exp when it diesThe SDK refreshes 60 seconds before exp rather than waiting for a 401, and re-sends the current token on every reconnect. If you prefer to mint tokens on your own backend, pass { token } instead of { publicKey } — the SDK then never calls the exchange.
Endpoint status
The token exchange is live. Starting a browser call with that token (POST /api/v1/client/calls) is implemented in the SDK but not published by the control plane. See The browser and What is not built.
CLI credentials
callagent login # prompts, echo suppressed
echo "$KEY" | callagent login --stdin
callagent whoami
callagent logoutThe first key cannot come from here. POST /api-keys needs a key to call it, so the first one is created in the dashboard — see Quickstart step 1.
Resolution order:
CALLAGENT_API_KEY— wins over everything, never touches disk. Use this in CI.- The OS keychain (macOS Keychain, libsecret on Linux).
~/.callagent/credentials.json, created mode0600inside a0700directory.
The key is never passed as a command-line argument, so it does not reach shell history or a ps listing.
Webhook authentication
Outbound webhooks are signed, not authenticated with a key. See Webhooks.