The two dimensions combine. A key can be scoped, brand-restricted, both, or neither. This supports two common patterns:
- Least-privilege automations. A bot that only replies to comments holds a key with exactly
inbox:readandinbox:write, nothing else. - Agency and client isolation. A key limited to one client’s brand cannot read or touch any other client, so a leaked or shared key stays contained.
Empty means unrestricted. An empty
scopes array grants every scope. An empty brand_ids array grants every brand. A key is restricted only when at least one of the two arrays is non-empty. Every key created before this feature keeps full access with no change.Scopes
Scopes are a stable, public vocabulary in the formresource:action. A scope-restricted key may only perform operations covered by the scopes it holds.
Fetch the catalog live from the API so your integration always renders the current list:
GET /v1/keys/scopes is available to any valid key, including restricted ones.
Always available
A few identity and introspection endpoints stay available to any valid key regardless of its scopes, because they only ever return the caller’s own data or static reference material:GET /v1/users/me(your profile)GET /v1/usage(your credit usage)GET /v1/keys/scopes(this scope catalog)GET /v1/webhooks/events(the static webhook event catalog)- the platform capability catalog
Admin operations
Some operations are never available to a restricted key. They require a full-access key or the dashboard:- Managing API keys (create, list, edit, rotate, revoke)
- Editing or deleting your user profile
- Creating, updating, or deleting brands (listing brands is allowed with
accounts:read) - Managing brand invites
POST /v1/keys could mint itself an unrestricted key. Denying the whole admin tier to restricted keys closes that path completely. Create and manage keys from the dashboard, then hand the scoped keys to your machines.
Brand restrictions
A brand-restricted key behaves as if the allowed brands are the only brands that exist. Filtering is silent:- Reads of an out-of-scope account, brand, conversation, or interaction return the ordinary
404(account.not_found,brand.not_found, and so on). Never a403, and never any confirmation that the resource exists. - List endpoints (accounts, brands, posts, conversations, inbox, mentions) return only resources inside the allowed brands.
GET /v1/brandsreturns only the allowed brands.
Posts
Posts are not stored with a brand directly; they reach a brand through their target accounts. So the rule is expressed per target account:- Read. A post is visible to a brand-restricted key if at least one of its target accounts belongs to an allowed brand. A draft with no targets is therefore invisible to a brand-restricted key.
- Write (create, edit, publish, retry, delete). Every target account must belong to an allowed brand. A violation returns the same
404 account.not_foundas a nonexistent account, so there is no information leak.
Media stays shared
media:read and media:write remain available to a brand-restricted key. The media library is a single account-level asset pool. An upload is inert until it is attached to a post, and post writes are brand-checked, so a brand-scoped key cannot use media to reach another client’s brand.
Deleting a brand never widens a key
When you delete a brand that appears in a key’sbrand_ids, the now-stale ID stays in the array and simply matches nothing. It is deliberately not pruned: emptying the array would flip the key back to unrestricted, which would be a silent privilege escalation.
Account-level scopes and brand restriction
webhooks:manage and analytics:read govern data that is not partitioned by brand today. Webhook endpoints and deliveries, and the events log and exports, all span every brand in the account.
Because of that, a brand-restricted key is denied these two scopes even when the scope is granted. A key that is scope-restricted but not brand-restricted may hold them normally. In the dashboard, selecting one or more brands disables the webhooks:manage and analytics:read checkboxes for this reason.
Creating a scoped key
Passscopes and brand_ids to POST /v1/keys. Both are optional; omit or leave empty for full access on that dimension.
raw_key. The restriction fields are echoed back so you can confirm what was applied:
Validation
- An unknown scope returns
400 validation.scope_invalid, naming the offending value. - A brand ID that does not exist or that you do not own returns
400 validation.brand_invalid.
Reading a key’s restrictions
GET /v1/keys returns the scopes and brand_ids on every key. Both arrays are always present. Two empty arrays mean full access.
Changing a key’s restrictions
Restrictions are editable after creation. Use a full-access key or the dashboard. Edit in place withPATCH /v1/keys/{id}. This replaces the name, scopes, and brands. The key value is unchanged, so live automations keep working with their existing secret:
POST /v1/keys/{id}/rotate. This issues a new raw_key while keeping the same id, name, scopes, and brands. The old secret stops working immediately. Use it when a key may have leaked:
Error responses
When a restricted key is refused, the response is403 with code auth.insufficient_scope. The exact message depends on the reason:
404 (for example account.not_found), never a 403. This is deliberate: a scoped key gets no signal about whether resources it cannot reach exist.
Enforcement is fail-closed. A new operation that has not been mapped to a scope is denied to restricted keys rather than left open. Full-access keys, dashboard JWTs, and OAuth 2.1 access tokens (used by MCP clients) are never scope-restricted.