OAuth 2.0
Authenticate users with the OAuth 2.0 authorization code flow and PKCE, exchange and refresh tokens, and request scopes.
Use OAuth when your software should act as an individual Kustomer user rather than as a shared machine user. Actions appear under that person's name and are limited to what they're allowed to do. The kusty CLI uses this flow for kusty auth login.
Kustomer implements the authorization code grant with PKCE (S256) and refresh tokens.
code_verifier = base64url(random(32)) code_challenge = base64url(sha256(code_verifier)) state = base64url(random(16))
Endpoints
Both endpoints are on your customer's org, not on a global host:
Ask the user for their org name (the subdomain they sign in at) before you start the flow.
1. Set up an OAuth client
An OAuth client is an app installed in the org. Its app definition declares the allowed redirectUris:
{
"app": "acme_assistant",
"version": "1.0.0",
"title": "Acme Assistant",
"visibility": "private",
"redirectUris": ["https://assistant.acme.example/oauth/callback"]
}- Register the app definition. The response includes the app's
clientId. - Install it in the org. Authorization only succeeds once the app is installed there, not merely registered.
- Mint a client secret for confidential clients (servers that can keep a secret). The secret is returned once.
See Apps platform › OAuth for the registration and secret endpoints, and Register and install for the full app lifecycle.
2. Send the user to authorize
Generate a random code_verifier and state, store them for this login attempt, and redirect:
https://acme.kustomerapp.com/oauth/authorize
?client_id=YOUR_CLIENT_ID
&redirect_uri=https%3A%2F%2Fassistant.acme.example%2Foauth%2Fcallback
&response_type=code
&scope=org.permission.customer%20org.permission.conversation%20offline_access
&state=Xy9cPq...
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256client_idstringrequired- The app's client ID.
redirect_uristringrequired- Must exactly match one of the app's
redirectUris. response_typestringrequired- Always
code. scopestring- Space-separated roles to request. Add
offline_accessto receive a refresh token. statestringrequired- An unguessable value you verify on the callback, to prevent CSRF.
code_challengestringbase64url(sha256(code_verifier)). Required for public clients; recommended for all.code_challenge_methodstringS256.
The user signs in to their org and approves. Kustomer redirects back to your redirect_uri with code and state, or with error and error_description if they declined.
3. Exchange the code
curl -X POST "https://acme.api.kustomerapp.com/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=$CODE" \
--data-urlencode "redirect_uri=https://assistant.acme.example/oauth/callback" \
--data-urlencode "code_verifier=$CODE_VERIFIER" \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "client_secret=$CLIENT_SECRET"The body is form-encoded, not JSON. A successful response:
{
"access_token": "eyJhbGciOi...",
"refresh_token": "def50200...",
"token_type": "Bearer",
"expires_in": 86400,
"scope": "org.permission.customer org.permission.conversation offline_access"
}Errors use the standard OAuth shape: {"error": "invalid_grant", "error_description": "..."}.
4. Call the API
Send the access token exactly like an API key:
curl "https://acme.api.kustomerapp.com/v1/users/current" -H "Authorization: Bearer $ACCESS_TOKEN"Access tokens last about 24 hours. Use expires_in rather than hard-coding a lifetime.
5. Refresh
curl -X POST "https://acme.api.kustomerapp.com/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=refresh_token" \
--data-urlencode "refresh_token=$REFRESH_TOKEN" \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "client_secret=$CLIENT_SECRET"Complete example
A minimal Node.js login for a local tool, with no dependencies beyond the standard library:
import { createHash, randomBytes } from 'node:crypto'
import { createServer } from 'node:http'
const org = 'acme'
const clientId = process.env.KUSTOMER_CLIENT_ID!
const clientSecret = process.env.KUSTOMER_CLIENT_SECRET!
const redirectUri = 'http://localhost:53682/callback'
const b64url = (b: Buffer) => b.toString('base64url')
const verifier = b64url(randomBytes(32))
const challenge = b64url(createHash('sha256').update(verifier).digest())
const state = b64url(randomBytes(16))
const authorize = new URL(`https://${org}.kustomerapp.com/oauth/authorize`)
authorize.search = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'org.permission.customer org.permission.conversation offline_access',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
}).toString()
console.log('Open this URL to sign in:\n', authorize.href)
const code = await new Promise<string>((resolve, reject) => {
const server = createServer((req, res) => {
const url = new URL(req.url!, redirectUri)
if (url.searchParams.get('state') !== state) return reject(new Error('state mismatch'))
res.end('Signed in. You can close this tab.')
server.close()
resolve(url.searchParams.get('code')!)
}).listen(53682)
})
const tokens = await fetch(`https://${org}.api.kustomerapp.com/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
code_verifier: verifier,
client_id: clientId,
client_secret: clientSecret,
}),
}).then((r) => r.json())
const me = await fetch(`https://${org}.api.kustomerapp.com/v1/users/current`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
}).then((r) => r.json())
console.log('Signed in as', me.data.attributes.name)Scopes
Scopes are Kustomer roles, written without an action to cover every action on a resource: org.permission.customer, org.permission.conversation, org.permission.message, org.permission.user. Add offline_access to get a refresh token. A token never has more access than the user who approved it, whatever scopes you request.
See API keys and roles for how role names are built.