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.

ScopeAddsUse it for
readEvery GET, plus the read-only POSTs (reports, research)Dashboards, alerting, exports
writeThe 21 proposal endpoints, and triggering auditsOptimization automation
adminConnection, account import, sync triggers, targets, briefProvisioning

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} from GET /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 literal current. A different projectId returns 403 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:

BucketLimitEndpoints
Read120Every GET
Write30Proposals, decisions, sync/audit triggers
Expensive10/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.

On this page