Authentication
Every Graine public API request authenticates with a gat_ bearer key. How keys work, how to mint, list and revoke them, every failure response, and how to rotate without downtime.
The public API lives under /v2 and accepts exactly one credential: a
gat_ API key sent as an HTTP bearer token.
There is no second way in: no other header, no ?api_key= query parameter,
and no browser session token. One documented path
means a partner integration cannot half-work — it either authenticates or it
fails loudly.
The bearer scheme
| Property | Value |
|---|---|
| Header | Authorization |
| Scheme | Bearer (matched case-insensitively) |
| Credential | Your API key, including the gat_ prefix |
| Base URL | https://api.graine.ai/v2 |
The scheme and the credential are split on the first run of whitespace, so
Authorization: Bearer gat_abc and Authorization: bearer gat_abc are both
accepted. Anything that is not two whitespace-separated parts — a bare
Authorization: gat_abc with no scheme, or Authorization: Basic ... — is
treated as no credential at all and returns 1100, not 1101.
Key format
Keys always start with the literal prefix gat_, followed by a URL-safe random
string minted from 32 bytes of entropy:
The prefix is what makes an immediate rejection possible. Because the API can
tell from the first four characters whether a credential could ever be a valid
API key, a non-gat_ bearer is refused before anything else is checked.
Treat the key as a password. It grants full access to your organization's agents, calls, batches, usage and wallet — within its scopes, and within the role of the person who made it (see Keys and workspace roles). It is never returned again after the response that mints it.
Session tokens are rejected
The dashboard signs people in with browser session tokens. Those are for the
dashboard only: on /v2 they are refused outright with a dedicated error code:
Two reasons this is its own code rather than a generic 1100:
- A leaked dashboard session must never drive the public API. The two credentials have different blast radii and different revocation stories.
- A session carries no plan or rate-limit tier, so it cannot be metered the way a key is.
The check is a prefix comparison on the credential itself, so a session token is refused immediately, before any key is looked up.
Rejection order
A key is checked in this exact order, stopping at the first failure. Knowing the order tells you what a given code rules out:
| # | Condition | Code | HTTP | Message |
|---|---|---|---|---|
| 1 | No Authorization header, non-Bearer scheme, or an empty credential | 1100 | 401 | Missing bearer token. Send Authorization: Bearer gat_... |
| 2 | Credential does not start with gat_ | 1101 | 401 | A browser session token is not a public API key. Authenticate with Authorization: Bearer gat_... |
| 3 | Key is not recognised | 1100 | 401 | Invalid API key. A key looks like 'gat_' followed by 43 characters; check yours was copied in full. |
| 4 | The key's status is not active | 1100 | 401 | API key is inactive. |
| 5 | The key has no organization bound to it | 1102 | 403 | This API key is not bound to an organization. |
Step 2 is decided from the credential alone. Steps 3 and 4 look the key up. Step 5 is a hard 403 rather than a permissive fallthrough: a key with no organization must never be able to read across organizations.
Every auth failure response
All /v2 errors use one flat envelope — no nesting, no detail key, no
validation array. The HTTP status is derived from the integer code, so the two
can never drift apart.
These are the codes an authenticated request can hit before your handler runs:
| Code | HTTP | Meaning |
|---|---|---|
1100 | 401 | Missing, unknown, or inactive API key |
1101 | 401 | A non-gat_ bearer (a browser session token) was presented |
1102 | 403 | Key is valid but not for this organization, or lacks the required scope |
1300 | 429 | Per-organization request rate exceeded, or concurrency exhausted |
1501 | 503 | Your key could not be checked at that moment, or another service the request depends on is temporarily unavailable |
The full response bodies:
1501 from authentication means your key could not be checked just then. A key
that was checked in the last few seconds keeps working through a brief
interruption, so this is rare. Retry with backoff.
Organization scoping
An API key is bound to exactly one organization at mint time, and that binding
is the whole tenancy boundary. Every /v2 route resolves the organization from
the key — never from the request.
For compatibility, routes accept an organization_id query
parameter. It is validated, not honoured: any value other than the key's own
organization is a hard 1102/403. It is never a silent override.
Scopes
/v2 defines eleven scopes. GET /v2/scopes returns this table, the routes
each one gates, and whether the calling key holds it — call it first whenever a
403 surprises you.
| Scope | What it allows |
|---|---|
agents:read | List and read agents, their configuration and their call history. |
agents:write | Create, replace, update, clone and archive agents. |
calls:read | List and read calls, transcripts, recordings, and batch and campaign progress. |
calls:write | Place a call and stop a call in progress. |
batches:write | Create, upload, schedule, pause, resume and stop batches, and manage the campaigns they dial under. |
webhooks:read | List and read webhook subscriptions and their delivery history. |
webhooks:write | Manage webhook subscriptions: callback URLs, event types and signing secrets. |
keys:read | List the organisation's API keys and their metadata. Never reveals a key. |
keys:write | Mint and revoke API keys. Never granted implicitly. |
account:read | Read the organisation's usage and consumption totals. |
audit:read | Read the organisation's audit trail and verify its hash chain. |
Absent is not empty
This is the one rule to take away, and it is the difference between two values that are both falsy in most languages:
A key's stored scopes | Effective policy |
|---|---|
Absent (no scopes field at all) | Unrestricted. The key predates the scope model and holds every scope except keys:write. GET /v2/scopes reports "scopes_source": "grandfathered". |
[] — an explicit empty list | Denied everything. An empty policy is honoured as one. This is how you mint a deliberately inert key. |
A list, e.g. ["calls:read","calls:write"] | Exactly those, plus resource:* and * wildcards if present. |
Every key minted before scopes existed carries no scopes field, and is
therefore unrestricted — deliberately. Reinterpreting an absent policy as "deny"
would have broken every integration built against this API so far. Every key
minted since carries an explicit list, so the grandfather clause does not
propagate.
keys:write is the exception in both directions: it is never implied by a
grandfathered key, and it must be granted deliberately with
grant_api_keys_write: true. It is the scope that can extend every other one.
What is enforced today
Every route you call with a key checks a scope, reads included, with two
exceptions below. A key that holds an explicit list without the named scope gets
1102/403, and the denial is itself recorded in the audit trail:
| Scope | Enforced on |
|---|---|
agents:read | GET /v2/agents, GET /v2/agents/{agent_id}, GET /v2/agents/{agent_id}/executions, the A/B experiment reads under /v2/agents/{agent_id}/versions, the knowledge-base reads and POST /v2/knowledge-bases/{collection}/query, GET /v2/phone-numbers, GET /v2/providers, GET /v2/inbound-agents, POST /v2/transliterate |
agents:write | POST /v2/agents, PUT/PATCH/DELETE /v2/agents/{agent_id}, POST /v2/agents/{agent_id}/clone, the A/B experiment writes, the knowledge-base uploads and deletes, POST /v2/inbound-agents, PUT /v2/inbound-agents/{application_sid}/webhooks |
calls:read | GET /v2/calls, GET /v2/calls/{execution_id} and its /timing, /transcript and /recording, the four GET /v2/campaigns reads |
calls:write | POST /v2/calls, POST /v2/calls/{execution_id}/stop, POST /v2/calls/{execution_id}/transfer |
batches:write | The campaign writes: POST /v2/campaigns, PATCH/DELETE /v2/campaigns/{campaign_id} and its pause / resume / revive / cancel |
webhooks:read | GET /v2/webhooks, GET /v2/webhooks/{subscription_id}, GET /v2/webhooks/deliveries, GET /v2/webhooks/deliveries/{delivery_id} |
webhooks:write | POST /v2/webhooks, PATCH/DELETE /v2/webhooks/{subscription_id}, POST /v2/webhooks/test, GET /v2/webhooks/signing-secret, PATCH /v2/agents/{agent_id}/event-subscriptions |
keys:read | GET /v2/api-keys |
keys:write | POST /v2/api-keys, DELETE /v2/api-keys/{key_id} |
account:read | GET /v2/usage, GET /v2/usage/requests |
audit:read | GET /v2/audit-logs, GET /v2/audit-logs/{event_id}, GET /v2/audit-logs/verify |
The two exceptions need only a valid key: GET /v2/user/me, because a key may
always describe itself, and GET /v2/scopes, because a key must always be able
to find out why it was refused.
Minting a narrower key
POST /v2/api-keys takes an optional scopes array:
- Omit
scopesand the new key inherits everything the minting key effectively holds, minuskeys:write. A grandfathered minter writes the full taxonomy out explicitly rather than passing its own absence on. - Send
[]and you mint a key that can do nothing. That is allowed, and it means what it says. - You can never grant a scope you do not hold. Asking for one is a
1102/403 naming the scopes — not a silently trimmed list, because a caller who believes it minted a broader key will ship an integration built on it. keys:writeinscopesis dropped, without an error. The new key is minted without it. The only way to give a new keykeys:writeisgrant_api_keys_write: true.- An unknown scope string is a
1001/422 listing the valid values.
The 403
A scope denial from a gated route:
Minting or revoking without keys:write is the same refusal, naming
keys:write. It is 1102, the same code as a cross-organization attempt.
Branch on the code and read message for the reason; never parse the message.
Keys and workspace roles
A key can never do through the API what the person who made it cannot do in the dashboard. Every workspace member is an Admin, a Member or View only (Team roles), and a key carries its creator's limits with it.
| Created by | What the key can hold | Who sees and revokes it in the dashboard |
|---|---|---|
| An Admin | The scopes the Admin chooses — unchanged from before roles | Every Admin |
| A Member | At most the member scopes below, and the key is recorded as made by that Member | That Member, and every Admin |
| View only | View only cannot create keys | — |
The member scopes:
A Member's key never holds keys:write, so it cannot create or revoke keys.
Where an action is admin-only in the dashboard — deleting or archiving an agent,
deleting a campaign — the API refuses it for a Member's key with a 403, even
though the key holds agents:write or batches:write.
In the dashboard, a Member's API keys page lists only the keys they created; an Admin's lists every key in the workspace with who created it, and keys a Member made are marked as such.
Existing keys are not affected
Keys minted before roles existed, and every key an Admin mints, behave exactly as they did. A live integration does not need to change anything.
Identifying a key
You never address a key by its secret. Two derived values do that job:
| Field | Derivation | Use |
|---|---|---|
key_id | "key_" + sha256(api_key)[:16] | The identifier. Pass it to DELETE /v2/api-keys/{key_id}. |
key_preview | First 12 characters, ..., last 4 characters | Display only. Never an identifier. |
key_id is stable and non-reversible: it identifies a key permanently without
ever exposing anything that could be replayed as a credential. A key of 16
characters or fewer is returned as-is in key_preview rather than masked.
To find the key_id of the key you are currently holding, call
GET /v2/user/me and read developer.key_id:
List keys
Returns this organization's keys, newest first. Secrets are never included.
Query parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
page | integer | 1 | 1-indexed, minimum 1 |
page_size | integer | 20 | Minimum 1, maximum 100 |
organization_id | string | — | Must equal the key's own organization |
Scope required: keys:read.
status is "active" or "inactive". An inactive key is refused at
authentication with 1100. is_current is true for the key making the
request — the one you must not revoke while you are using it.
scopes is the key's own list, and scopes_source is "explicit". A key
minted before scopes existed has scopes null and scopes_source
"grandfathered": it holds every scope except keys:write. An empty list is
different — a key that holds nothing.
Rows are sorted newest first with a total, stable tie-break, so paging never shuffles a row between pages.
Errors: 1100, 1102 (missing keys:read, or wrong organization), 1300,
1501 (keys could not be read at that moment — message:
API keys are temporarily unavailable.).
Mint a key
Returns 201 Created. This is the only response in the entire API that ever
carries a full secret, and it is shown exactly once.
Scope required: keys:write.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | 1–100 characters, stored as the developer name. Whitespace-collapsed; must not be blank. |
email | string | no | Valid email address. Contact for the key's owner. |
description | string | no | Max 500 characters. Free-text note; trimmed, and an empty string becomes null. |
plan | string | no | Max 40 characters. Defaults to the minting key's plan and may never exceed it. |
grant_api_keys_write | boolean | no | Defaults to false. Grants the new key keys:write. |
scopes | string array | no | The new key's scopes. See Minting a narrower key. |
Unknown fields are rejected, not ignored — a typo'd field name is a loud
1001/422 rather than a silently dropped setting.
Capture api_key from this response and store it immediately. No endpoint can
show it again. If you lose it, revoke the key and mint another.
What the new key inherits
- Organization. Always the minting key's own. It cannot be redirected.
- Scopes. The ones you sent in
scopes, or without it everything the minting key holds.keys:writeonly withgrant_api_keys_write: true. - Plan. The minting key's plan, or a lower one — never higher.
- Rate-limit tier. Set equal to the resolved plan.
Plan ceiling
A key may mint another key on its own plan or a lower one. Requesting a higher
plan is 1102/403. Plans are ranked:
| Rank | Plans |
|---|---|
| 0 | free, trial |
| 1 | basic, starter |
| 2 | pro, growth |
| 3 | business |
| 4 | enterprise |
A plan name that is not in this table can only be minted as an exact (case-insensitive) copy of the minting key's own plan. Anything else:
Limits and contention
An organization may hold 25 active keys. Revoked keys are deactivated rather than deleted and do not consume the cap, so rotation never permanently locks you out.
Key changes — mints and revokes — are applied one at a time, across all organizations, and each holds the others off for at most 10 seconds, so the change in progress may not be yours. A request that arrives meanwhile is refused rather than queued — retry it:
If key changes cannot be coordinated at that moment, the change is refused
with 1501 (API keys are temporarily unavailable.) rather than risk
losing a key.
Errors: 1000, 1100, 1102 (missing scope, wrong organization, a scope
the minting key does not hold, or a plan above the minting key's), 1201 (a key
change is in flight, or the active-key ceiling is reached), 1001, 1300,
1501.
Revoke a key
Scope required: keys:write.
Path parameter
| Parameter | Notes |
|---|---|
key_id | The key_... id from GET /v2/api-keys. Not the secret, and not the preview. |
Four behaviours worth knowing:
- Revocation is deactivation, not erasure.
statusbecomesinactiveand authentication refuses the key from then on, but the record survives so an account admin can undo a mistake. - You cannot revoke the key you are authenticating with. That check runs
first, so an integration cannot lock itself out mid-run. It returns
1201/409:You cannot revoke the key you are authenticating with. - Cross-tenant ids return 404, never 403. Only your own organization's
keys can be selected, so a key id harvested elsewhere cannot reach another
tenant's key — and the API never confirms that another tenant's key exists. Unknown id:
{"error": 1200, "message": "API key not found."} - Repeating the call is safe. Revoking an already-inactive key is a no-op
that returns its original
revoked_atrather than stamping a fresh one over the real timestamp.
Errors: 1002 (blank key_id), 1100, 1102 (missing scope or wrong
organization), 1200 (no such key in this organization), 1201 (a key change
is in flight, or you tried to revoke your own key), 1300, 1501.
Rotating a key
Rotation is mint-then-revoke, in that order, and it needs no downtime.
Mint the replacement. POST /v2/api-keys. Store api_key from the
response — this is your only chance.
Deploy it. Both keys are valid at the same time. Roll the new key out to every worker, container and cron job that authenticates.
Confirm the new key works. Call GET /v2/user/me with it and check that
developer.key_id matches the key_id you were given at mint time.
Revoke the old key. DELETE /v2/api-keys/{old_key_id}, authenticating with
the new key. Authenticating with the old key would hit the self-revoke
guard and return 1201.
Verify. GET /v2/api-keys — the old row now reads "status": "inactive".
Key changes take up to 30 seconds to apply everywhere. A revoked key can keep working, and a new key can be refused, for up to 30 seconds after the change. Allow that settling window before asserting that a revoked key is dead, or that a fresh key is live.
Handling keys well
Never put a key in a browser or a mobile binary. A /v2 key carries your
whole organization. Call /v2 from your server; the dashboard's own session
credential is deliberately rejected here precisely so the two never get
confused.
Store keys in a secret manager, not in source. The only copy you will ever receive is the one in the mint response. Commit that to a repository and the only remedy is rotation.
Give each integration its own key. key_id, developer_name and
description are all listed by GET /v2/api-keys, so per-integration keys
make an incident a one-line revoke instead of a fleet-wide rotation. The
25-active-key ceiling is generous enough for that.
Keep the minting scope narrow. Leave grant_api_keys_write at false for
anything that does not manage keys. A key that cannot mint keys cannot
escalate.
Rate limits are per organization, not per key. Minting more keys does not buy more quota — the counter is keyed on the organization. Every response after your key is authenticated, errors included, carries the current state:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests permitted per 60-second window |
X-RateLimit-Remaining | Requests left in the current window |
X-RateLimit-Reset | Seconds until the window rolls |
The ceilings are 2000 requests per minute on most routes; 1200 per minute —
1000 you can rely on sustained — on placing, stopping and transferring calls,
creating campaigns, and reading calls; 20 on knowledge-base uploads; and 60 on
transliteration. Exceeding one returns 1300/429 with Retry-After alongside
the three headers above:
Live figures for your own organization are on GET /v2/user/me under
rate_limits (default_per_minute, calls_per_minute,
executions_per_minute).
Retry 503, not 401. A 1501 is a temporary problem on our side and is
worth retrying with backoff. A 1100, 1101 or 1102 is a decision about your credential and
will return the same answer however many times you repeat it — fix the key
instead.
Read the integer, not the sentence. Branch on error; messages are written
for humans and may be reworded. message is always a single sentence and never
contains a stack trace, another system's response body, a database query or a
phone number.
Error code reference
Every /v2 code, so you can write one handler for the whole surface:
| Code | HTTP | Meaning |
|---|---|---|
1000 | 400 | Malformed or contradictory input the schema could not reject |
1001 | 422 | Validation rejected the body or query; the message names the field |
1002 | 400 | A required parameter is absent |
1100 | 401 | Missing, unknown, or inactive API key |
1101 | 401 | A non-gat_ bearer (browser session token) was presented |
1102 | 403 | Key is valid but not for this organization, or lacks the scope |
1200 | 404 | No such agent, execution, batch or key in this organization |
1201 | 409 | State does not allow the action |
1300 | 429 | Per-organization request rate exceeded, or concurrency exhausted |
1400 | 402 | The wallet cannot fund the call |
1500 | 500 | Unhandled exception; the message is always An unexpected error occurred. |
1501 | 503 | A service the request depends on is temporarily unavailable |
1502 | 504 | A service the request depends on did not respond in time |
A 1001 names the offending field so you can fix it without guessing:
All /v2 timestamps — created_at, revoked_at and every other — are
ISO-8601 UTC with a literal Z and millisecond precision:
2026-08-26T10:15:30.123Z.

