Skip to main content

Overview

API Mode gives you complete control over the authentication experience. Instead of redirecting users to a hosted UI, your application presents a custom login interface where users authenticate and authorize your application to act on their behalf. Your application then generates the authorization code through API calls. This is ideal for mobile apps, custom branded experiences, or headless systems.
More complexity, more control: API mode requires 5 steps and additional security considerations. Only use this if you need full control over the user experience.
User Authorization Model: Even with custom UI, the user remains in control of the authentication process and grants your application permission to access their account.

When to Use API Mode

Ideal For

  • Native mobile applications
  • Custom branded login experiences
  • Headless/API-only architectures
  • Embedded authentication flows
  • White-label solutions

Not Ideal For

  • Quick integrations
  • Standard web applications
  • Third-party integrations
  • Limited development resources

Prerequisites

Before starting, ensure you have:
  • ✅ API keys (x-client-key and x-secret-key)
  • ✅ Secure credential storage mechanism
  • ✅ Understanding of PKCE implementation
  • ✅ Ability to handle user credentials securely
  • ✅ Implementation of proper error handling

Flow Diagram

Implementation Guide

Step 1: Initiate OAuth with API Mode

Start the OAuth flow with mode=api parameter to indicate you’ll handle authentication directly.
import crypto from 'crypto';

interface OAuthSession {
  jwtToken: string;
  codeVerifier: string;
  state: string;
}

function generateCodeVerifier(): string {
  return crypto.randomBytes(32).toString('base64url');
}

function generateCodeChallenge(verifier: string): string {
  return crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');
}

async function initiateOAuthAPIMode(): Promise<OAuthSession> {
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = generateCodeChallenge(codeVerifier);
  const state = crypto.randomBytes(16).toString('hex');

  const params = new URLSearchParams({
    mode: 'api',  // Key difference from hosted UI
    response_type: 'code',
    client_id: 'your-client-id',
    redirect_uri: 'https://yourapp.com/oauth/callback',
    state: state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256'
  });

  const response = await fetch(
    `https://api.example.com/v1/auth/oauth/authorize/initiate?${params}`,
    {
      headers: {
        'x-client-key': process.env.CLIENT_KEY!,
        'x-secret-key': process.env.SECRET_KEY!
      }
    }
  );

  if (!response.ok) {
    const error = await response.json();
    throw new Error(`OAuth initiation failed: ${error.error_description}`);
  }

  const data = await response.json();

  // Store session data for later steps
  return {
    jwtToken: data.token,  // JWT for authorization flow
    codeVerifier,
    state
  };
}
Request Parameters:
ParameterRequiredDescription
modeYesMust be api (this enables API mode)
response_typeYesMust be code
client_idYesYour application’s client identifier
redirect_uriYesCallback URL (required but not used in API mode)
stateYesRandom string for CSRF protection
code_challengeYesBASE64URL(SHA256(code_verifier))
code_challenge_methodYesMust be S256
Response:
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "expires_in": 600
}
Important: The token returned here is a JWT for the OAuth flow session (valid 10 minutes). It’s NOT an access token for API calls.

Step 2: Authenticate User

Present your custom login UI to the user and authenticate them through the API.
interface LoginCredentials {
  email: string;
  password: string;
  otpCode?: string;  // If OTP required
}

interface LoginResponse {
  accessToken: string;
  user: {
    id: string;
    email: string;
    name: string;
  };
}

async function loginUser(credentials: LoginCredentials): Promise<string> {
  const response = await fetch('https://api.example.com/v1/auth/login', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-client-key': process.env.CLIENT_KEY!
    },
    body: JSON.stringify({
      email: credentials.email,
      password: credentials.password,
      ...(credentials.otpCode && { otpCode: credentials.otpCode })
    })
  });

  if (!response.ok) {
    const error = await response.json();

    // Handle OTP requirement
    if (error.requiresOtp) {
      throw new Error('OTP_REQUIRED');
    }

    throw new Error(`Login failed: ${error.message}`);
  }

  const data: LoginResponse = await response.json();

  // Return access token for Step 3
  return data.accessToken;
}

