Graine AI

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.

curl https://api.graine.ai/v2/user/me \
  -H "Authorization: Bearer gat_YOUR_KEY_HERE"

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

PropertyValue
HeaderAuthorization
SchemeBearer (matched case-insensitively)
CredentialYour API key, including the gat_ prefix
Base URLhttps://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:

gat_1oJ9Zt7Yq2Kx4RbW8sNfHc0LdPmV3gTuA6iE5nQzYwU

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:

HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
  "error": 1101,
  "message": "A browser session token is not a public API key. Authenticate with Authorization: Bearer gat_..."
}

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:

#ConditionCodeHTTPMessage
1No Authorization header, non-Bearer scheme, or an empty credential1100401Missing bearer token. Send Authorization: Bearer gat_...
2Credential does not start with gat_1101401A browser session token is not a public API key. Authenticate with Authorization: Bearer gat_...
3Key is not recognised1100401Invalid API key. A key looks like 'gat_' followed by 43 characters; check yours was copied in full.
4The key's status is not active1100401API key is inactive.
5The key has no organization bound to it1102403This 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.

{
  "error": 1100,
  "message": "Invalid API key. A key looks like 'gat_' followed by 43 characters; check yours was copied in full."
}

These are the codes an authenticated request can hit before your handler runs:

CodeHTTPMeaning
1100401Missing, unknown, or inactive API key
1101401A non-gat_ bearer (a browser session token) was presented
1102403Key is valid but not for this organization, or lacks the required scope
1300429Per-organization request rate exceeded, or concurrency exhausted
1501503Your key could not be checked at that moment, or another service the request depends on is temporarily unavailable

The full response bodies:

{ "error": 1100, "message": "Missing bearer token. Send Authorization: Bearer gat_..." }
{ "error": 1100, "message": "Invalid API key. A key looks like 'gat_' followed by 43 characters; check yours was copied in full." }
{ "error": 1100, "message": "API key is inactive." }
{ "error": 1101, "message": "A browser session token is not a public API key. Authenticate with Authorization: Bearer gat_..." }
{ "error": 1102, "message": "This API key is not bound to an organization." }
{ "error": 1102, "message": "This API key is not authorized for that organization." }
{ "error": 1102, "message": "This API key is not authorized for this action. Missing scope: keys:write." }
{ "error": 1501, "message": "API key checks are temporarily unavailable." }

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.

# Redundant but harmless — this is the key's own org.
curl "https://api.graine.ai/v2/api-keys?organization_id=org_your_own" \
  -H "Authorization: Bearer gat_YOUR_KEY_HERE"
 
# 403, error 1102 — not your organization.
curl "https://api.graine.ai/v2/api-keys?organization_id=org_someone_else" \
  -H "Authorization: Bearer gat_YOUR_KEY_HERE"

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.

ScopeWhat it allows
agents:readList and read agents, their configuration and their call history.
agents:writeCreate, replace, update, clone and archive agents.
calls:readList and read calls, transcripts, recordings, and batch and campaign progress.
calls:writePlace a call and stop a call in progress.
batches:writeCreate, upload, schedule, pause, resume and stop batches, and manage the campaigns they dial under.
webhooks:readList and read webhook subscriptions and their delivery history.
webhooks:writeManage webhook subscriptions: callback URLs, event types and signing secrets.
keys:readList the organisation's API keys and their metadata. Never reveals a key.
keys:writeMint and revoke API keys. Never granted implicitly.
account:readRead the organisation's usage and consumption totals.
audit:readRead 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 scopesEffective 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 listDenied 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:

ScopeEnforced on
agents:readGET /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:writePOST /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:readGET /v2/calls, GET /v2/calls/{execution_id} and its /timing, /transcript and /recording, the four GET /v2/campaigns reads
calls:writePOST /v2/calls, POST /v2/calls/{execution_id}/stop, POST /v2/calls/{execution_id}/transfer
batches:writeThe campaign writes: POST /v2/campaigns, PATCH/DELETE /v2/campaigns/{campaign_id} and its pause / resume / revive / cancel
webhooks:readGET /v2/webhooks, GET /v2/webhooks/{subscription_id}, GET /v2/webhooks/deliveries, GET /v2/webhooks/deliveries/{delivery_id}
webhooks:writePOST /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:readGET /v2/api-keys
keys:writePOST /v2/api-keys, DELETE /v2/api-keys/{key_id}
account:readGET /v2/usage, GET /v2/usage/requests
audit:readGET /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:

# A key that can dial and read its own calls, and nothing else.
curl -X POST https://api.graine.ai/v2/api-keys \
  -H "Authorization: Bearer gat_YOUR_KEY_HERE" \
  -H "Content-Type: application/json" \
  -d '{ "name": "dialer-prod", "scopes": ["calls:read", "calls:write"] }'
  • Omit scopes and the new key inherits everything the minting key effectively holds, minus keys: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:write in scopes is dropped, without an error. The new key is minted without it. The only way to give a new key keys:write is grant_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:

{
  "error": 1102,
  "message": "This API key is not authorized for this action. Missing scope: agents:write."
}

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 byWhat the key can holdWho sees and revokes it in the dashboard
An AdminThe scopes the Admin chooses — unchanged from before rolesEvery Admin
A MemberAt most the member scopes below, and the key is recorded as made by that MemberThat Member, and every Admin
View onlyView only cannot create keys—

The member scopes:

agents:read   agents:write   calls:read   calls:write
batches:write  webhooks:read  account:read

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:

FieldDerivationUse
key_id"key_" + sha256(api_key)[:16]The identifier. Pass it to DELETE /v2/api-keys/{key_id}.
key_previewFirst 12 characters, ..., last 4 charactersDisplay only. Never an identifier.
key_id       key_9f2a4c1b8e7d3506
key_preview  gat_1oJ9Zt7Y...zYwU

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:

