---
title: "Authentication & API Keys"
description: "API keys, OAuth access tokens, webhook secrets, and how every credential is scoped to a single organization."
canonical: "https://docs.primitive.dev/docs/auth"
last-updated: "2026-05-17T00:00:00.000Z"
---

# Primitive authentication and API keys

Every authenticated request to `https://api.primitive.dev/v1` carries a bearer credential in the `Authorization` header. Primitive issues three kinds of bearer credential, each scoped to a single organization:

CredentialPrefixUse it forLifetimeAPI key`prim_`Server-to-server integrations, CI, headless agentsLong-lived until revokedOAuth access token`prim_oat_`CLI, desktop, local-agent, and third-party app accessShort-lived (1 hour), refreshableAgent connection key`pconn_`External runtime paired to one email addressUntil reconnect or revoke

```
curl https://api.primitive.dev/v1/whoami \
  -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN"
```


Set `PRIMITIVE_AUTH_TOKEN` to either an API key (`prim_...`) or an OAuth access token (`prim_oat_...`). The wire format is identical for both:


```
Authorization: Bearer prim_...
Authorization: Bearer prim_oat_...
```


Never put API keys, OAuth access tokens, or OAuth refresh tokens in client-side code, browser storage, screenshots, or logs.

## Organization scoping