// Example: Handle OTP flow
async function sendOTP(email: string): Promise<void> {
  await fetch('https://api.example.com/v1/auth/login/otp', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-client-key': process.env.CLIENT_KEY!
    },
    body: JSON.stringify({ email })
  });
}
Request Body:
FieldRequiredDescription
emailYesUser’s email address
passwordYesUser’s password
otpCodeConditionalRequired if OTP is enabled for user
Response:
{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "user": {
    "id": "user_123",
    "email": "[email protected]",
    "name": "John Doe"
  }
}
Security: Never log or expose user passwords. Always transmit over HTTPS in production.

Step 3: Generate Authorization Code

Use both tokens from Steps 1 and 2 to generate the authorization code.
async function generateAuthCode(
  session: OAuthSession,
  accessToken: string
): Promise<{ code: string; state: string }> {
  const response = await fetch('https://api.example.com/v1/auth/oauth/authorize', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${accessToken}`,  // Access token from Step 2
      'x-client-key': process.env.CLIENT_KEY!
    },
    body: JSON.stringify({
      token: session.jwtToken  // JWT token from Step 1
    })
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(`Authorization failed: ${error.error_description}`);
  }

  const data = await response.json();

  return {
    code: data.code,
    state: data.state
  };
}
Request Requirements:
RequirementLocationDescription
Access TokenAuthorization headerBearer token from Step 2 login
JWT TokenRequest bodySession token from Step 1 initiation
Response:
{
  "code": "auth_code_xyz123",
  "state": "random_csrf_protection_string_12345",
  "url": "https://yourapp.com/oauth/callback?code=auth_code_xyz123&state=random_csrf_protection_string_12345"
}
Token Confusion Prevention:
  • JWT Token (Step 1): Used ONLY in this request body
  • Access Token (Step 2): Used ONLY in the Authorization header here
  • Neither of these are the final tokens you’ll use for API calls

Step 4: Exchange Code for Long-Lived Tokens

Exchange the authorization code for access and refresh tokens that you’ll use for actual API calls.
interface TokenResponse {
  access_token: string;
  refresh_token: string;
  expires_in: number;
  refresh_token_expires_in: number;
  token_type: string;
}

async function exchangeCodeForTokens(
  code: string,
  session: OAuthSession
): Promise<TokenResponse> {
  // Verify state parameter
  if (session.state !== /* state from Step 3 response */) {
    throw new Error('State parameter mismatch');
  }

  const response = await fetch('https://api.example.com/v1/auth/oauth/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-client-key': process.env.CLIENT_KEY!,
      'x-secret-key': process.env.SECRET_KEY!
    },
    body: JSON.stringify({
      grant_type: 'authorization_code',
      code: code,
      redirect_uri: 'https://yourapp.com/oauth/callback',
      code_verifier: session.codeVerifier  // From Step 1
    })
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(`Token exchange failed: ${error.error_description}`);
  }

  const tokens: TokenResponse = await response.json();

  // Store tokens securely
  await secureStorage.set('access_token', tokens.access_token);
  await secureStorage.set('refresh_token', tokens.refresh_token);
  await secureStorage.set('token_expiry', Date.now() + (tokens.expires_in * 1000));

  return tokens;
}
Request Body:
FieldRequiredDescription
grant_typeYesMust be authorization_code
codeYesAuthorization code from Step 3
redirect_uriYesMust exactly match Step 1
code_verifierYesOriginal PKCE verifier from Step 1
Response:
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 21600,
  "refresh_token": "def50200a1b2c3d4e5f6...",
  "refresh_token_expires_in": 604800,
  "scope": "read write"
}
These are your final tokens:
  • Access token: Use this for all API calls (6 hour expiry)
  • Refresh token: Use this to get new access tokens (7 day expiry)

Step 5: Use Access Token for API Calls

Now use the access token from Step 4 to make API requests on behalf of the user for all authenticated operations.
User Authorization Boundaries: The access token enables your application to act on behalf of the user. Ensure all API calls align with the user’s expectations and the permissions they granted.
async function callProtectedAPI(endpoint: string) {
  const accessToken = await secureStorage.get('access_token');
  const tokenExpiry = await secureStorage.get('token_expiry');

  // Check if token expired
  if (Date.now() >= tokenExpiry) {
    // Refresh token before making the call
    accessToken = await refreshAccessToken();
  }

  const response = await fetch(`https://api.example.com${endpoint}`, {
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'x-client-key': process.env.CLIENT_KEY!
    }
  });

  if (response.status === 401) {
    // Token might be invalid, try refreshing
    const newToken = await refreshAccessToken();

    // Retry with new token
    return fetch(`https://api.example.com${endpoint}`, {
      headers: {
        'Authorization': `Bearer ${newToken}`,
        'x-client-key': process.env.CLIENT_KEY!
      }
    });
  }

  return response.json();
}