curl https://api.graine.ai/v2/user/me \
  -H "Authorization: Bearer gat_YOUR_KEY_HERE"
{
  "organization_id": "org_live_9312",
  "developer": {
    "id": "dev_4c1b8e7d3506af92",
    "name": "Billing integration",
    "key_id": "key_9f2a4c1b8e7d3506",
    "key_preview": "gat_1oJ9Zt7Y...zYwU"
  },
  "plan": "pro",
  "rate_limit_tier": "pro"
}

List keys

GET /v2/api-keys

Returns this organization's keys, newest first. Secrets are never included.

Query parameters

ParameterTypeDefaultNotes
pageinteger11-indexed, minimum 1
page_sizeinteger20Minimum 1, maximum 100
organization_idstring—Must equal the key's own organization

Scope required: keys:read.

curl "https://api.graine.ai/v2/api-keys?page=1&page_size=20" \
  -H "Authorization: Bearer gat_YOUR_KEY_HERE"
{
  "data": [
    {
      "key_id": "key_9f2a4c1b8e7d3506",
      "key_preview": "gat_1oJ9Zt7Y...zYwU",
      "developer_id": "dev_4c1b8e7d3506af92",
      "developer_name": "Billing integration",
      "developer_email": "ops@example.com",
      "plan": "pro",
      "rate_limit_tier": "pro",
      "token_type": "partner_api",
      "status": "active",
      "description": "Nightly reconciliation job",
      "created_at": "2026-08-14T09:12:44.518Z",
      "is_current": true,
      "scopes": ["agents:read", "calls:read", "calls:write"],
      "scopes_source": "explicit"
    }
  ],
  "pagination": {
    "page": 1,
    "page_size": 20,
    "total": 1,
    "total_pages": 1,
    "has_more": false
  }
}

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

POST /v2/api-keys

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

FieldTypeRequiredNotes
namestringyes1–100 characters, stored as the developer name. Whitespace-collapsed; must not be blank.
emailstringnoValid email address. Contact for the key's owner.
descriptionstringnoMax 500 characters. Free-text note; trimmed, and an empty string becomes null.
planstringnoMax 40 characters. Defaults to the minting key's plan and may never exceed it.
grant_api_keys_writebooleannoDefaults to false. Grants the new key keys:write.
scopesstring arraynoThe 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.

curl -X POST https://api.graine.ai/v2/api-keys \
  -H "Authorization: Bearer gat_YOUR_KEY_HERE" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Billing integration",
    "email": "ops@example.com",
    "description": "Nightly reconciliation job",
    "plan": "pro",
    "grant_api_keys_write": false
  }'
{
  "key_id": "key_c73e1a90f4d28b65",
  "api_key": "gat_1oJ9Zt7Yq2Kx4RbW8sNfHc0LdPmV3gTuA6iE5nQzYwU",
  "key_preview": "gat_1oJ9Zt7Y...zYwU",
  "developer_id": "dev_4c1b8e7d3506af92",
  "developer_name": "Billing integration",
  "plan": "pro",
  "rate_limit_tier": "pro",
  "org_id": "org_live_9312",
  "created_at": "2026-08-26T10:15:30.123Z",
  "message": "Save this key now - it will not be shown again."
}

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:write only with grant_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:

RankPlans
0free, trial
1basic, starter
2pro, growth
3business
4enterprise

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:

{
  "error": 1102,
  "message": "This API key cannot mint a key on the 'enterprise' plan. A new key may not exceed the minting key's plan ('pro')."
}

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.

{
  "error": 1201,
  "message": "This organization already has 25 active API keys. Revoke one before minting another."
}

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:

{
  "error": 1201,
  "message": "Another API key change is already in progress. Retry in a moment."
}

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

DELETE /v2/api-keys/{key_id}

Scope required: keys:write.

Path parameter

ParameterNotes
key_idThe key_... id from GET /v2/api-keys. Not the secret, and not the preview.
curl -X DELETE https://api.graine.ai/v2/api-keys/key_c73e1a90f4d28b65 \
  -H "Authorization: Bearer gat_YOUR_KEY_HERE"
{
  "key_id": "key_c73e1a90f4d28b65",
  "status": "inactive",
  "revoked": true,
  "revoked_at": "2026-08-26T10:41:02.774Z"
}

Four behaviours worth knowing:

  • Revocation is deactivation, not erasure. status becomes inactive and 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_at rather 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:

HeaderMeaning
X-RateLimit-LimitRequests permitted per 60-second window
X-RateLimit-RemainingRequests left in the current window
X-RateLimit-ResetSeconds 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:

{
  "error": 1300,
  "message": "Rate limit exceeded: 2000 requests per minute for this organization. Retry in 42s."
}

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:

CodeHTTPMeaning
1000400Malformed or contradictory input the schema could not reject
1001422Validation rejected the body or query; the message names the field
1002400A required parameter is absent
1100401Missing, unknown, or inactive API key
1101401A non-gat_ bearer (browser session token) was presented
1102403Key is valid but not for this organization, or lacks the scope
1200404No such agent, execution, batch or key in this organization
1201409State does not allow the action
1300429Per-organization request rate exceeded, or concurrency exhausted
1400402The wallet cannot fund the call
1500500Unhandled exception; the message is always An unexpected error occurred.
1501503A service the request depends on is temporarily unavailable
1502504A service the request depends on did not respond in time

A 1001 names the offending field so you can fix it without guessing:

{
  "error": 1001,
  "message": "Invalid value for 'name': ensure this value has at most 100 characters."
}

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.