Skip to content

Auth & scopes

Machine clients authenticate with API keys in the Authorization header:

Authorization: Bearer vcp_live_...
Authorization: Bearer vcp_test_...

Keys are stored as SHA-256 hashes; only the first 8 characters are retained as a displayable prefix, and the full secret is shown exactly once at creation. A key carries an optional name, an optional expires_at, and its scopes. Test keys (vcp_test_) route all telephony to a mock provider: no PSTN dial ever originates from one (see Test mode).

The prefix is the mode, and the mode decides which telephony provider your calls reach. A vcp_test_ key is a complete sandbox: every dial is simulated, deterministic, and free. A vcp_live_ key reaches your own verified Twilio subaccount.

Live keys are a post-promotion handoff, not a key-mode escalation. After the active workspace owner completes the production preflight and requests review, staff independently recheck the gate set and perform the fresh-step-up, dual-control promotion. Only then can that owner use their first-party browser session plus CSRF synchronizer to call POST /v1/onboarding/production-api-key and claim one reveal-once vcp_live_ credential. POST /v1/api-keys with mode: "live" from a test key still answers 403 key_mode_escalation: a sandbox credential can never promote itself. Build against the sandbox until staff promotion is complete.

Every way authentication fails, whether the key is absent, malformed, unrecognized, revoked, or expired, answers with one byte-identical unauthenticated body, deliberately. “Revoked” and “expired” are facts about a key that was issued, so a per-reason message would answer whether a token ever existed here. The response carries WWW-Authenticate: Bearer. A valid key on a non-active tenant gets tenant_suspended instead, which is a billing/compliance state, not a bad key.

The catalog is exactly these twenty-five values, and nothing else is a scope:

Read Write / manage
agents:read agents:write
contacts:read contacts:write
campaigns:read campaigns:write
calls:read calls:write
reps:read calls:control
calls:listen_live
reps:write
compliance:read dnc:manage
exports:read exports:write
billing:read billing:write
requests:read requests:write
users:read messages:write
telephony:manage
webhooks:manage
keys:manage

Read and write are split wherever a read-only integration is plausible, so a CRM sync that only pulls outcomes must not be able to launch a campaign on the same credential. billing:write is split from billing:read for the same reason: a dashboard that renders spend must not be able to raise the cap that bounds it, or start a production subscription. dnc:manage, telephony:manage and keys:manage carry no split, because neither half of those has a meaningful read-only use - listing keys is GET /v1/api-keys, which reveals no secret.

calls:control is separate from calls:write, and calls:listen_live is separate from calls:read: a reporting or disposition integration must never silently gain authority over a live carrier leg or raw live audio. Those two scopes name future P2 controls; the currently shipped handoff foundation only offers durable rep configuration and a fail-closed readiness read, not a transfer, realtime token, or audio feed.

One asymmetry worth knowing before you design around it: there is no messages:read. messages:write remains schema-reserved, but campaign SMS is unavailable in this deployment; do not treat the scope as proof that a live conversation can send a message.

An endpoint declares the scopes it needs: one for almost all, more where data crosses a boundary. GET /v1/analytics/latency declares both campaigns:read and calls:read, because it aggregates the turn ledger that everything else guards with calls:read. A key that lacks a declared scope gets 403 insufficient_scope. Two endpoints declare no scope at all - GET /v1/me and GET /v1/api-keys - because authentication alone is their check and neither returns a secret.

Spell scopes exactly, and the API will tell you if you did not

Section titled “Spell scopes exactly, and the API will tell you if you did not”

POST /v1/api-keys validates scopes[] against the catalog. A value that is not in the table above is refused with 422 unknown_scope, naming the field and the offending strings - the key is not minted. (This is a change: the endpoint used to store scope strings verbatim, so campaigns:wrte minted a credential that appeared to grant something and granted nothing, surfacing much later as a baffling insufficient_scope.)

scopes[] is also required and non-empty. A key with no scopes authenticates and can call nothing but GET /v1/me, which is the same silent uselessness arriving through a default instead of a typo.

Minting, rotating and revoking credentials all require the keys:manage scope, and three further rules hold on top of it:

  • A key may only grant scopes it holds. Asking for more is 403 insufficient_scope, so the narrow key you hand a contractor cannot mint itself full access.
  • A test key cannot mint a live key (key_mode_escalation). The mode a key may issue is the mode it holds, never a field the caller supplies.
  • A key may not revoke or rotate a key broader than itself - except its own, which it may always do. Without this, a calls:read key could revoke your root credential and take the whole account offline.

Every mint, rotation and revocation is written to your tenant’s audit log.

Rotation replaces a secret without a gap and without a window where two credentials are live:

POST /v1/api-keys/{key_id}/rotate
curl -X POST https://api.vocapable.com/v1/api-keys/key_01J9.../rotate \
-H "Authorization: Bearer vcp_test_..." \
-H "Content-Type: application/json" \
-d '{"name": "billing-sync (rotated 2026-08-11)"}'
{
"id": "key_01JB0F...",
"name": "billing-sync (rotated 2026-08-11)",
"display_prefix": "vcp_test",
"mode": "test",
"scopes": ["calls:read"],
"created_at": "2026-08-11T18:22:04Z",
"expires_at": null,
"revoked_at": null,
"secret": "vcp_test_...",
"replaced_api_key_id": "key_01J9..."
}

The retirement and the mint happen in one transaction, so a lost response leaves exactly one live key, and replaying the request answers 404 api_key_not_found rather than handing you a second replacement.

The replacement carries the retired key’s mode and scopes, not the caller’s. That is what lets a narrow hand-off key replace its own secret, and it is why the request body has no scopes field: rotation replaces a credential, it does not re-grant one. To change what a key may do, mint a new one and revoke the old.

Two more things worth doing when you mint:

  • Name your keys. Without a name you are choosing between 8-character prefixes when you decide which one to retire.
  • Set expires_at on anything you hand to a third party. Expiry is enforced at authentication, so the key stops working at that instant with the usual unauthenticated body.

Newly issued tenant-first keys are granted the current full twenty-five-scope catalog at onboarding. Migration 0041 does not widen historical keys, and rotation preserves a key’s exact grants; mint a deliberately authorized replacement to add a new scope. For production integrations, mint purpose-scoped keys per system: a webhook consumer needs no key at all, an outcome-sync job needs only the :read scopes it polls, and only your dial path needs calls:write. Include keys:manage on any key whose holder should be able to rotate it.

Dashboard users authenticate with first-party accounts and a session cookie, not an API key. Those routes exist on /v1 and appear in the API reference - POST /v1/auth/sign-up, POST /v1/auth/sign-in, GET/DELETE /v1/auth/session, the two password-resets routes and the two email-verifications routes - but an API key gets 401 on all of them, and every mutation among them additionally requires the session’s CSRF token. The same is true of /v1/onboarding.

The practical consequence for a headless integrator: signing up, paying the sandbox fee, and obtaining the initial test key are browser flows. The separate production-readiness request and one-time live-key handoff are also owner-session browser routes; neither exposes a customer API promotion mutation. Once a key has been safely delivered to the operator, everything within that key’s scopes is the API.