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.
| Issuer | https://www.salesos.org |
| Metadata | https://www.salesos.org/.well-known/oauth-authorization-server (also /.well-known/openid-configuration) |
| Authorize | https://www.salesos.org/v1/oauth/authorize |
| Token | https://www.salesos.org/v1/oauth/token |
| Revoke | https://www.salesos.org/v1/oauth/revoke |
| Introspect | https://www.salesos.org/v1/oauth/introspect |
| Userinfo | https://www.salesos.org/v1/oauth/userinfo |
| JWKS | https://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;httpis accepted only forlocalhost,127.0.0.1and[::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.
| Scope | What the user sees on the consent screen |
|---|---|
READ_LEADS / WRITE_LEADS | View (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_FUNCTIONS | Run your organization's serverless functions |
MANAGE_METADATA | Retrieve and deploy your organization's metadata |
PUBLISH_EVENTS | Publish your organization's custom events (implies READ_EVENTS) |
READ_EVENTS | See your organization's event types and receive its custom events |
openid | Confirm who you are (returns an id_token) |
profile | See your name |
email | See your email address |
offline_access | Keep 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.
redirect_uriis required and must exactly match a registered URI.code_challenge_methodmust beS256;plainis rejected.prompt=consentalways shows the consent screen;prompt=nonenever does and fails withconsent_requiredif the user hasn't approved these scopes before.prompt=loginis treated likeconsent.
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
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
- 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_accesswas granted. - An id_token (RS256, keys at the JWKS URL) is returned when
openidwas granted. It carriessub,aud(your client ID),nonce,org, andemail/namefor those scopes.
4. Call the API
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
- 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.
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
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
A client can only introspect its own tokens; anything else is {"active": false}.
Userinfo
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 | Status | Typical cause |
|---|---|---|
invalid_request | 400 | Missing parameter, wrong PKCE method, unregistered redirect_uri |
invalid_client | 401 | Unknown client, wrong or missing secret, secret sent by a public client |
invalid_grant | 400 | Code expired, reused or for another client; PKCE mismatch; redirect_uri differs; refresh token used, revoked or expired; access revoked |
unauthorized_client | 400 | Client credentials not enabled, or the app is suspended |
unsupported_grant_type | 400 | Not one of the three grants |
invalid_scope | 400 | A scope the app isn't allowed, or wider than the original grant |
access_denied | redirect | The user declined, or the organization's policy blocks the app |
consent_required | redirect | prompt=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).