Skip to main content
POST
/
v1
/
auth
/
login
{
  "accessToken": "<string>",
  "userId": "<string>",
  "isOtpRequired": true,
  "phoneNumber": "<string>",
  "phase": "<string>",
  "verificationState": "<string>",
  "isLinked": true
}

Overview

Authenticate a user with their credentials to obtain an access token. This endpoint is used in two contexts:
  1. OAuth Step 2 (API Mode): When using mode=api in OAuth flow, this step authenticates the user to get an access token needed for Step 3
  2. Direct Login: For applications not using OAuth, this provides immediate access token for API authentication
The access token returned expires in 6 hours and should be used in the Authorization: Bearer header for authenticated API requests.

When to Use

  • Implementing OAuth 2.0 flow in API-mode (Step 2 of 4)
  • Direct user authentication without OAuth
  • Need to verify user credentials and get access token
  • User login for internal applications
This step is NOT needed when using the hosted UI OAuth flow (without mode=api). The hosted UI handles authentication automatically.

Request

Headers

x-client-key
string
required
Your public API client key
x-us-env
boolean
Set to true to route requests to the US backend environmentDefault: false (international environment)

Body Parameters

email
string
required
User’s email addressFormat: Valid emailExample: [email protected]
password
string
required
User’s passwordFormat: Password stringExample: SecurePassword123!
otpCode
string
One-time password code (required only if user has OTP enabled)Call POST /v1/auth/login/otp first to send the OTP code to the user’s phoneLength: 6 digitsExample: 123456

Response

accessToken
string
required
Access token for API authenticationUse this in Authorization: Bearer header for authenticated endpointsExpiry: 6 hoursExample: US_b6b9168a-bb56-4c6a-9c0d-4650ea74f5f9
userId
string
User’s unique identifierFormat: UUIDExample: b6b9168c-bb56-4c6a-9c0d-4650ea74f5f9
isOtpRequired
boolean
Indicates if user requires OTP verificationIf true, you must:
  1. Call POST /v1/auth/login/otp to send OTP code
  2. Retry this endpoint with otpCode parameter
Example: false
phoneNumber
string
Masked phone number (only returned if isOtpRequired is true)Example: +445*****225
phase
string
User onboarding phase (only returned during onboarding)Possible values:
  • ACCOUNT
  • PHONE_NUMBER
  • PERSONAL_INFORMATION
  • PHYSICAL_ADDRESS
  • MAILING_ADDRESS
Example: null
verificationState
string
User’s KYC verification statusPossible values:
  • UNVERIFIED - No verification submitted
  • PENDING - Verification in progress
  • VERIFIED - Successfully verified
  • REJECTED - Verification failed
Example: VERIFIED
isLinked
boolean
Indicates if client has valid permission to access user’s accountIn OAuth context, false means authorization hasn’t been granted yetExample: false

Success Response

{
  "accessToken": "US_b6b9168a-bb56-4c6a-9c0d-4650ea74f5f9",
  "userId": "b6b9168c-bb56-4c6a-9c0d-4650ea74f5f9",
  "isOtpRequired": false,
  "phase": null,
  "verificationState": "VERIFIED",
  "isLinked": false
}

Error Responses

{
  "message": "Invalid email or password"
}
Common causes:
  • Incorrect email or password
  • Account doesn’t exist
  • Account is locked or disabled
{
  "message": "OTP verification required",
  "isOtpRequired": true
}
Solution: Call POST /v1/auth/login/otp first, then retry with otpCode
{
  "message": "email must be a valid email"
}
Common causes:
  • Invalid email format
  • Missing required parameters
  • Invalid parameter types

Code Examples

curl -X POST "https://dev.api.baanx.com/v1/auth/login" \
  -H "x-client-key: your-client-key" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "password": "SecurePassword123!"
  }'

OTP Flow Example

