Authentication
Create an API key with the CLI or the console, send it on every request, and pick the right credential - API key, OAuth token, or session JWT - for what you are building
Every request to the Amdahl Platform API must be authenticated. The platform supports three credential types. Pick the one that matches your integration shape.
| Credential | Header | Best for | Refresh |
|---|---|---|---|
| API key | X-API-Key: amdhl_... OR Authorization: Bearer amdhl_... | Server-to-server, CLIs, agents, scheduled jobs | Manual rotation |
| OAuth 2.1 access token | Authorization: Bearer <access_token> | Third-party apps acting on behalf of a user | Refresh token flow |
| Supabase JWT | Authorization: Bearer <jwt> + X-Client-Slug: <slug> | First-party web app only | Short-lived |
All three resolve to the same { user_id, business_id, scopes } payload before any tool runs, and every tool invocation writes one row to the platform audit log.
One key works on every transport. An API key is accepted as X-API-Key AND as Authorization: Bearer. A Bearer value that starts with amdhl_ is validated as an API key and never as an OAuth token (the prefix is reserved, so it cannot collide with one). On /api/platform/v1 the server first checks any Bearer value as a console session token, which costs one extra lookup before an amdhl_ key reaches the key validator; the result is the same. Use whichever header your client makes easiest; the resolved payload, scopes, and audit rows are identical.
Scope enforcement runs on that resolved payload whatever the credential, but the scopes it carries differ by path: an API key carries the scopes it was minted with, an OAuth token carries the scopes it was issued with (a refresh never adds one), and a Supabase JWT resolves to a broad workspace-member grant (per-route checks gate admin-only actions against business_members.role).
Send exactly one credential. If a request carries both headers, the Authorization: Bearer value is tried first on both /api/platform/v1 and /mcp: a valid API key or OAuth token in the Bearer header is what authenticates the request, and X-API-Key is read only when the Bearer value does not validate. (A Supabase session JWT in the Bearer header is the exception on /api/platform/v1: when X-API-Key is also present, the JWT is not considered and the key authenticates.) Do not rely on either order.
API keys
API keys are the recommended path for almost every integration. They are long-lived and scoped to one user plus one business.
Create an API key
The simplest way is the CLI:
npx @amdahl/cli login
npx @amdahl/cli keys create --name "My integration" --preset agentThe CLI makes the key on your machine and sends the server only its hash. You approve the request in the console by typing the code the terminal shows, and the terminal prints the key once. --preset picks the permission bundle below (agent is Customer agent, read-only is Read only), and --expires is 30d, 90d (default) or 365d.
Or use the console. In console.amdahl.ai, open the workspace the key should act in, go to Settings, then Developer, and click Create key. Give it a Label, pick its Access (default Customer agent) and when it Expires (30, 90 or 365 days, or No expiry; 90 days by default), then click Create key. Copy the key now: it is shown once.
Either way, an expired key gets 401, like a revoked one. The key is bound to your user and one workspace. To act in another workspace, create a separate key there.
Permission bundles
A key's permissions are a named bundle, not a free-form scope list. The console calls it Access. The two any member can pick:
| Bundle | Covers | Does not cover |
|---|---|---|
| Customer agent (default) | Read only's search and reads (except team and artifact reads), plus firing and authoring evals (evals:execute, evals:write), starting and answering Chats (conversations:write, workflows:write), market search (external_search:execute), rewriting outbound drafts (messages:execute), and agent, routine and subscription writes | connections:write / connections:delete (those ride OAuth sessions only) |
| Read only | Search (data:read), reading Chats, evals, agents, routines, subscriptions and connections | Firing an eval, starting a Chat, rewriting a draft, market search, any write |
Pick Customer agent for anything that grades a draft, rewrites one, or starts a Chat: a Read only key gets 403 forbidden on POST /evals/run, POST /messages/optimize and POST /chat. A Chat started without external_search:execute is confined to your own corpus and never reaches the market. Internal agent and Full admin appear only for workspace admins. The scopes each operation needs are in the tool catalog.
Key-management endpoints
To list or revoke keys from a terminal, use amdahl keys list and amdahl keys revoke <prefix> (revoking also asks for approval in the console). The key-management endpoints live under /api/mcp/api-keys. This is a different base path from the Platform API (/api/platform/v1), so $AMDAHL_BASE does not apply. They authenticate with a Supabase session JWT, not an API key:
Deprecated:
POST /api/mcp/api-keys/{business_id}. It still works, but it takes a name only, so it cannot set Access or Expires, and every key it creates never expires. Create keys with the CLI or the console instead, where you choose both. The endpoint will be removed in a later release, announced in the changelog first.
curl -s -X POST "https://app.amdahl.ai/api/mcp/api-keys/$BUSINESS_ID" \
-H "Authorization: Bearer $SUPABASE_JWT" \
-H "Content-Type: application/json" \
-d '{ "name": "My integration" }'Response 201 (plaintext key is returned exactly once):
{
"id": "f1e2...",
"key": "amdhl_abc123...",
"name": "My integration",
"bundle_name": "mcp_customer_agent",
"scopes": ["context:read", "data:read", "..."],
"message": "Store this key securely. It will not be shown again."
}Two consequences worth planning around:
- A key cannot mint another key. These endpoints only accept a browser-session JWT, and the CLI's
amdahl keyscommands need a browser sign-in and a console approval. There is no unattended key-bootstrapping flow. - The permission bundle and expiry are not settable at creation. The endpoint accepts a name only, and the key never expires. A workspace admin's key gets the Customer agent bundle (
mcp_customer_agent); anyone else's gets Read only (mcp_read_only). The response names the bundle and lists its scopes. To choose a bundle, use the CLI or the console.
You must be a member of the :businessId you pass, or the call returns 403.
Use an API key
Send the key in either header — both authenticate identically:
curl -H "X-API-Key: $AMDAHL_KEY" ...
# or, equivalently:
curl -H "Authorization: Bearer $AMDAHL_KEY" ...The server tells them apart by shape: every API key starts with the reserved amdhl_ prefix, so a Bearer value carrying it is validated as an API key; any other Bearer value is treated as an OAuth access token or Supabase JWT.
Check which credential you are using
GET /api/platform/v1/me/context describes the credential that calls it: who it acts as, in which workspace, with what role and scopes. It takes no parameters, so it can only ever describe the credential you already hold. It works for API keys and OAuth tokens.
curl -s "https://app.amdahl.ai/api/platform/v1/me/context" -H "X-API-Key: $AMDAHL_KEY"{
"user_id": "5d2e…",
"business_id": "0b6f…",
"scopes": ["data:read", "messages:execute", "..."],
"auth_method": "api_key",
"email": "[email protected]",
"workspace": { "id": "0b6f…", "name": "Acme", "slug": "acme" },
"role": "editor",
"client_id": null
}| Field | Notes |
|---|---|
user_id, email | The person the credential acts as. |
business_id, workspace | The one workspace it is bound to. The workspace id equals business_id. |
role | That person's role in the workspace: owner, admin, editor or viewer. |
scopes | The scopes the credential carries. |
auth_method | api_key or oauth. |
client_id | For an OAuth token, the OAuth client it was issued to. null for an API key. |
This response is not wrapped in a data envelope. workspace is null only when the workspace record cannot be read; the other fields are still filled in. A browser session gets 401, because a session names a user but no workspace, and so does a request with no credential. To also learn whether the credential can optimize drafts and which connections need attention, read GET /setup/status.
Storage and security
- Plaintext keys are returned on creation only. They are never retrievable again.
- The server stores only a SHA-256 hash. Rows in
mcp_api_keysrecord a short display prefix —amdhl_plus the first 8 characters of the key, for exampleamdhl_abc123de. - Keys are per-user plus per-business. Switching business context requires a different key.
- Every key has the form
amdhl_followed by 48 hex characters. There is no separate live/test prefix: test keys carryis_test=truein the database but are otherwise indistinguishable by prefix.
List keys
curl -s "https://app.amdahl.ai/api/mcp/api-keys/$BUSINESS_ID" \
-H "Authorization: Bearer $SUPABASE_JWT"Returns { "keys": [...] } — metadata only (id, name, display prefix, scopes, last_used_at). Plaintext key values are never returned.
Rotate a key
There is no rotation endpoint. Roll a key manually, in this order, so you are never without a working credential:
- Create the replacement key (
amdahl keys createor the console). - Deploy it to your integration and confirm traffic has moved — watch
last_used_aton the new key. - Revoke the old key.
Revoke a key
curl -s -X DELETE "https://app.amdahl.ai/api/mcp/api-keys/$BUSINESS_ID/$KEY_ID" \
-H "Authorization: Bearer $SUPABASE_JWT"Revocation is immediate. An unknown or already-revoked key id returns 404. Because this endpoint authenticates with a session JWT rather than the key itself, revoking the key your integration is currently using is possible — follow the rotation order above to avoid an outage.
scope_mode: legacy vs strict
Every API key carries a scope_mode field that governs how the unified tools layer interprets its scopes:
strict(the default for every new key): the key's exact scopes are used. If a tool requiresevals:executeand the key lacks it, the call returns403 forbidden.legacy(back-fill only for keys issued before the unified tools layer): role defaults are applied as a grandfather rule, and every request emits an audit flaglegacy_mode = truefor tracking.
You will not set scope_mode yourself unless you are migrating historical data.
OAuth 2.1
Use OAuth when your app acts on behalf of an end user and you cannot securely store a long-lived key (for example a desktop app or a third-party integration that signs users in).
The platform implements RFC 7591 dynamic client registration and authorization code with PKCE (S256 only).
Dynamic client registration
curl -s -X POST "https://app.amdahl.ai/oauth/register" \
-H "Content-Type: application/json" \
-d '{
"client_name": "My desktop integration",
"redirect_uris": ["https://myapp.example/oauth/callback"]
}'Response 201:
{
"client_id": "dyn-6f1c2a9e-3b7d-4e0a-9c51-2d8f4b6a1e07",
"client_name": "My desktop integration",
"redirect_uris": ["https://myapp.example/oauth/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}This is a public client: there is no client_secret, and token_endpoint_auth_method is none. PKCE is what protects the code exchange. Registration is limited to 5 requests per minute per IP.
Redirect URIs must be HTTPS in production; http://localhost and http://127.0.0.1 are allowed for local development only.
Clients that discover the server instead of hard-coding it can read /.well-known/oauth-authorization-server (RFC 8414) and /.well-known/oauth-protected-resource (RFC 9728) on the API host. A 401 from the API also carries a WWW-Authenticate: Bearer resource_metadata="…" header pointing at the second one.
Sign-in and workspace access
The OAuth sign-in page sends a one-time code to any well-formed email address. There is no password. Whether that person can use Amdahl is decided after sign-in, when the authorization request is checked:
- A member of at least one workspace continues to consent (or straight to your redirect, for a first-party client) and the token is bound to one workspace.
- A person with no workspace sees a
no_businesspage instead of a code. If they may create a workspace, it reads Create a workspace first; otherwise it reads Join the beta. Both link to console.amdahl.ai/new. No code reaches your redirect URI, so your app should let the user start the flow again once they have a workspace.
A deleted workspace never counts as a membership.
Authorization code + PKCE
-
Generate a
code_verifier(43 to 128 chars, URL-safe) and derivecode_challenge = BASE64URL(SHA256(verifier)). -
Send the user to:
codehttps://app.amdahl.ai/oauth/authorize ?response_type=code &client_id=dyn-6f1c2a9e-... &redirect_uri=https://myapp.example/oauth/callback &code_challenge=<challenge> &code_challenge_method=S256 &scope=data:read%20evals:read%20evals:execute &state=<random-anti-csrf>Add
&workspace=<slug>to suggest a workspace. If the user is a member of that workspace, it is preselected on the consent page. A slug the user cannot use is ignored, the same way whether it does not exist or belongs to someone else. -
After consent, the user is redirected back with
?code=...&state=.... -
Exchange the code for an access token:
bashcurl -s -X POST "https://app.amdahl.ai/oauth/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code" \ -d "code=<code>" \ -d "redirect_uri=https://myapp.example/oauth/callback" \ -d "client_id=dyn-6f1c2a9e-..." \ -d "code_verifier=<verifier>"
Response:
{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "...",
"scope": "data:read evals:read evals:execute"
}Refreshing tokens
curl -s -X POST "https://app.amdahl.ai/oauth/token" \
-d "grant_type=refresh_token" \
-d "refresh_token=<refresh_token>" \
-d "client_id=dyn-6f1c2a9e-..."A refresh never adds scopes. The new token keeps the scopes of the token it replaces, minus any scope Amdahl has since retired. If none of those scopes is still offered, the refresh fails with invalid_grant. To gain a scope, send the user through authorization again with that scope in the request.
Revoking tokens
Revocation is per-token (RFC 7009), not per-client:
curl -s -X POST "https://app.amdahl.ai/oauth/revoke" \
-H "Content-Type: application/json" \
-d '{ "token": "<access_or_refresh_token>" }'A form-encoded body (-H "Content-Type: application/x-www-form-urlencoded" -d "token=<access_or_refresh_token>") works the same, as RFC 7009 describes. The token endpoint accepts both forms too.
Given a token, this returns 200 per RFC 7009, including for a token that was already invalid, so a success response confirms the token is not usable, not that it existed. A request with no token returns 400 invalid_request.
There is no endpoint that revokes a client and all of its tokens at once. Note also that access is re-checked on every request: a token whose user has been removed from the workspace stops working immediately, without waiting for expiry or an explicit revocation. A refresh applies the same check before issuing a new pair: refreshing on behalf of a user who has been removed from the workspace fails with invalid_grant, and that failure revokes every other OAuth token the user holds for that workspace, so a later re-invite starts a clean session rather than resuming the old one.
Scopes
Scopes are fine-grained resource:action strings enforced on every tool call. Examples: data:read, evals:execute, routines:write.
Effective scopes on a request are the intersection of:
- The scopes granted on the credential (API key or OAuth token), and
- The default scopes for the user's role in the business (
viewer,editor,admin,owner).
A request that asks for an action outside its effective scope returns 403 forbidden. The scope is named in the message, not in details:
{
"error": {
"code": "forbidden",
"message": "Missing required scope(s): evals:execute"
}
}A refusal for role rather than scope has the same forbidden code with a different message. Neither carries details, so read message for the human explanation and the tool catalog for each operation's required scopes.
Each tool's required scope is listed in the tool catalog.
JWT (web app only)
The first-party Amdahl web app signs in with Supabase Auth and calls the Platform API with the Supabase access token (Authorization: Bearer <jwt>).
Unlike the other two credentials, a JWT does not name a workspace on its own, so this path also requires an X-Client-Slug: <workspace-slug> header. The JWT establishes who you are; the slug selects which workspace the request is scoped to, and membership is checked against business_members. A JWT request without the slug header is rejected with 400 Missing X-Client-Slug header. A slug that matches no workspace returns 404, and a workspace you are not a member of returns 403.
This path is not intended for external integrations: tokens are short-lived (one hour) and bound to browser session state. External integrations should use an API key or OAuth.
FAQ
My request returned 401 unauthenticated. Your header is missing, malformed, or pointing at a revoked credential. Check the header shape against Quickstart step 1, or run amdahl whoami to see which credential you are using.
My request returned 403 forbidden with "Missing required scope(s)" in the message. The credential is valid but lacks the scope that tool requires. For an API key, create a new key with the needed bundle (amdahl keys create --preset agent, or the console). For an OAuth token, send the user through authorization again and request the scope; a refresh never adds one. If the credential already carries the scope, grant the user a higher role. Cross-reference the tool catalog for each tool's required scope.
My OAuth access token expired. Use the refresh token flow above. Each refresh returns a new refresh token; store it, because the old one stops working about a minute after use. A refresh token expires 30 days after it is issued. A refresh fails with invalid_grant when the refresh token has expired or been revoked via /oauth/revoke, when none of the scopes the user approved is still offered, or when the user has lost access to the workspace; send the user through authorization again. A server error (5xx) during a refresh is temporary: keep the tokens and retry. Tokens stop working as soon as the user loses access to the workspace, whether they are already-issued access tokens or a refresh attempt.
I lost the plaintext value of my API key. Keys cannot be recovered. Create a replacement, move your integration onto it, then revoke the old one.
Can I share one API key across teammates? No. Keys are per-user plus per-business. Mint one per integration or per operator so audit rows attribute actions correctly.
Can I call the API without a business context? No. Every credential resolves to exactly one business_id. To operate against multiple businesses, mint a key per business.