Authentication
Every request to /api/v1 carries an API key. The key identifies the organization, so there is no organization_id parameter anywhere in this API and nothing to tamper with.
The two headers
Either works. Authorization is the one to use; X-CallAgent-Key exists for clients that cannot set an Authorization header.
Authorization: Bearer ca_sk_9zK…X-CallAgent-Key: ca_sk_9zK…Bearer is matched case-sensitively with exactly one space after it. Bearer key (two spaces) or bearer key is read as a missing key, not a bad one.
Key types and prefixes
The server stamps a prefix at issuance. There are exactly two:
| Type | Plaintext key | Stored key_prefix | Where it belongs |
|---|---|---|---|
private | ca_sk_ + 48 chars | ca_sk | Your server. Never in a browser, a mobile binary or a git repository. |
public | ca_pk_ + 48 chars | ca_pk | A browser bundle. Safe to publish. |
service_account | ca_sk_ + 48 chars | ca_sk | Machine-to-machine. Shares the private prefix — the type is a column, not something you can see in the key. |
A key is 54 characters: the six-character prefix, then 48 characters of [A-Za-z0-9]. The stored key_prefix has no trailing underscore, which is why a redacted key renders as ca_sk…mF3q rather than ca_sk_…mF3q.
Not pk_live_ / sk_test_
There is no environment in the key. ca_sk_ is the same string in development and in production, and there is no _live_/_test_ variant of either prefix. If you have seen pk_live_ in an internal design note, that scheme was proposed and never implemented — ca_sk_ and ca_pk_ are what the server writes. Any pk_live_ or sk_test_ string you find in this codebase belongs to Stripe, not to CallAgent.
A private key is stored only as sha256(key), so a database leak yields nothing replayable — and a lost key is rotated, never recovered. A public key is additionally stored in plaintext, because the SDK has to be able to show it to you again.
What a public key can and cannot do
A public key is safe to ship in a browser bundle. It is not a weaker private key; it is the first half of an exchange:
browser ── ca_pk_… ──▶ POST /client-tokens ──▶ short-lived JWT ──▶ sessionThe JWT is scoped to one organization, optionally one assistant and one session, and expires. See client tokens below.
A public key currently authenticates like a private one
The control plane stores a key_hash for public keys as well as private ones, so a ca_pk_… key presented as Authorization: Bearer is accepted on ordinary REST endpoints, subject only to its origin and IP restrictions. Treat this as an implementation detail that may be tightened, not as a supported way to call the API: restrict every public key to the origins that may use it, and never grant one scopes it does not need for the token exchange.
Restrictions on a key
Four restrictions can be attached to a key. Only three of them are enforced on an ordinary API request.
| Restriction | Field | Enforced on a REST request | On failure |
|---|---|---|---|
| Scopes | scopes | Per-endpoint, in the handler — see below | 403 |
| Origins | allowed_origins | Yes, on every request | 403 |
| IP allowlist | allowed_ips | Yes, on every request | 403 |
| Assistants | allowed_assistant_ids | No — only when minting a client token | — |
Origins are fnmatch globs (https://*.example.com). A key with a non-empty origin list rejects a request that carries no Origin header at all, which is every request from curl — so do not put an origin restriction on a key you call from a server.
IPs are compared as exact strings against the client address. The application trusts X-Forwarded-For from its proxy.
Assistant restriction is not a general key restriction. allowed_assistant_ids constrains which assistant a client token may be minted for. It does not stop the key itself from reading or writing another assistant over REST. If you need that boundary, use scopes and separate keys.
Scopes
A scope is <resource>.<verb>. A key may carry:
- an empty list — every scope. This is the default, and it is a real grant, not an unset value.
*— every scope, explicitly.<resource>.*— every verb on that resource.call.*grantscall.read,call.create,call.end,call.deleteandcall.listen.- exact scopes —
call.read,webhook.write, …
GET /api-keys/scopes returns the full vocabulary, grouped, with a description for each. That endpoint enforces no scope of its own, so any valid key can read it.
The 86 scopes, by group:
| Group | Scopes |
|---|---|
| Assistants | assistant.read assistant.write assistant.delete assistant.publish squad.read squad.write |
| Tools & knowledge | tool.read tool.write tool.delete mcp.read mcp.write knowledge.read knowledge.write |
| Calls | call.read call.create call.end call.delete call.listen recording.read recording.delete transcript.read |
| Telephony | phone_number.read phone_number.write trunk.read trunk.write extension.read extension.write ivr.write |
| Contact centre | queue.read queue.write operator.read operator.write operator.monitor operator.whisper operator.barge operator.force_state wallboard.read team.read team.write team.manage_any |
| Engage | campaign.read campaign.write chat.read chat.write |
| Observe | analytics.read board.write eval.read eval.write scorecard.write monitor.write issue.write |
| Developers | api_key.read api_key.write webhook.read webhook.write |
| Platform | credential.read credential.write integration.write storage.write registry.read |
| Customers | customer.read customer.write customer.delete |
| Organization | organization.read organization.write member.read member.invite member.write role.write audit.read sso.write scim.write environment.write |
| Billing | billing.read billing.write |
Scope enforcement is per-endpoint, and it is not universal
Scopes are checked inside the controller that needs them, not by a middleware that covers everything. Every endpoint page in the API reference states the scope that endpoint actually enforces, and says so plainly when it enforces none.
Two consequences worth planning around. First, a <resource>.* grant is only validated for shape: nonsense.* is accepted and stored, it simply never matches anything. Second, a scope you do not grant is not a boundary you can rely on for every endpoint — where a page says no scope is enforced, any valid key reaches it. Give a key the narrowest set that works today, but treat key separation, not scoping, as the security boundary between two systems.
Client tokens
POST /client-tokens exchanges a public key for a short-lived JWT.
The route sits inside the authenticated group, so the request needs a bearer key and repeats the public key in the body. In practice the browser sends the same ca_pk_… string twice.
curl -X POST "$CALLAGENT_API_URL/client-tokens" \
-H "Authorization: Bearer ca_pk_YOUR_PUBLIC_KEY" \
-H "Content-Type: application/json" \
-d '{
"public_key": "ca_pk_YOUR_PUBLIC_KEY",
"assistant_id": "01JQ0000000000000000000000",
"ttl": 900
}'| Field | Type | Required | Notes |
|---|---|---|---|
public_key | string | yes | max 128 chars |
assistant_id | ULID | — | Checked against the key's allowed_assistant_ids |
user_id | string | — | max 128 chars; becomes the user claim |
session_id | string | — | max 64 chars; a ULID is generated when omitted |
ttl | integer | — | seconds, 60–3600, default 900 |
{
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
"token_type": "Bearer",
"expires_at": "2026-01-01T12:15:00+00:00",
"expires_in": 900,
"organization_id": "01JQ…",
"assistant_id": "01JQ…",
"session_id": "01JQ…"
}
}The JWT is HS256. Its claims are iss (callagent), aud (callagent-client), jti, iat, nbf, exp, organization, assistant, user, session, origins, key and scopes. Null values are omitted rather than sent as null.
POST /client-tokens/verify takes {"token": "…"} and answers 200 with {"data": {"valid": true, "claims": {…}}}, or 401 with a code naming the reason:
malformed · bad_algorithm · bad_signature · not_yet_valid · expired · wrong_audience · wrong_origin · key_revoked
Revocation is re-checked on every verify against the key claim, so revoking an API key invalidates every outstanding token minted from it immediately.
Issuing and rotating keys
POST /api-keys returns the plaintext key exactly once, in a key field that appears nowhere else — not in GET /api-keys, not in the resource, not in the database.
{
"data": {
"id": "01JQ…",
"name": "Billing sync",
"type": "private",
"key": "ca_sk_…",
"key_prefix": "ca_sk",
"last_four": "mF3q",
"scopes": ["billing.read"],
"expires_at": null,
"notice": "…"
}
}A public key must be restricted to at least one origin; creating one without allowed_origins is a 422, because a public key with no origin restriction is visible to anyone who opens your site and usable by all of them.
POST /api-keys/{apiKey}/rotate issues a replacement carrying the same name, scopes and restrictions, and puts the old key on a timer:
overlap_hours | Old key |
|---|---|
0 | Expired and revoked immediately |
1–720 | Expires after the overlap; still usable until then |
| omitted | 24 hours |
Deploy the new key during the overlap, confirm traffic has moved, and let the old one lapse. DELETE /api-keys/{apiKey} revokes immediately with no overlap.
What a failed authentication looks like
| Condition | Status | error.type | error.message |
|---|---|---|---|
| No key on the request | 401 | authentication_error | Missing API key. |
| Unknown, revoked or expired key | 401 | authentication_error | Invalid or revoked API key. |
| Origin not allowed | 403 | authentication_error | This API key is not permitted from this origin. |
| IP not allowed | 403 | authentication_error | This API key is not permitted from this address. |
| Organization inactive | 403 | organization_error | This organization is not active. |
| Missing scope | 403 | permission_error | This API key does not carry the [assistant.write] scope. |
Revoked, expired and never-existed are deliberately indistinguishable. So is another organization's resource, which returns 404 rather than 403 — a 403 would confirm the row exists.
The middleware failures carry no request_id
The first four rows — and the organization check — are produced by middleware that runs before the central error renderer, so they carry type and message but no request_id, and the organization check carries no documentation_url either. The missing-scope 403 is raised inside a handler and does carry a request_id. See errors.