SalesOS

OAuth 2.0

Let your app act on behalf of SalesOS users — authorization code with PKCE, refresh token rotation, client credentials, revocation and introspection.

Use OAuth when your software works with other people's SalesOS data: a marketplace app, a browser extension, a mobile app, or a service your customers connect to their SalesOS organization. For a script that only touches your own organization, an API key is simpler.

SalesOS implements OAuth 2.0 (RFC 6749) with PKCE (RFC 7636), token revocation (RFC 7009), introspection (RFC 7662), server metadata (RFC 8414) and OpenID Connect id_tokens and userinfo.

Issuerhttps://www.salesos.org
Metadatahttps://www.salesos.org/.well-known/oauth-authorization-server (also /.well-known/openid-configuration)
Authorizehttps://www.salesos.org/v1/oauth/authorize
Tokenhttps://www.salesos.org/v1/oauth/token
Revokehttps://www.salesos.org/v1/oauth/revoke
Introspecthttps://www.salesos.org/v1/oauth/introspect
Userinfohttps://www.salesos.org/v1/oauth/userinfo
JWKShttps://www.salesos.org/v1/oauth/jwks

Register an app

An organization owner or admin registers apps under Settings → OAuth apps (part of API Access, Enterprise):

  • Client type. Confidential for a server that can keep a secret. Public for single-page, mobile and desktop apps: no secret, and PKCE is required on every authorization.
  • Redirect URIs are matched exactly — scheme, host, port, path and query. They must be https; http is accepted only for localhost, 127.0.0.1 and [::1] while you develop. No wildcards, fragments or embedded credentials.
  • Scopes — the most the app may ever ask for. Each authorization asks for a subset.
  • Who can install it. By default only people in your own organization can authorize the app. Turn on Other organizations can install it to let any SalesOS organization connect it (subject to that organization's policy).
  • Rate limit — requests per hour, counted per organization that installed the app.

The client secret is shown once. SalesOS stores only a hash of it. Rotate it from the same screen; you can keep the old secret working for up to 168 hours while you deploy the new one.

Scopes

Resource scopes are the same as API key scopes: READ_<RESOURCE> allows GET, WRITE_<RESOURCE> allows every method and implies read. OAuth tokens never get * or the legacy read/write scopes, so an endpoint outside the scope catalogue is not available to OAuth apps.

ScopeWhat the user sees on the consent screen
READ_LEADS / WRITE_LEADSView (and create, edit, delete) your leads
READ_CONTACTS / WRITE_CONTACTS… your contacts
READ_ACCOUNTS / WRITE_ACCOUNTS… your accounts
READ_OPPORTUNITIES / WRITE_OPPORTUNITIES… your deals
READ_PRODUCTS / WRITE_PRODUCTS… your products
READ_QUOTES / WRITE_QUOTES… your quotes
READ_ORDERS / WRITE_ORDERS… your orders
READ_TASKS / WRITE_TASKS… your tasks
READ_ACTIVITIES / WRITE_ACTIVITIES… your activities
READ_CASES / WRITE_CASES… your cases
READ_CUSTOM_OBJECTS / WRITE_CUSTOM_OBJECTS… your custom object records
READ_CUSTOM_FIELDS / WRITE_CUSTOM_FIELDS… your custom field definitions
READ_WEBHOOKS / WRITE_WEBHOOKS… your webhook subscriptions
INVOKE_FUNCTIONSRun your organization's serverless functions
MANAGE_METADATARetrieve and deploy your organization's metadata
PUBLISH_EVENTSPublish your organization's custom events (implies READ_EVENTS)
READ_EVENTSSee your organization's event types and receive its custom events
openidConfirm who you are (returns an id_token)
profileSee your name
emailSee your email address
offline_accessKeep access when you are not using it (returns a refresh token)

GET /v1/oauth/scopes returns this list. GraphQL and the Bulk API check the scope of each entity they touch, as they do for API keys.

A token never has more access than the person who authorized it. It acts as that user, in the organization they authorized, with their role, record visibility (own / team / all), Profile, field-level security and plan entitlements. If they are deactivated or leave the organization, the token stops working on the next request.

Authorization code flow with PKCE

1. Send the user to the authorize endpoint

Generate a fresh code_verifier (43–128 characters of A-Z a-z 0-9 - . _ ~) and state for every attempt, and keep both in the user's session.

CODE_VERIFIER=$(openssl rand -base64 64 | tr -d '\n=+/' | cut -c1-64)
CODE_CHALLENGE=$(printf %s "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=')
https://www.salesos.org/v1/oauth/authorize
  ?response_type=code
  &client_id=sos_ci_3f2a…
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback
  &scope=READ_LEADS%20WRITE_CONTACTS%20offline_access%20openid%20email
  &state=af0ifjsldkj
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
  &nonce=n-0S6_WzA2Mj
  • redirect_uri is required and must exactly match a registered URI.
  • code_challenge_method must be S256; plain is rejected.
  • prompt=consent always shows the consent screen; prompt=none never does and fails with consent_required if the user hasn't approved these scopes before. prompt=login is treated like consent.

SalesOS signs the user in if needed, then shows a consent screen with your app's name, logo and publisher, the organization being connected and each scope in plain language. Once a user has approved a set of scopes, later requests for the same (or fewer) scopes skip the screen.

2. Handle the redirect

https://app.example.com/oauth/callback?code=sos_ac_…&state=af0ifjsldkj&iss=https%3A%2F%2Fwww.salesos.org

Check that state matches what you stored and that iss is https://www.salesos.org (RFC 9207). If the user declines, you get error=access_denied instead of code. The code is valid for 5 minutes and only once.

3. Exchange the code

# Confidential client (HTTP Basic; client_secret_post in the body also works)
curl -X POST https://www.salesos.org/v1/oauth/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d code="$CODE" \
  -d redirect_uri=https://app.example.com/oauth/callback \
  -d code_verifier="$CODE_VERIFIER"

# Public client: no secret, client_id in the body, PKCE mandatory
curl -X POST https://www.salesos.org/v1/oauth/token \
  -d grant_type=authorization_code -d client_id="$CLIENT_ID" \
  -d code="$CODE" -d redirect_uri=http://localhost:3000/callback \
  -d code_verifier="$CODE_VERIFIER"
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6ImF0K2p3dCJ9…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "READ_LEADS WRITE_CONTACTS offline_access openid email",
  "refresh_token": "sos_rt_…",
  "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6…"
}
  • The access token is a signed JWT (typ: at+jwt) valid for 1 hour. Treat it as opaque: use introspection to check it.
  • A refresh token is returned only when offline_access was granted.
  • An id_token (RS256, keys at the JWKS URL) is returned when openid was granted. It carries sub, aud (your client ID), nonce, org, and email / name for those scopes.

4. Call the API

curl https://api.salesos.org/v1/leads -H "Authorization: Bearer $ACCESS_TOKEN"

Responses carry RateLimit-* headers for your app's limit in that organization; past it you get 429 OAUTH_RATE_LIMITED with Retry-After. Writes accept Idempotency-Key and need no CSRF token.

Refresh tokens

curl -X POST https://www.salesos.org/v1/oauth/token -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=refresh_token -d refresh_token="$REFRESH_TOKEN"
  • Rotation: every refresh returns a new refresh token; the old one stops working. Store the new one before you use the new access token.
  • Reuse detection: presenting a refresh token that was already used revokes the whole token family — every refresh and access token that descends from the original authorization — and returns invalid_grant. The user has to authorize again. This is what protects a user whose refresh token was copied. Serialize refreshes in your app (one in flight per user) so two workers never race with the same token.
  • You may pass a narrower scope; you can never widen it.
  • Refresh tokens last 30 days from their last use and at most 90 days from the original authorization.

Client credentials (server-to-server)

For a confidential app registered by an organization to call that organization's data with no user present. Enable Server-to-server access on the app and choose an integration user; tokens act as that user, with that user's permissions, so create a dedicated user with a narrow Profile.

curl -X POST https://www.salesos.org/v1/oauth/token -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=client_credentials -d scope="READ_LEADS WRITE_LEADS"

No refresh token is issued; request a new access token when it expires. Identity scopes (openid, profile, email, offline_access) are not available here. Leaving out scope grants every resource scope the app allows.

Revocation

curl -X POST https://www.salesos.org/v1/oauth/revoke -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d token="$REFRESH_TOKEN" -d token_type_hint=refresh_token

Always 200. Revoking a refresh token revokes its whole family, including access tokens; revoking an access token revokes just that token. Tokens issued to another client are ignored. Users disconnect apps under Settings → Connected apps, and organization admins can revoke an app for one person or everyone.

Introspection

curl -X POST https://www.salesos.org/v1/oauth/introspect -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d token="$ACCESS_TOKEN"
{
  "active": true,
  "token_type": "Bearer",
  "scope": "READ_LEADS offline_access",
  "client_id": "sos_ci_3f2a…",
  "sub": "cm1x…",
  "username": "[email protected]",
  "organization_id": "cm0q…",
  "grant_type": "authorization_code",
  "exp": 1790000000,
  "iat": 1789996400,
  "aud": "salesos-api",
  "iss": "https://www.salesos.org",
  "jti": "5e7c…"
}

A client can only introspect its own tokens; anything else is {"active": false}.

Userinfo

curl https://www.salesos.org/v1/oauth/userinfo -H "Authorization: Bearer $ACCESS_TOKEN"

Needs openid. Returns sub, organization_id, plus name (profile) and email / email_verified (email).

Organization policy

Under Settings → Connected apps → Policy an organization's owners and admins can:

  • Let people connect apps (on by default). Off: only owners and admins can authorize apps.
  • Approved apps only (off by default). On: an app from another publisher must be approved by client ID first. Apps your own organization registered are always allowed.

A blocked user sees why on the consent screen; your app receives error=access_denied.

Errors

Token, revoke and introspect errors use the RFC 6749 shape:

{ "error": "invalid_grant", "error_description": "The authorization code is invalid or expired" }
errorStatusTypical cause
invalid_request400Missing parameter, wrong PKCE method, unregistered redirect_uri
invalid_client401Unknown client, wrong or missing secret, secret sent by a public client
invalid_grant400Code expired, reused or for another client; PKCE mismatch; redirect_uri differs; refresh token used, revoked or expired; access revoked
unauthorized_client400Client credentials not enabled, or the app is suspended
unsupported_grant_type400Not one of the three grants
invalid_scope400A scope the app isn't allowed, or wider than the original grant
access_deniedredirectThe user declined, or the organization's policy blocks the app
consent_requiredredirectprompt=none without prior consent

On API calls, a bad token is 401 with WWW-Authenticate: Bearer error="invalid_token" (OAUTH_TOKEN_INVALID, OAUTH_TOKEN_EXPIRED, OAUTH_TOKEN_REVOKED, OAUTH_USER_INACTIVE), and a missing scope is 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="WRITE_LEADS" (OAUTH_SCOPE_INSUFFICIENT). The routes API keys can never reach (/auth, /api-keys, /admin, /oauth-apps, /connected-apps, /apps, /developer-orgs, … — see authentication) are never reachable with an OAuth token either (OAUTH_SCOPE_BLOCKED).

On this page