Authentication
API keys, scopes, and how a key reaches an account.
Every request carries a bearer key:
curl https://your-app.example.com/api/v1/accounts \
-H "Authorization: Bearer tsk_your_key"Keys are minted in Workspace → Settings → API keys (org-wide) or a project's Settings → API keys (one account). The plaintext is shown once at creation and never again — only a SHA-256 hash is stored, so a lost key must be revoked and replaced.
The same key also authenticates the MCP server:
send it as the Bearer there and a headless agent gets the asa_* tools with
exactly this key's scope, account reach and expiry. Both surfaces verify it
with the same code, so revoking a key stops REST and MCP on the next request.
A key is an agent credential: nothing it proposes auto-applies, on either
surface.
Scopes
Scopes are ranked, not orthogonal: a key satisfies a requirement when its own tier is at least as high.
| Scope | Adds | Use it for |
|---|---|---|
read | Every GET, plus the read-only POSTs (reports, research) | Dashboards, alerting, exports |
write | The 21 proposal endpoints, and triggering audits | Optimization automation |
admin | Connection, account import, sync triggers, targets, brief | Provisioning |
The approval grant
Approving, rejecting and reverting queued actions needs the separate
canApprove grant on top of write — not a higher rung. Proposing a
change and deciding on it are different authorities: an integration that only
needs to queue work shouldn't be able to wave it through. (Earlier versions had
an approve rung; existing approve keys became write plus the grant.)
Every change a key proposes queues for review, even one the safe-apply policy would auto-apply for a person. A key is an unattended caller, so applying stays a separate, deliberate decision.
A scope failure returns 403 forbidden_scope naming both what was needed and
what the key has:
{
"error": {
"code": "forbidden_scope",
"message": "This endpoint requires 'admin' scope (import accounts, change optimization targets) — this key has 'write' scope.",
"required_scope": "admin",
"key_scope": "write"
}
}A refusal at the grant carries required_grant: "approve" instead of
required_scope.
Reaching an account
A key is bound one of three ways:
- Org-scoped — reaches every account in the workspace. Pass an explicit
{projectId}fromGET /accounts. - Org-scoped, limited to a subset — same, but targeting an account outside
the set returns
403 account_mismatch. - Account-scoped — bound to one account. Pass its
projectId, or the literalcurrent. A differentprojectIdreturns403 account_mismatch.
Org-level endpoints (GET /accounts, GET /connection,
POST /connection/discover) refuse account-scoped keys with
403 forbidden_scope — such a key can't see the workspace's other accounts and
shouldn't be able to enumerate them.
current on an org-scoped key returns 400 invalid_request: there is no one
account for it to mean.
Rate limits
Per key, per minute, enforced per running instance:
| Bucket | Limit | Endpoints |
|---|---|---|
| Read | 120 | Every GET |
| Write | 30 | Proposals, decisions, sync/audit triggers |
| Expensive | 10 | /reports, /connection/discover, /research/* |
Successful responses carry RateLimit-Limit, RateLimit-Remaining and
RateLimit-Policy. A 429 carries retry_after_ms in the body and
Retry-After in the headers.
Treat these as a runaway-loop backstop rather than a quota. The real global cap on the money surface is the safe-apply policy's 20 auto-applies per account per 24 hours, which is database-derived and therefore actually global — see Policy states.