Scopes
A scope is a resource:action pair. A key holds a set of them, chosen when the key is created, and an endpoint declares the ones it requires. There is no inheritance and no wildcard — contact:write does not imply contact:read.
The vocabulary is defined by API.SCOPE in src/lib/const/api.const.ts.
The full list
| Scope | Grants |
|---|---|
call:read | List and fetch calls. |
contact:read | List and fetch contacts. |
contact:write | Create, update and delete contacts, and add numbers to the do-not-call list. |
campaign:read | List and fetch campaigns and their contacts. |
campaign:write | Create and update campaigns, and add contacts to them. |
transcript:read | Fetch call transcripts. |
number:read | List the phone numbers this organization holds. |
webhook:read | List endpoints and their delivery log. See webhooks. |
webhook:write | Create, update and delete webhook endpoints, rotate their secrets, send tests. |
lead_result:write | Report what became of a lead after the call. See lead results. |
qa:read | Fetch QA reviews and the rubrics they are scored against. See QA reviews and QA rubrics. |
qa:write | Request AI QA reviews (charged to credit) and delete them; create, edit and archive rubrics. |
usage:read | List and sum what the organization was charged. Not a default. See usage. |
Thirteen scopes, read and write and nothing finer. The dashboard's own permission model is more granular internally, but an integration is one program with one credential, and a scope list long enough to need a manual is a scope list nobody reads before ticking every box.
Because these two vocabularies are deliberately separate, an internal permissions refactor cannot silently change what your key can do.
The default
A key created without an explicit scope list gets every read but usage:read, and no write:
call:read contact:read campaign:read transcript:read number:read webhook:read qa:readNot the empty set, which would make such a key 403 on everything and read as a broken API; and never everything, because the one thing a default must not do is hand out mutation. usage:read is left out because spend is billing data, which the dashboard shows only to those who may see billing: a key reads it only when it was granted on purpose.
The practical consequence: any key that can write was granted that explicitly. If you minted a key for contact import, it almost certainly holds contact:write and nothing else — which is exactly the case the contacts import endpoint handles specially when you start sending campaign_ids.
When a scope is missing
A key that authenticated but lacks a required scope gets 403 with the missing scopes named:
json
{
"error": {
"code": "FORBIDDEN",
"message": "This API key is missing the campaign:write scope."
}
}The message lists every missing scope, comma-separated, so one round trip tells you the whole shortfall rather than one item of it. The wording is identical whether the check happened before or after the request body was read.
We do not silently degrade. An endpoint will never accept a request, ignore the part your key was not allowed to do, and return 200. If the answer is partly no, the answer is 403.
Checking what a key holds
The root endpoint returns the key's scopes:
json
{ "data": { "scopes": ["contact:read", "contact:write"] } }That list is filtered against the scopes this build actually recognises, so a key still carrying a grant we have since retired is shown what it can use today, not what it was minted with. Treat the root endpoint's answer as authoritative over your own records.
Organization kinds
Separately from scopes, some endpoints are restricted by what the organization is rather than what the key may do. Contacts, for example, are buyer-side, so the import endpoint is unavailable to a seller-side organization regardless of scopes:
json
{
"error": {
"code": "FORBIDDEN",
"message": "This endpoint is only available to buyer organizations."
}
}This is a property of the tenant, not of the credential — a differently-scoped key will not change the answer.