Skip to content

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 ​

ScopeGrants
call:readList and fetch calls.
contact:readList and fetch contacts.
contact:writeCreate, update and delete contacts, and add numbers to the do-not-call list.
campaign:readList and fetch campaigns and their contacts.
campaign:writeCreate and update campaigns, and add contacts to them.
transcript:readFetch call transcripts.
number:readList the phone numbers this organization holds.
webhook:readList endpoints and their delivery log. See webhooks.
webhook:writeCreate, update and delete webhook endpoints, rotate their secrets, send tests.
lead_result:writeReport what became of a lead after the call. See lead results.
qa:readFetch QA reviews and the rubrics they are scored against. See QA reviews and QA rubrics.
qa:writeRequest AI QA reviews (charged to credit) and delete them; create, edit and archive rubrics.
usage:readList 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:read

Not 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.