If the user has OTP enabled, you need to handle a two-step login process:
async function loginWithOTP(email, password) {
  // Step 1: Initial login attempt
  let response = await fetch('https://dev.api.baanx.com/v1/auth/login', {
    method: 'POST',
    headers: {
      'x-client-key': 'your-client-key',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ email, password })
  });

  let data = await response.json();

  // Step 2: If OTP required, send OTP and retry
  if (data.isOtpRequired) {
    const userId = data.userId;

    // Send OTP code to user's phone
    await fetch('https://dev.api.baanx.com/v1/auth/login/otp', {
      method: 'POST',
      headers: {
        'x-client-key': 'your-client-key',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ userId })
    });

    // Get OTP code from user (show input form)
    const otpCode = await promptUserForOTP();

    // Retry login with OTP code
    response = await fetch('https://dev.api.baanx.com/v1/auth/login', {
      method: 'POST',
      headers: {
        'x-client-key': 'your-client-key',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ email, password, otpCode })
    });

    data = await response.json();
  }

  return data;
}

OAuth Context: Step 2

When using this endpoint as Step 2 in OAuth API-mode flow:
OAuth Step 2
// After Step 1: Initiate OAuth
const { token: jwtToken } = await initiateOAuth(); // Step 1

// Step 2: Authenticate user
const loginResponse = await fetch('https://dev.api.baanx.com/v1/auth/login', {
  method: 'POST',
  headers: {
    'x-client-key': 'your-client-key',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    email: '[email protected]',
    password: 'SecurePassword123!'
  })
});

const { accessToken } = await loginResponse.json();

// Step 3: Use accessToken to generate authorization code
const authResponse = await fetch('https://dev.api.baanx.com/v1/auth/oauth/authorize', {
  method: 'POST',
  headers: {
    'x-client-key': 'your-client-key',
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ token: jwtToken })
});

const { code } = await authResponse.json();
// Continue to Step 4: Token exchange

Edge Cases and Important Notes

Token Expiry: Access tokens expire after 6 hours. In OAuth flow, you should exchange them for long-lived tokens in Step 4. For direct login, implement token refresh logic.
Onboarding Phase: If phase is not null, the user hasn’t completed registration. Guide them through the remaining onboarding steps before allowing full access.
Account Security: Implement rate limiting on your client side to prevent brute force attacks. The API has built-in protection, but client-side throttling improves UX.

Failed Login Attempts

After multiple failed login attempts, accounts may be temporarily locked:
async function loginWithRetry(email, password, maxAttempts = 3) {
  let attempts = 0;

  while (attempts < maxAttempts) {
    try {
      return await login(email, password);
    } catch (error) {
      attempts++;

      if (error.message === 'Account temporarily locked') {
        throw new Error('Too many failed attempts. Please try again later.');
      }

      if (attempts >= maxAttempts) {
        throw new Error('Maximum login attempts exceeded');
      }
    }
  }
}

Token Storage Security

Always store access tokens securely:
  • Web apps: Use httpOnly cookies or secure sessionStorage
  • Mobile apps: Use secure device storage (Keychain/Keystore)
  • Never: Store in localStorage or expose in URLs

Response Scenarios

Understanding different response scenarios helps you handle all authentication states correctly and build robust integrations.

Response Field Decision Matrix

Use this table to determine what action to take based on the response field values:
accessTokenisOtpRequiredphaseAction Required
non-nullfalsenullSuccess: Store token, proceed with API calls
nulltruenullOTP Flow: Call /v1/auth/login/otp, collect code, retry with otpCode
nullfalsenon-nullOnboarding: Direct user to complete registration at specified phase
non-nullfalsenon-nullPartial Onboarding: Token valid but registration incomplete
For complete flow explanations with examples, see the Authentication Guide.

HTTP 200: Successful Login (No OTP)

User authenticated successfully with no additional steps required. Response:
{
  "accessToken": "US_b6b9168a-bb56-4c6a-9c0d-4650ea74f5f9",
  "userId": "b6b9168c-bb56-4c6a-9c0d-4650ea74f5f9",
  "isOtpRequired": false,
  "phoneNumber": null,
  "phase": null,
  "verificationState": "VERIFIED",
  "isLinked": false
}
Client Action:
  1. Store accessToken securely (sessionStorage, secure storage)
  2. Track token issuance time for expiration handling
  3. Use token in Authorization: Bearer header for all API calls
  4. If isLinked: false and long-lived access needed, initiate OAuth flow
cURL Example:
curl -X POST "https://dev.api.baanx.com/v1/auth/login" \
  -H "x-client-key: your-client-key" \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"SecurePassword123!"}'

HTTP 200: OTP Required

