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).
Key modes: vcp_test_ and vcp_live_
Section titled “Key modes: vcp_test_ and vcp_live_”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.
One 401 for every failure
Section titled “One 401 for every failure”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 twenty-five scopes
Section titled “The twenty-five scopes”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.
Key management is a privilege boundary
Section titled “Key management is a privilege boundary”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
403insufficient_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:readkey could revoke your root credential and take the whole account offline.
Every mint, rotation and revocation is written to your tenant’s audit log.
Rotating a key
Section titled “Rotating a key”Rotation replaces a secret without a gap and without a window where two credentials are live:
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_aton anything you hand to a third party. Expiry is enforced at authentication, so the key stops working at that instant with the usualunauthenticatedbody.
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.
Humans are separate
Section titled “Humans are separate”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.