Docs

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.

CredentialHeaderBest forRefresh
API keyX-API-Key: amdhl_...Server-to-server, CLIs, agents, scheduled jobsManual rotation
OAuth 2.1 access tokenAuthorization: Bearer <access_token>Third-party apps acting on behalf of a userRefresh token flow
Supabase JWTAuthorization: Bearer <jwt> + X-Client-Slug: <slug>First-party web app onlyShort-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:

  1. Sign in and open the workspace the key should act in (keys are per-workspace).
  2. Go to Settings -> Developer.
  3. Click Create key and give it a name.
  4. 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:

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

json
{
  "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:

bash
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_keys record a short display prefix — amdhl_ plus the first 8 characters of the key, for example amdhl_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 carry is_test=true in the database but are otherwise indistinguishable by prefix.

List keys

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

  1. Create the replacement key (dashboard or the endpoint above).
  2. Deploy it to your integration and confirm traffic has moved — watch last_used_at on the new key.
  3. Revoke the old key.

Revoke a key

bash
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 requires pages:write and the key lacks it, the call returns 403 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 flag legacy_mode = true for 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

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

json
{
  "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

  1. Generate a code_verifier (43 to 128 chars, URL-safe) and derive code_challenge = BASE64URL(SHA256(verifier)).

  2. Send the user to:

    code
    https://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>
  3. After consent, the user is redirected back with ?code=...&state=....

  4. Exchange the code for an access token:

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

json
{
  "access_token": "...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "...",
  "scope": "data:read pages:read pages:write"
}

Refreshing tokens

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

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

  1. The scopes granted on the credential (API key or OAuth token), and
  2. 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.