User has 2FA enabled. Initial authentication succeeded but OTP verification required. Response:
{
  "accessToken": null,
  "userId": "b6b9168c-bb56-4c6a-9c0d-4650ea74f5f9",
  "isOtpRequired": true,
  "phoneNumber": "+445*****225",
  "phase": null,
  "verificationState": "VERIFIED",
  "isLinked": false
}
Why accessToken is null: The token won’t be issued until OTP verification completes. This prevents unauthorized access even if credentials are compromised. Client Action:
  1. Call POST /v1/auth/login/otp with userId to send OTP
  2. Display OTP input field to user
  3. Show masked phoneNumber so user knows where to check
  4. Retry POST /v1/auth/login with credentials + otpCode parameter
  5. Handle OTP-specific errors (invalid code, expired code, rate limit)
Flow:
# Step 1: Send OTP
curl -X POST "https://dev.api.baanx.com/v1/auth/login/otp" \
  -H "x-client-key: your-client-key" \
  -H "Content-Type: application/json" \
  -d '{"userId":"b6b9168c-bb56-4c6a-9c0d-4650ea74f5f9"}'

# Step 2: User receives SMS, enters code "123456"

# Step 3: Complete login with OTP
curl -X POST "https://dev.api.baanx.com/v1/auth/login" \
  -H "x-client-key: your-client-key" \
  -H "Content-Type: application/json" \
  -d '{
    "email":"[email protected]",
    "password":"SecurePassword123!",
    "otpCode":"123456"
  }'
See Send OTP for detailed OTP endpoint documentation.

HTTP 200: User Onboarding Incomplete

User account exists but hasn’t completed registration. Response:
{
  "accessToken": null,
  "userId": "b6b9168c-bb56-4c6a-9c0d-4650ea74f5f9",
  "isOtpRequired": false,
  "phoneNumber": null,
  "phase": "PHONE_NUMBER",
  "verificationState": null,
  "isLinked": false
}
Phase Values & Meanings:
  • ACCOUNT: Basic account creation (email/password set)
  • PHONE_NUMBER: Phone verification required
  • PERSONAL_INFORMATION: Name, DOB, SSN collection
  • PHYSICAL_ADDRESS: Residential address information
  • MAILING_ADDRESS: Mailing address (if different from physical)
Client Action:
  1. Direct user to continue registration at the specified phase
  2. Do NOT attempt API calls - accessToken is null
  3. Guide user through onboarding steps
  4. After completion, phase will be null and accessToken will be provided
API endpoints requiring authentication will fail with 401 until onboarding completes and a valid accessToken is issued.

HTTP 401: Invalid Credentials

Incorrect email/password combination or account doesn’t exist. Response:
{
  "message": "Invalid email or password"
}
Common Causes:
  • Typo in email or password
  • User hasn’t registered yet
  • Password changed but old credentials used
  • Testing with wrong environment credentials
Client Action:
  1. Display generic error message to user (don’t specify which field is wrong)
  2. Implement rate limiting on client side (exponential backoff)
  3. Offer “Forgot Password” option after 2-3 failed attempts
  4. After 5 failed attempts, suggest account recovery
Security Best Practice:
// Don't reveal which field is incorrect
if (error.status === 401) {
  showError('Invalid email or password');
  // NOT: 'Email not found' or 'Wrong password'
}

HTTP 403: Account Locked

Account temporarily locked due to security concerns. Response:
{
  "message": "Account is temporarily locked. Please try again later or contact support."
}
Common Causes:
  • Multiple failed login attempts (brute force protection)
  • Suspicious activity detected
  • Manual lock by admin/support
  • Security policy violation
Client Action:
  1. Display error message with support contact information
  2. Do NOT retry immediately - will extend lock duration
  3. Implement exponential backoff (start with 1 hour delay)
  4. Provide link to account recovery/support
Typical Lock Duration: 15-60 minutes (automatic unlock)

HTTP 422: Validation Error

Request parameters failed validation. Response:
{
  "message": "email must be a valid email",
  "field": "email"
}
Common Validation Errors:
  • email must be a valid email: Invalid email format
  • password is required: Missing password field
  • otpCode must be 6 digits: Invalid OTP code format
Client Action:
  1. Validate input on client side before submission
  2. Display field-specific error messages
  3. Highlight invalid fields in UI
  4. Prevent submission until validation passes
Client-Side Validation Example:
function validateLoginForm(email: string, password: string): string | null {
  if (!email || !/\S+@\S+\.\S+/.test(email)) {
    return 'Please enter a valid email address';
  }
  if (!password || password.length < 8) {
    return 'Password must be at least 8 characters';
  }
  return null;
}

