Skip to content

Authenticate with OAuth ​

OAuth lets an application call the REST API on behalf of a user who consents in the browser. The token is bound to one organization and one project, and the user can reduce the requested scopes at consent time. Usage is billed to the organization's credits exactly as with an API key.

For the default API key path, see Authentication.

When to use OAuth vs API key ​

CaseUse
An app acts on behalf of a signed-in userOAuth
Server-to-server work with no human at consent timeAPI key
CI jobs and headless integrationsAPI key
Short-lived delegated access to one projectOAuth

OAuth uses public clients only. There is no client secret and no client_credentials grant.

Standards ​

OAuth 2.1 authorization code with PKCE (S256 only). The device authorization flow is used by the SipPulse CLI only. Read the endpoint URLs from server metadata instead of hardcoding them:

  1. GET https://api.sippulse.ai/.well-known/oauth-protected-resource (RFC 9728) points to the authorization server.
  2. The authorization server metadata (RFC 8414) gives the authorize, token, and revoke endpoints.
  3. Pass the resource parameter (RFC 8707) with the value https://api.sippulse.ai for the REST API.

The inference scopes below are valid only for the REST API resource. MCP clients cannot request them.

Client registration with CIMD ​

Register the client with a Client ID Metadata Document (CIMD). The client_id is an HTTPS URL that serves JSON describing the client. The document must be at most 16 KiB, and no redirects are followed. Loopback redirect URIs may use any port. There is no dynamic client registration.

The document contains client_id (the same URL), client_name, redirect_uris, and token_endpoint_auth_method set to "none", plus optionally application_type, grant_types, and response_types.

json
{
  "client_id": "https://app.example.com/client.json",
  "client_name": "Example App",
  "redirect_uris": ["http://127.0.0.1:8090/callback"],
  "token_endpoint_auth_method": "none",
  "application_type": "native",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"]
}

Authorization code flow with PKCE ​

  1. Generate a code_verifier and its code_challenge (S256). Keep the verifier; send only the challenge.
  2. Send the user to the authorization_endpoint from metadata with response_type=code, your client_id, redirect_uri, code_challenge, code_challenge_method=S256, a state value, resource=https://api.sippulse.ai, and the requested scope.
  3. The user signs in, picks one organization and one project, and may uncheck scopes. The token is bound to that project.
  4. The callback to your redirect_uri carries code, state, and iss. Verify state and iss before continuing.
  5. Exchange the code at the token_endpoint from metadata:
bash
curl -X POST "$TOKEN_ENDPOINT" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=$CODE" \
  --data-urlencode "redirect_uri=http://127.0.0.1:8090/callback" \
  --data-urlencode "client_id=https://app.example.com/client.json" \
  --data-urlencode "code_verifier=$CODE_VERIFIER"
  1. Call the API with the access token in the header only. Never put the token in the URL:
bash
curl "https://api.sippulse.ai/v1/llms/models" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Request offline_access alongside the other scopes to receive a refresh token.

Scopes for model inference ​

ScopeEndpoints it unlocks
spai:llm:executePOST /v1/llms/completion, POST /v1/chat/completions, GET /v1/llms/models, POST /v1/openai/chat/completions
spai:tts:executePOST /v1/tts/generate, POST /v1/audio/speech, GET /v1/tts/models, GET /v1/tts/voices, POST /v1/openai/audio/speech
spai:stt:executeSpeech-to-text endpoints, now also POST /v1/audio/transcriptions and POST /v1/openai/audio/transcriptions
spai:embeddings:executePOST /v1/openai/embeddings
spai:decisions:executePOST /v1/decisions, GET /v1/models
spai:models:readGET /v1/openai/models
spai:analytics:readGET /v1/analytics/catalog, POST /v1/analytics/query

spai:analytics:read also works on the MCP resource, is included in the CLI default login and read-only login, and never bills. Sessions authorized before the scope existed must log in again to gain it. See Usage Analytics.

Other existing scopes cover agents (spai:agents:read|write|execute), secrets (spai:secrets:read|write), channels (spai:channels:read|write), and threads (spai:threads:read|write). Routes not listed for OAuth tokens answer 403.

Using an access token with the OpenAI SDK ​

Pass the access token as the API key or bearer credential. The token expires after 1 hour, so the app must refresh it before it expires.

python
from openai import OpenAI

client = OpenAI(
    api_key=access_token,  # OAuth access token, refreshed before the 1h expiry
    base_url="https://api.sippulse.ai/v1/openai",
)

Token lifetime and refresh ​

  • Access tokens last 1 hour.
  • Refresh tokens require the offline_access scope. They rotate on every use, with a 30-day sliding window and a 90-day absolute limit.
  • Reusing an old refresh token revokes the whole grant, and the app must send the user through consent again.

Revocation ​

  • POST the token to the revocation endpoint from server metadata, or
  • ask the user to revoke the app in Settings > OAuth access (see OAuth access).

CLI ​

spai auth login keeps its default scopes. Inference commands need explicit scopes: --scope <llm|tts|embeddings|decisions> is repeatable and cannot be combined with --read-only.

sh
spai auth login --scope llm --scope tts
spai llm complete "Summarize this call" --model MODEL
spai tts synthesize "Hello" --model MODEL --voice VOICE --output hello.mp3
spai embeddings create "Some text" --model MODEL
spai decisions ask '{"question": "..."}'

spai llm models lists LLM models; spai llm complete [prompt] is non-streaming, reads stdin when the prompt is omitted, and accepts --model, --system, --temperature, and --max-tokens. spai tts voices accepts --model; spai tts synthesize [text] requires --model, --voice, and --output FILE. spai embeddings create [text] takes --model. spai decisions ask [request] takes a JSON argument or stdin. A command whose scope was not granted fails before any request with the exact fix, for example This command needs spai:llm:execute. Run: spai auth login --scope llm; with an API key there is no local check. See CLI.

Limits ​

  • OAuth tokens work only on the routes their scopes unlock. Anything else answers 403.
  • Inference scopes are REST API only; MCP clients cannot request them.
  • Send the token only as the Authorization: Bearer <token> header, never in the URL.
  • Integrations without a human at consent time keep using API keys.