Authentication
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_... | 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.
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 the user consented to, and a Supabase JWT resolves to a broad workspace-member grant (per-route checks gate admin-only actions against business_members.role).
Credentials are tried in a fixed order: X-API-Key first, then Authorization: Bearer (Supabase JWT, falling through to OAuth access token). If both X-API-Key and Authorization are present on a Platform API request, X-API-Key wins and the bearer token is ignored. The /mcp endpoint applies the opposite order, trying the bearer token first. Send exactly one credential rather than relying on precedence.
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
Use the dashboard. In console.amdahl.co:
- Sign in and open the workspace the key should act in (keys are per-workspace).
- Go to Settings -> Developer.
- Click Create key and give it a name.
- Copy the plaintext key immediately. It is shown once and is never retrievable again.
The key is bound to your user and that one workspace. To act against another workspace, switch workspaces and create a separate key there.
The key-management endpoints live under /api/mcp/api-keys — note 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:
curl -s -X POST "https://app.amdahl.co/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",
"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, so there is no unattended key-bootstrapping flow. Create the first key in the dashboard.
- Scopes are not settable at creation. The endpoint accepts a name only; the key is issued with a default scope set. To get a key with specific scopes, use the dashboard or ask an admin.
You must be a member of the :businessId you pass, or the call returns 403.
Use an API key
Send the key in the X-API-Key header:
curl -H "X-API-Key: $AMDAHL_KEY" ...The Authorization: Bearer header is reserved for OAuth access tokens and Supabase JWTs, not API keys.
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.co/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 (dashboard or the endpoint above).
- 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.co/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 requirespages:writeand 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.co/oauth/register" \
-H "Content-Type: application/json" \
-d '{
"client_name": "My desktop integration",
"redirect_uris": ["https://myapp.example/oauth/callback"]
}'Response:
{
"client_id": "mcp_abc123...",
"client_secret": "...",
"redirect_uris": ["https://myapp.example/oauth/callback"]
}Redirect URIs must be HTTPS in production; http://localhost and http://127.0.0.1 are allowed for local development only.
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.co/oauth/authorize ?response_type=code &client_id=mcp_abc123... &redirect_uri=https://myapp.example/oauth/callback &code_challenge=<challenge> &code_challenge_method=S256 &scope=data:read%20pages:read%20pages:write &state=<random-anti-csrf> -
After consent, the user is redirected back with
?code=...&state=.... -
Exchange the code for an access token:
bashcurl -s -X POST "https://app.amdahl.co/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=mcp_abc123..." \ -d "code_verifier=<verifier>"
Response:
{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "...",
"scope": "data:read pages:read pages:write"
}Refreshing tokens
curl -s -X POST "https://app.amdahl.co/oauth/token" \
-d "grant_type=refresh_token" \
-d "refresh_token=<refresh_token>" \
-d "client_id=mcp_abc123..."Revoking tokens
Revocation is per-token (RFC 7009), not per-client:
curl -s -X POST "https://app.amdahl.co/oauth/revoke" \
-H "Content-Type: application/json" \
-d '{ "token": "<access_or_refresh_token>" }'Per RFC 7009 this always returns 200, including for a token that was already invalid — so a success response confirms the token is not usable, not that it existed.
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.
Scopes
Scopes are fine-grained resource:action strings enforced on every tool call. Examples: pages:write, context:read, webhooks:delete.
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 with details.missing_scope naming the scope that would unblock it.
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, and a slug 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. Re-read Quickstart step 2 and confirm the header shape.
My request returned 403 forbidden with missing_scope. The credential is valid but lacks the scope that tool requires. Create a key with the needed scope from the dashboard, or 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. Refresh tokens stay valid until the token is revoked via /oauth/revoke or the user loses access to the workspace.
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.