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.

InteractiveOAuth 2.0 authorization code with PKCE
Your app
Browser
Kustomer web
Kustomer API
Step 1 of 8 · Your app → Your app · your server or CLI
code_verifier = base64url(random(32))
code_challenge = base64url(sha256(code_verifier))
state = base64url(random(16))
Authorize on your org's web host, exchange the code on its API host. Confidential clients also send client_secret on the token request.

Endpoints

Both endpoints are on your customer's org, not on a global host:

StepURL
Authorizehttps://<org>.kustomerapp.com/oauth/authorize
Tokenhttps://<org>.api.kustomerapp.com/oauth/token

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 definition (excerpt)
{
  "app": "acme_assistant",
  "version": "1.0.0",
  "title": "Acme Assistant",
  "visibility": "private",
  "redirectUris": ["https://assistant.acme.example/oauth/callback"]
}
  1. Register the app definition. The response includes the app's clientId.
  2. Install it in the org. Authorization only succeeds once the app is installed there, not merely registered.
  3. 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:

Text
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=S256
Authorize parameters
client_idstringrequired
redirect_uristringrequired
response_typestringrequired
scopestring
statestringrequired
code_challengestring
code_challenge_methodstring

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
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:

200 OK
{
  "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:

Shell
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
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:

Node.js
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.