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
| Case | Use |
|---|---|
| An app acts on behalf of a signed-in user | OAuth |
| Server-to-server work with no human at consent time | API key |
| CI jobs and headless integrations | API key |
| Short-lived delegated access to one project | OAuth |
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:
GET https://api.sippulse.ai/.well-known/oauth-protected-resource(RFC 9728) points to the authorization server.- The authorization server metadata (RFC 8414) gives the authorize, token, and revoke endpoints.
- Pass the
resourceparameter (RFC 8707) with the valuehttps://api.sippulse.aifor 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.
{
"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
- Generate a
code_verifierand itscode_challenge(S256). Keep the verifier; send only the challenge. - Send the user to the
authorization_endpointfrom metadata withresponse_type=code, yourclient_id,redirect_uri,code_challenge,code_challenge_method=S256, astatevalue,resource=https://api.sippulse.ai, and the requestedscope. - The user signs in, picks one organization and one project, and may uncheck scopes. The token is bound to that project.
- The callback to your
redirect_uricarriescode,state, andiss. Verifystateandissbefore continuing. - Exchange the code at the
token_endpointfrom metadata:
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"- Call the API with the access token in the header only. Never put the token in the URL:
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
| Scope | Endpoints it unlocks |
|---|---|
spai:llm:execute | POST /v1/llms/completion, POST /v1/chat/completions, GET /v1/llms/models, POST /v1/openai/chat/completions |
spai:tts:execute | POST /v1/tts/generate, POST /v1/audio/speech, GET /v1/tts/models, GET /v1/tts/voices, POST /v1/openai/audio/speech |
spai:stt:execute | Speech-to-text endpoints, now also POST /v1/audio/transcriptions and POST /v1/openai/audio/transcriptions |
spai:embeddings:execute | POST /v1/openai/embeddings |
spai:decisions:execute | POST /v1/decisions, GET /v1/models |
spai:models:read | GET /v1/openai/models |
spai:analytics:read | GET /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.
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_accessscope. 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
POSTthe 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.
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.