// Example usage
const userData = await callProtectedAPI('/v1/users/me');
const walletData = await callProtectedAPI('/v1/wallets');

Token Management

Refresh Access Token

When the access token expires (6 hours), use the refresh token to obtain a new one:
async function refreshAccessToken(): Promise<string> {
  const refreshToken = await secureStorage.get('refresh_token');

  const response = await fetch('https://api.example.com/v1/auth/oauth/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-client-key': process.env.CLIENT_KEY!,
      'x-secret-key': process.env.SECRET_KEY!
    },
    body: JSON.stringify({
      grant_type: 'refresh_token',
      refresh_token: refreshToken
    })
  });

  if (!response.ok) {
    // Refresh token expired or invalid, need to re-authenticate
    throw new Error('REFRESH_FAILED');
  }

  const tokens = await response.json();

  // Update stored tokens
  await secureStorage.set('access_token', tokens.access_token);
  await secureStorage.set('refresh_token', tokens.refresh_token);
  await secureStorage.set('token_expiry', Date.now() + (tokens.expires_in * 1000));

  return tokens.access_token;
}
Learn more in the Token Management Guide.

Complete Implementation Example

Here’s a complete mobile app implementation using React Native:
// services/oauth.service.ts
import * as SecureStore from 'expo-secure-store';
import * as Crypto from 'expo-crypto';

class OAuthService {
  private session: OAuthSession | null = null;

  async startOAuthFlow() {
    // Step 1: Initiate
    this.session = await this.initiateOAuthAPIMode();
    return this.session;
  }

  async authenticateUser(email: string, password: string) {
    if (!this.session) throw new Error('OAuth not initiated');

    // Step 2: Login
    const accessToken = await this.loginUser({ email, password });

    // Step 3: Generate code
    const { code, state } = await this.generateAuthCode(accessToken);

    // Step 4: Exchange for tokens
    const tokens = await this.exchangeCodeForTokens(code, state);

    // Clean up session
    this.session = null;

    return tokens;
  }

  private async initiateOAuthAPIMode(): Promise<OAuthSession> {
    const codeVerifier = await Crypto.digestStringAsync(
      Crypto.CryptoDigestAlgorithm.SHA256,
      Math.random().toString()
    );
    const codeChallenge = await Crypto.digestStringAsync(
      Crypto.CryptoDigestAlgorithm.SHA256,
      codeVerifier
    );
    const state = await Crypto.digestStringAsync(
      Crypto.CryptoDigestAlgorithm.SHA256,
      Math.random().toString()
    );

    const params = new URLSearchParams({
      mode: 'api',
      response_type: 'code',
      client_id: process.env.OAUTH_CLIENT_ID!,
      redirect_uri: 'myapp://oauth/callback',
      state,
      code_challenge: codeChallenge,
      code_challenge_method: 'S256'
    });

    const response = await fetch(
      `${process.env.API_URL}/v1/auth/oauth/authorize/initiate?${params}`,
      {
        headers: {
          'x-client-key': process.env.CLIENT_KEY!,
          'x-secret-key': process.env.SECRET_KEY!
        }
      }
    );

    const data = await response.json();

    return {
      jwtToken: data.token,
      codeVerifier,
      state
    };
  }