Edge Cases

Multiple Failed OTP Attempts

After 3-5 failed OTP verification attempts, the account may be temporarily locked. Response:
{
  "message": "Too many failed OTP attempts. Please try again later.",
  "retryAfter": 1800
}
Handling:
if (error.message.includes('Too many failed OTP attempts')) {
  const waitMinutes = Math.ceil(error.retryAfter / 60);
  showError(`Please wait ${waitMinutes} minutes before trying again`);

  // Disable OTP input and show countdown timer
  startCountdown(error.retryAfter);
}

Expired OTP Code

OTP codes typically expire after 5-10 minutes. Response:
{
  "message": "OTP code has expired",
  "isOtpRequired": true
}
Handling:
if (error.message.includes('expired')) {
  // Automatically request new OTP
  await sendNewOtp(userId);
  showMessage('Code expired. New code sent to your phone.');
}

Environment Mismatch

Using production credentials in sandbox or vice versa. Symptoms:
  • 401 errors despite correct credentials
  • User not found errors
  • Token format mismatch errors
Resolution:
// Ensure environment consistency
const environment = 'production'; // or 'sandbox'

const headers = {
  'x-client-key': environment === 'production'
    ? PROD_CLIENT_KEY
    : SANDBOX_CLIENT_KEY,
  'x-us-env': environment === 'production' ? 'true' : 'false'
};

const apiBase = 'https://dev.api.baanx.com';

Token Already Exists

User attempting to login while already having a valid session. Behavior:
  • New token is issued
  • Previous token remains valid until expiration
  • No error returned
Best Practice:
async function login(email: string, password: string) {
  const existingToken = getStoredToken();

  if (existingToken && isTokenValid(existingToken)) {
    // Ask user if they want to create new session
    const createNew = await confirmNewSession();
    if (!createNew) return existingToken;
  }

  const newToken = await performLogin(email, password);
  storeToken(newToken);
  return newToken;
}

Network Timeout During Authentication

Request timeout or network interruption. Handling:
async function loginWithRetry(
  email: string,
  password: string,
  maxRetries = 3
): Promise<string> {
  let lastError: Error;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout

      const response = await fetch('/v1/auth/login', {
        method: 'POST',
        signal: controller.signal,
        headers: { 'x-client-key': CLIENT_KEY },
        body: JSON.stringify({ email, password })
      });

      clearTimeout(timeoutId);

      if (!response.ok) throw new Error(`HTTP ${response.status}`);

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

    } catch (error) {
      lastError = error;

      if (error.name === 'AbortError') {
        console.log(`Attempt ${attempt} timed out`);
      }

      if (attempt < maxRetries) {
        const delay = Math.pow(2, attempt) * 1000; // Exponential backoff
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }

  throw new Error(`Login failed after ${maxRetries} attempts: ${lastError.message}`);
}

Race Condition: Multiple Simultaneous Login Requests

If multiple login requests are made simultaneously (e.g., from different tabs). Behavior:
  • Each request returns a valid but different access token
  • Last token stored wins
  • Previous tokens remain valid until expiration
Prevention:
let loginPromise: Promise<string> | null = null;

async function login(email: string, password: string): Promise<string> {
  // If login already in progress, return existing promise
  if (loginPromise) {
    return loginPromise;
  }

  loginPromise = performLogin(email, password)
    .finally(() => {
      loginPromise = null;
    });

  return loginPromise;
}

Concurrent Onboarding and Login

User attempting login while still in registration flow. Scenario:
{
  "accessToken": "US_temp123",
  "phase": "PERSONAL_INFORMATION",
  "isOtpRequired": false
}
Handling: Some phases may provide a temporary token for registration API calls. Check both accessToken and phase:
if (response.phase !== null) {
  // Redirect to onboarding, even if accessToken exists
  navigateToOnboarding(response.phase);
} else if (response.accessToken) {
  // Full access granted
  navigateToApp();
}

Complete Integration Example

Full production-ready login implementation with comprehensive error handling.
import { z } from 'zod';

const LoginResponseSchema = z.object({
  accessToken: z.string().nullable(),
  userId: z.string().uuid(),
  isOtpRequired: z.boolean(),
  phoneNumber: z.string().nullable(),
  phase: z.string().nullable(),
  verificationState: z.string().nullable(),
  isLinked: z.boolean()
});

