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 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

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 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:

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 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:

#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 present in the key store1100401Invalid API key.
4Key record's status is not active1100401API key is inactive.
5Key record has no organization bound to it1102403This 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.

{
  "error": 1100,
  "message": "Invalid API key."
}

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
1501503The key store (or another upstream) is unreachable or returned 5xx

The full response bodies:

{ "error": 1100, "message": "Missing bearer token. Send Authorization: Bearer gat_..." }
{ "error": 1100, "message": "Invalid API key." }
{ "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 to manage API keys. Ask an account admin to enable the 'keys:write' scope." }
{ "error": 1501, "message": "Authentication service is temporarily unavailable." }

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.

# 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 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.

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 progress.
calls:writePlace a call and stop a call in progress.
batches:writeCreate, upload, schedule, pause, resume and stop batches.
webhooks:writeManage webhook subscriptions: callback URLs, event types and signing secrets.
keys:writeMint and revoke API keys. Never granted implicitly.
audit:readRead 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 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

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:

ScopeEnforced on
agents:readGET /v2/agents, GET /v2/agents/{agent_id}, GET /v2/agents/{agent_id}/executions
agents:writePOST /v2/agents, PUT/PATCH/DELETE /v2/agents/{agent_id}, POST /v2/agents/{agent_id}/clone
calls:readGET /v2/calls, GET /v2/calls/{execution_id}, its /transcript, and the three GET /v2/batches reads
calls:writePOST /v2/calls, POST /v2/calls/{execution_id}/stop
batches:writePOST /v2/batches, /upload, and {batch_id} schedule / stop / pause / resume
keys:writePOST /v2/api-keys, DELETE /v2/api-keys/{key_id}
audit:readGET /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:

# 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.
  • 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."
}

Key management is still checked by the older keys:write grant and answers with its own wording:

{
  "error": 1102,
  "message": "This API key is not authorized to manage API keys. Ask an account admin to enable the 'keys:write' scope."
}

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:

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_idstringMust equal the key's own organization

Scope required: none.

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
    }
  ],
  "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.

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

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.

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.
  • Permissions. The minting key's list, with keys:write stripped unless you set 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."
}

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:

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

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

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 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_id is 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_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 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:

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 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:

{
  "error": 1300,
  "message": "Rate limit exceeded: 1000 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 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:

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.
1501503An upstream is unreachable or returned 5xx
1502504An upstream exceeded its timeout

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.