  private async loginUser(credentials: LoginCredentials): Promise<string> {
    const response = await fetch(`${process.env.API_URL}/v1/auth/login`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-client-key': process.env.CLIENT_KEY!
      },
      body: JSON.stringify(credentials)
    });

    const data = await response.json();
    return data.accessToken;
  }

  private async generateAuthCode(accessToken: string) {
    const response = await fetch(`${process.env.API_URL}/v1/auth/oauth/authorize`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${accessToken}`,
        'x-client-key': process.env.CLIENT_KEY!
      },
      body: JSON.stringify({
        token: this.session!.jwtToken
      })
    });

    const data = await response.json();
    return { code: data.code, state: data.state };
  }

  private async exchangeCodeForTokens(code: string, state: string) {
    if (state !== this.session!.state) {
      throw new Error('State mismatch');
    }

    const response = await fetch(`${process.env.API_URL}/v1/auth/oauth/token`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-client-key': process.env.CLIENT_KEY!,
        'x-secret-key': process.env.SECRET_KEY!
      },
      body: JSON.stringify({
        grant_type: 'authorization_code',
        code,
        redirect_uri: 'myapp://oauth/callback',
        code_verifier: this.session!.codeVerifier
      })
    });

    const tokens = await response.json();

    // Store securely
    await SecureStore.setItemAsync('access_token', tokens.access_token);
    await SecureStore.setItemAsync('refresh_token', tokens.refresh_token);
    await SecureStore.setItemAsync('token_expiry',
      String(Date.now() + tokens.expires_in * 1000)
    );

    return tokens;
  }

  async callAPI(endpoint: string) {
    let accessToken = await SecureStore.getItemAsync('access_token');
    const expiry = await SecureStore.getItemAsync('token_expiry');

    if (Date.now() >= Number(expiry)) {
      accessToken = await this.refreshAccessToken();
    }

    return fetch(`${process.env.API_URL}${endpoint}`, {
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'x-client-key': process.env.CLIENT_KEY!
      }
    });
  }

  private async refreshAccessToken(): Promise<string> {
    const refreshToken = await SecureStore.getItemAsync('refresh_token');

    const response = await fetch(`${process.env.API_URL}/v1/auth/oauth/token`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-client-key': process.env.CLIENT_KEY!,
        'x-secret-key': process.env.SECRET_KEY!
      },
      body: JSON.stringify({
        grant_type: 'refresh_token',
        refresh_token: refreshToken
      })
    });

    const tokens = await response.json();

    await SecureStore.setItemAsync('access_token', tokens.access_token);
    await SecureStore.setItemAsync('refresh_token', tokens.refresh_token);
    await SecureStore.setItemAsync('token_expiry',
      String(Date.now() + tokens.expires_in * 1000)
    );

    return tokens.access_token;
  }
}

export default new OAuthService();

Error Handling

Cause: Too much time between Step 1 initiation and Step 3 authorization.Solution:
  • Restart from Step 1
  • Implement session expiry monitoring
  • Show countdown timer to users
if (Date.now() - sessionStartTime > 600000) {
  throw new Error('SESSION_EXPIRED');
}
Cause: Wrong email/password in Step 2.Solution:
  • Display clear error message
  • Allow retry with rate limiting
  • Implement “forgot password” flow
if (error.code === 'INVALID_CREDENTIALS') {
  setError('Invalid email or password. Please try again.');
}
Cause: Using wrong token in wrong step.Solution:
  • JWT Token: Only in Step 3 request body
  • Access Token (Step 2): Only in Step 3 Authorization header
  • Access Token (Step 4): For all subsequent API calls
// Step 3: CORRECT
headers: {
  'Authorization': `Bearer ${accessTokenFromStep2}`,
},
body: {
  token: jwtTokenFromStep1
}
Cause: User has 2FA enabled.Solution:
  • Check requiresOtp in login response
  • Call POST /v1/auth/login/otp to send code
  • Prompt user for OTP
  • Retry login with otpCode field
if (error.requiresOtp) {
  await sendOTP(email);
  const otpCode = await promptUserForOTP();
  await loginUser({ email, password, otpCode });
}

Security Considerations

Credential Security

  • Never log passwords
  • Clear password fields after use
  • Use HTTPS only
  • Implement rate limiting

Token Storage

  • Use platform-specific secure storage
  • Never store in AsyncStorage (RN)
  • Never store in localStorage (web)
  • Encrypt if possible

PKCE Validation

  • Store code_verifier securely
  • Don’t reuse code_verifier
  • Validate state parameter
  • Clear after exchange

Error Handling

  • Don’t expose internal errors
  • Log security events
  • Implement retry limits
  • Clear sensitive data on error
Learn more in the Security Guide.

Testing Checklist

Before production:
  • All 5 steps complete successfully
  • PKCE code_verifier validation works
  • State parameter prevents CSRF
  • Session timeout (10 min) is handled
  • Invalid credentials show appropriate error
  • OTP flow works if enabled
  • Token refresh works automatically
  • Expired refresh token triggers re-auth
  • API calls use correct access token
  • Secure storage is implemented
  • Network errors are handled gracefully
  • User can logout and revoke tokens

Next Steps