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 X-API-DEV header, no X-API-Key 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 local 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 any network call is made.
Treat the key as a password. It grants full access to your organization's agents, calls, batches, usage and wallet. It is never returned again after the response that mints it.
Session tokens are rejected
The dashboard authenticates browsers with Stytch B2B session tokens. Those
tokens work on the internal /api/v1 surface. 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 partner API. The two credentials have different blast radii and different revocation stories.
- A session carries no plan or rate-limit tier. Session authentication returns only a member id, an organization id and an auth method — there is nothing for per-organization tiering and rate limiting to key on.
The check is a prefix comparison on the credential itself, so a session token never costs a round-trip to the identity provider. It is rejected locally, before the key store is even read.
Rejection order
require_api_key evaluates in this exact order and stops 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 present in the key store | 1100 | 401 | Invalid API key. |
| 4 | Key record's status is not active | 1100 | 401 | API key is inactive. |
| 5 | Key record has no organization bound to it | 1102 | 403 | This API key is not bound to an organization. |
Step 2 returns before any network call. Steps 3 and 4 require the key store. Step 5 is a hard 403 rather than a permissive fallthrough: an organization-less principal running organization-scoped queries would read across tenants.
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 | The key store (or another upstream) is unreachable or returned 5xx |
The full response bodies:
1501 from the authentication layer means the key store was unreachable and
the in-process cache was cold. A warm cache is served instead of failing, so a
brief key-store outage does not take your integration down. 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 symmetry with the internal API, 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 eight 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 progress. |
calls:write | Place a call and stop a call in progress. |
batches:write | Create, upload, schedule, pause, resume and stop batches. |
webhooks:write | Manage webhook subscriptions: callback URLs, event types and signing secrets. |
keys:write | Mint and revoke API keys. Never granted implicitly. |
audit:read | Read the organization'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
Scope checks are live on these routes. 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 |
agents:write | POST /v2/agents, PUT/PATCH/DELETE /v2/agents/{agent_id}, POST /v2/agents/{agent_id}/clone |
calls:read | GET /v2/calls, GET /v2/calls/{execution_id}, its /transcript, and the three GET /v2/batches reads |
calls:write | POST /v2/calls, POST /v2/calls/{execution_id}/stop |
batches:write | POST /v2/batches, /upload, and {batch_id} schedule / stop / pause / resume |
keys:write | POST /v2/api-keys, DELETE /v2/api-keys/{key_id} |
audit:read | GET /v2/audit-logs, GET /v2/audit-logs/{event_id}, GET /v2/audit-logs/verify |
Everything else needs only a valid key: GET /v2/user/me, GET /v2/usage,
GET /v2/api-keys, GET /v2/scopes, and — for now — every route under
/v2/webhooks, /v2/knowledge-bases, /v2/agents/{agent_id}/versions,
/v2/phone-numbers, /v2/providers and /v2/inbound-agents.
webhooks:write is published but not yet gating
GET /v2/scopes lists POST /v2/webhooks and the subscription writes under
webhooks:write, because that is where the taxonomy is going. Those routes do
not check it yet: today a key without webhooks:write can still create,
edit and delete webhook subscriptions.
Treat it as a scope you should already be requesting on new keys, not as a control you can rely on for isolation. When it starts gating, a key that already carries it keeps working unchanged.
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. - An unknown scope string is a
1001/422 listing the valid values.
The 403
A scope denial from a gated route:
Key management is still checked by the older keys:write grant and
answers with its own wording:
Both are 1102, the same code as a cross-organization attempt. Branch on the
code and read message for the reason; never parse the message.
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: none.
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.
Rows are sorted newest first with a total, stable tie-break, so paging never shuffles a row between pages.
Errors: 1100, 1102 (wrong organization), 1300, 1501 (the key store
is unreachable — message: The API key store is 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. |
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.
- Permissions. The minting key's list, with
keys:writestripped unless you setgrant_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.
The whole read-modify-write of the key store runs under a per-organization lock. If another key change is already in flight, the request is refused rather than queued — retry it:
Errors: 1000, 1100, 1102 (missing scope, wrong organization, 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 guard runs
before the key store is even read, 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. The organization filter is
applied before any
key_idis computed, so a key id harvested elsewhere cannot select another tenant's record — 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 are not instantaneous everywhere. Validated keys are cached in-process for 30 seconds. A mint or a revoke clears that cache on the worker that served the request, but other workers can serve a stale answer for up to 30 seconds. Allow a 30-second 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 runtime 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. Successful responses carry 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 1000 requests per minute on most routes, and 500 per minute on
call creation, batch creation and execution listing. 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 an upstream blip 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, an upstream 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 | An upstream is unreachable or returned 5xx |
1502 | 504 | An upstream exceeded its timeout |
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.