Every credential authorizes exactly one organization. An API key or OAuth token resolves to one `org_id`, and all reads and writes are isolated to that org's data — you cannot reach another org's resources by changing an id in the URL. To act on a different organization, use a credential issued for that organization. Confirm what a credential resolves to with [GET /v1/whoami](#introspection-with-v1whoami).

## API keys

API keys (prefix `prim_`) are the simplest credential for backend services. Send the key as a bearer token on every request. A key works until it is revoked, or until its expiry if it was given one (`expires_at`; your organization's credential policy can require one). Rotating a key keeps its expiry unless you give the new key one (`expires_at` on the rotation). To keep an expiring key working, extend it before it expires with `primitive keys update <id> --expires-at <time>` or `PATCH /v1/keys/{id}`; an expired key cannot be extended. Treat a key as a secret: rotate it if it leaks, and prefer one key per integration so you can revoke narrowly.

Headless agents can obtain a key with no human in the loop — see [How to obtain credentials](#how-to-obtain-credentials).

## Agent connection keys

An owner creates a private invitation through `POST /v1/agent-connections`. The runtime claims it once through `POST /v1/agent-connections/claim` and stores the returned opaque `pconn_` key privately. Use that key as an ordinary HTTP bearer credential, with the [public setup guide](https://api.primitive.dev/v1/agent-connections/setup).

The key permits mail reads for its address, sending as its address, reading and writing its own address notes, reading the `AGENT_WORKING`, `AGENT_RUNTIME` and `AGENT_INFO` notes of other agents in its organization, and disconnecting its own address with `DELETE /v1/agent-connections/{address}`. It cannot call account, whoami, threads, raw downloads, or other connection-management operations. Claim returns its bound address and trusted setup owner address. Reconnect preserves the address and revokes its previous key.

## OAuth (Authorization Code + PKCE)

Primitive supports OAuth 2.0 Authorization Code with PKCE for public clients (no client secret). This is the right flow for CLIs, desktop apps, local agents, and third-party apps that act on behalf of a human.

Endpoints:


```
GET  /.well-known/oauth-authorization-server
GET  /.well-known/oauth-protected-resource
POST /oauth/register
GET  /oauth/authorize
POST /oauth/token
POST /oauth/revoke
```


Supported parameters:

FieldValue`response_types``code``grant_types``authorization_code`, `refresh_token``code_challenge_methods``S256``token_endpoint_auth_method``none` (public clients)`scope``primitive:api`

A grant currently authorizes full Primitive API access for the selected organization. Fine-grained OAuth scopes are not exposed yet.

The `/oauth/*` endpoints follow the OAuth 2.0 spec, not the v1 REST envelope: errors come back as `{ "error": "...", "error_description": "..." }` (for example `invalid_grant`, `invalid_request`, `unsupported_grant_type`), and a rate-limited token request returns the OAuth `slow_down` error. Redirect URIs are capped at 20 per client. Don't apply the [Errors](/docs/errors) `success`/`error` envelope handling to these endpoints.

### Dynamic Client Registration

Clients register themselves with no manual setup. `POST /oauth/register` returns a `client_id`:


```
curl -X POST https://api.primitive.dev/oauth/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "my-cli",
    "redirect_uris": ["http://127.0.0.1:8976/callback"]
  }'
```


```
{
  "client_id": "...",
  "client_id_issued_at": 1735689600,
  "client_name": "my-cli",
  "redirect_uris": ["http://127.0.0.1:8976/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}
```


Redirect URIs must be HTTPS, or `http://` on a loopback host (`localhost`, `127.0.0.1`, `::1`) for local tools. Fragments are not allowed.

### Authorization Code lifecycle

- Generate a PKCE `code_verifier` and its `S256` `code_challenge`.

- Send the user to `GET /oauth/authorize` with `response_type=code`, your `client_id`, `redirect_uri`, `code_challenge`, and `code_challenge_method=S256`. The user signs in and picks the organization to authorize.

- Primitive redirects back to your `redirect_uri` with a one-time `code`. Authorization codes expire after 10 minutes and can be exchanged only once.

- Exchange the code at `POST /oauth/token`:


```
curl -X POST https://api.primitive.dev/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=authorization_code \
  -d client_id=$CLIENT_ID \
  -d code=$CODE \
  -d redirect_uri=http://127.0.0.1:8976/callback \
  -d code_verifier=$CODE_VERIFIER
```


```
{
  "access_token": "prim_oat_...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "...",
  "scope": "primitive:api"
}
```


The access token is valid for 1 hour. Use it as a bearer token exactly like an API key.

### Refresh-token rotation

Refresh tokens are valid for 90 days and are single-use. Exchanging one issues a new access token **and a new refresh token**, and immediately revokes the one you presented:


```
curl -X POST https://api.primitive.dev/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=refresh_token \
  -d client_id=$CLIENT_ID \
  -d refresh_token=$REFRESH_TOKEN
```


Always store the new `refresh_token` from the response and discard the old one. Presenting a refresh token that has already been rotated or revoked is treated as token reuse: the entire grant is revoked, and every access and refresh token under it stops working. Re-run the authorization flow to recover.

### Revocation

Revoke a token (access or refresh) with `POST /oauth/revoke`. Revoking any token revokes the whole grant — both its access and refresh tokens:


```
curl -X POST https://api.primitive.dev/oauth/revoke \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d token=$TOKEN
```


Connected apps can also be revoked from **Settings -> Connected Apps**.

## Discovery metadata

Two metadata documents let clients (and agents) configure auth without hard-coding endpoints:


```
GET /.well-known/oauth-authorization-server
GET /.well-known/oauth-protected-resource
```


The authorization-server document (RFC 8414) advertises the `authorization_endpoint`, `token_endpoint`, `revocation_endpoint`, `registration_endpoint`, supported response/grant types, `code_challenge_methods_supported`, and token lifetimes. The protected-resource document (RFC 9728) describes this API as an OAuth-protected resource and points back at the authorization server.

### The 401 challenge

A request with a missing or invalid bearer token returns `401 unauthorized` with a spec-shaped `WWW-Authenticate` header pointing at the protected-resource metadata:


```
WWW-Authenticate: Bearer realm="Primitive API", resource_metadata="https://www.primitive.dev/.well-known/oauth-protected-resource"
```


A cold-starting client should fetch that metadata document, follow it to the authorization server, and obtain a token — rather than guessing endpoints or looping on the 401. See the [Errors](/docs/errors) reference for the full envelope.

## Introspection with /v1/whoami

`GET /v1/whoami` resolves the presented credential and reports what it authorizes. Use it to confirm the org, user, and credential type before acting:


```
curl https://api.primitive.dev/v1/whoami \
  -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN"
```


```
{
  "success": true,
  "data": {
    "org_id": "...",
    "user_id": "...",
    "role": "owner",
    "request_id": "req_...",
    "auth_method": "api_key",
    "key_id": "..."
  }
}
```


`auth_method` distinguishes an API key from an OAuth token; `key_id` identifies the specific credential (useful for audit and revocation).

## Webhook signing secrets

Webhook deliveries are signed with your organization's webhook secret, unless the webhook has a secret of its own (its `signed_with` is `own_secret`; see [Which secret](/docs/signature-verification#which-secret)). Verifiers need the secret to validate that a delivery came from Primitive: see [Signature verification](/docs/signature-verification).

Retrieve the organization's current secret (it is generated on first read if the org does not have one yet):


```
curl https://api.primitive.dev/v1/account/webhook-secret \
  -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN"
```


```
{ "secret": "<opaque-base64-secret>" }
```


Rotate the organization's secret when it may have leaked:


```
curl -X POST https://api.primitive.dev/v1/account/webhook-secret/rotate \
  -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN"
```


```
{ "secret": "<opaque-base64-secret>" }
```


Rotation is rate-limited to once per 60 minutes; a rotation inside that window returns `rate_limit_exceeded` with a `Retry-After` header. Before the new secret takes effect, rotation redeploys every Function that verifies with the organization secret, so it returns `409 function_not_deployed` while any Function is still deploying or a Function with code failed its last deploy: let the deploy finish, or redeploy or delete that Function, then rotate again. It returns `409 rotation_in_progress`, with a `Retry-After` header, while another rotation of the same secret is running. For Functions, rotation is dual-signed: deliveries carry signatures from both the previous and the new secret until the new one is promoted, and the previous secret is only retired then, so a Function still on the previous secret keeps validating while it rolls over. Even so, a Function delivery that started just before the rotation, or one that reaches a copy of the Function not updated with the new secret, can be rejected, and it may not be retried: find it in the [delivery log](/docs/endpoints#webhook-deliveries) and [replay it](/docs/endpoints#replay-a-delivery). A webhook's deliveries carry one signature, made with the new secret from the moment the new secret takes effect, which is just before the rotation answers. Be ready to update your webhook handler when you rotate, and switch it to the new secret as soon as you have it: a delivery the handler rejects during the switch may not be retried, so find it in the [delivery log](/docs/endpoints#webhook-deliveries) and [replay it](/docs/endpoints#replay-a-delivery). If the rotation's response is lost (a timeout, for example), read the secret with `GET /v1/account/webhook-secret`: if it changed, the rotation finished. If it is unchanged, the rotation may still be running. Rotating again then answers `409 rotation_in_progress` (or a `503`) while it runs, or `rate_limit_exceeded` if it has finished meanwhile; after any of these, read the secret again before you use it. A webhook that signs with its own secret is not affected by this rotation.

## How to obtain credentials

You do not start with a credential — you acquire one through one of these flows, then use it as a bearer token everywhere above.

- CLI device login. Run `primitive login` to authorize the CLI on your machine. It registers a client, opens the browser sign-in, and stores the resulting OAuth tokens locally, refreshing them automatically. See the [CLI reference](/docs/cli).

- Agent signup (headless). An autonomous agent can self-provision a managed inbox with no API key and only one email-verification code. The result is a full account tied to that email; if one already exists, the agent is signed in to it. See [Skills & Agent Signup](/docs/skills) for the step-by-step flow, and [Which onboarding fits?](/docs/which-onboarding) to choose a path.

- Third-party apps. Apps acting on behalf of a user use the [OAuth flow](#oauth-authorization-code--pkce) above with Dynamic Client Registration.

## See also

- [REST API](/docs/api) — response envelope, pagination, and endpoint inventory.

- [Errors](/docs/errors) — the `unauthorized` / `forbidden` codes and recovery guidance.

- [CLI](/docs/cli) — `primitive login` and agent signup.

- [SDKs](/docs/sdks) — language clients that handle the `Authorization` header for you.

- [Signature verification](/docs/signature-verification) — using the webhook signing secret to verify webhooks.

- [Quickstart](/docs/quickstart) — get a credential and make your first call.

Maintained by the [primitive.dev team](/about) · [Trust & security](/trust)Last updated: May 17, 2026