type LoginResponse = z.infer<typeof LoginResponseSchema>;

class AuthenticationError extends Error {
  constructor(
    message: string,
    public code: string,
    public statusCode: number,
    public retryable: boolean = false
  ) {
    super(message);
    this.name = 'AuthenticationError';
  }
}

class LoginService {
  private readonly apiBase: string;
  private readonly clientKey: string;
  private readonly maxRetries = 3;
  private readonly timeoutMs = 30000;

  constructor(apiBase: string, clientKey: string) {
    this.apiBase = apiBase;
    this.clientKey = clientKey;
  }

  async login(
    email: string,
    password: string,
    otpCode?: string
  ): Promise<LoginResponse> {
    const body = { email, password, ...(otpCode && { otpCode }) };

    try {
      const response = await this.fetchWithTimeout(
        `${this.apiBase}/v1/auth/login`,
        {
          method: 'POST',
          headers: {
            'x-client-key': this.clientKey,
            'Content-Type': 'application/json'
          },
          body: JSON.stringify(body)
        }
      );

      const data = await response.json();

      if (!response.ok) {
        throw this.handleErrorResponse(response.status, data);
      }

      return LoginResponseSchema.parse(data);

    } catch (error) {
      if (error instanceof AuthenticationError) {
        throw error;
      }

      throw new AuthenticationError(
        'Network error during login',
        'NETWORK_ERROR',
        0,
        true
      );
    }
  }

  async sendOtp(userId: string): Promise<void> {
    const response = await this.fetchWithTimeout(
      `${this.apiBase}/v1/auth/login/otp`,
      {
        method: 'POST',
        headers: {
          'x-client-key': this.clientKey,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ userId })
      }
    );

    if (!response.ok) {
      const data = await response.json();
      throw this.handleErrorResponse(response.status, data);
    }
  }

  async loginWithOtpFlow(
    email: string,
    password: string,
    otpCallback: () => Promise<string>
  ): Promise<LoginResponse> {
    const initialResponse = await this.login(email, password);

    if (!initialResponse.isOtpRequired) {
      return initialResponse;
    }

    await this.sendOtp(initialResponse.userId);

    const otpCode = await otpCallback();

    return await this.login(email, password, otpCode);
  }

  private async fetchWithTimeout(
    url: string,
    options: RequestInit
  ): Promise<Response> {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);

    try {
      const response = await fetch(url, {
        ...options,
        signal: controller.signal
      });
      return response;
    } finally {
      clearTimeout(timeoutId);
    }
  }

  private handleErrorResponse(
    status: number,
    data: any
  ): AuthenticationError {
    switch (status) {
      case 401:
        return new AuthenticationError(
          'Invalid credentials',
          'INVALID_CREDENTIALS',
          401,
          false
        );

      case 403:
        return new AuthenticationError(
          data.message || 'Account locked',
          'ACCOUNT_LOCKED',
          403,
          false
        );

      case 422:
        return new AuthenticationError(
          data.message || 'Validation error',
          'VALIDATION_ERROR',
          422,
          false
        );

      case 429:
        return new AuthenticationError(
          'Too many attempts',
          'RATE_LIMITED',
          429,
          true
        );

      default:
        return new AuthenticationError(
          data.message || 'Authentication failed',
          'UNKNOWN_ERROR',
          status,
          status >= 500
        );
    }
  }
}

// Usage Example
const loginService = new LoginService(
  'https://dev.api.baanx.com',
  'your-client-key'
);

async function handleLogin(email: string, password: string) {
  try {
    const response = await loginService.loginWithOtpFlow(
      email,
      password,
      async () => {
        return await promptUserForOtp();
      }
    );

    if (response.phase !== null) {
      console.log('User needs to complete onboarding:', response.phase);
      redirectToOnboarding(response.phase);
      return;
    }

    if (!response.accessToken) {
      throw new Error('No access token received');
    }

    console.log('Login successful');
    storeToken(response.accessToken);
    redirectToApp();

  } catch (error) {
    if (error instanceof AuthenticationError) {
      handleAuthError(error);
    } else {
      console.error('Unexpected error:', error);
    }
  }
}

function handleAuthError(error: AuthenticationError) {
  switch (error.code) {
    case 'INVALID_CREDENTIALS':
      showError('Invalid email or password');
      offerPasswordReset();
      break;

    case 'ACCOUNT_LOCKED':
      showError('Account is locked. Please contact support.');
      showSupportLink();
      break;

    case 'RATE_LIMITED':
      showError('Too many attempts. Please wait and try again.');
      break;

    case 'NETWORK_ERROR':
      if (error.retryable) {
        showError('Connection issue. Retrying...');
        retryLogin();
      }
      break;

    default:
      showError('Login failed. Please try again.');
  }
}

function promptUserForOtp(): Promise<string> {
  return new Promise((resolve) => {
    // Show OTP input modal
    const modal = showOtpModal();
    modal.onSubmit((code: string) => resolve(code));
  });
}

function storeToken(token: string) {
  sessionStorage.setItem('accessToken', token);
  sessionStorage.setItem('tokenIssuedAt', Date.now().toString());
}

function redirectToOnboarding(phase: string) {
  window.location.href = `/onboarding/${phase.toLowerCase()}`;
}

function redirectToApp() {
  window.location.href = '/dashboard';
}

function showError(message: string) {
  console.error(message);
}

function offerPasswordReset() {
  console.log('Showing password reset link');
}

function showSupportLink() {
  console.log('Showing support contact information');
}

function retryLogin() {
  console.log('Retrying login...');
}

function showOtpModal() {
  return {
    onSubmit: (callback: (code: string) => void) => {
      setTimeout(() => callback('123456'), 1000);
    }
  };
}

Testing Different Scenarios

Use these test cases to verify your integration handles all scenarios correctly:
Setup:
  • Valid email and password
  • No OTP enabled
  • Onboarding complete
Expected Result:
  • HTTP 200
  • Valid accessToken returned
  • isOtpRequired: false
  • phase: null
Verification:
curl -X POST "https://dev.api.baanx.com/v1/auth/login" \
  -H "x-client-key: your-client-key" \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"ValidPass123!"}'

# Should return accessToken immediately
Setup:
  • Valid email and password
  • OTP enabled on account
Expected Result:
  • HTTP 200 on initial login with isOtpRequired: true
  • HTTP 200 on OTP send
  • HTTP 200 on final login with valid accessToken
Verification:
# Step 1: Initial login
curl -X POST "https://dev.api.baanx.com/v1/auth/login" \
  -H "x-client-key: your-client-key" \
  -d '{"email":"[email protected]","password":"ValidPass123!"}'
# Returns: isOtpRequired: true, userId, phoneNumber

# Step 2: Send OTP
curl -X POST "https://dev.api.baanx.com/v1/auth/login/otp" \
  -H "x-client-key: your-client-key" \
  -d '{"userId":"<userId from step 1>"}'

# Step 3: Complete with OTP
curl -X POST "https://dev.api.baanx.com/v1/auth/login" \
  -H "x-client-key: your-client-key" \
  -d '{
    "email":"[email protected]",
    "password":"ValidPass123!",
    "otpCode":"123456"
  }'
# Returns: accessToken
Setup:
  • Invalid email or password
Expected Result:
  • HTTP 401
  • Error message: “Invalid email or password”
Verification:
curl -X POST "https://dev.api.baanx.com/v1/auth/login" \
  -H "x-client-key: your-client-key" \
  -d '{"email":"[email protected]","password":"WrongPass123!"}'

# Should return 401 with error message
Setup:
  • Valid credentials
  • User has not completed onboarding
Expected Result:
  • HTTP 200
  • accessToken: null
  • phase: "PHONE_NUMBER" (or other phase)
Verification:
curl -X POST "https://dev.api.baanx.com/v1/auth/login" \
  -H "x-client-key: your-client-key" \
  -d '{"email":"[email protected]","password":"ValidPass123!"}'

# Should return phase indicating incomplete onboarding
Setup:
  • Account locked due to failed attempts
Expected Result:
  • HTTP 403
  • Error message about account lock
Verification:
# Simulate by making multiple failed attempts
for i in {1..6}; do
  curl -X POST "https://dev.api.baanx.com/v1/auth/login" \
    -H "x-client-key: your-client-key" \
    -d '{"email":"[email protected]","password":"WrongPassword"}'
done

# Final attempt should return 403
For detailed authentication flows and patterns, see the Authentication Guide.