> ## Documentation Index
> Fetch the complete documentation index at: https://docs.baanx.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Authenticate User

> Authenticate user with email and password to obtain an access token

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

<Note>
  This step is **NOT needed** when using the hosted UI OAuth flow (without `mode=api`). The hosted UI handles authentication automatically.
</Note>

## Request

### Headers

<ParamField header="x-client-key" type="string" required>
  Your public API client key
</ParamField>

<ParamField header="x-us-env" type="boolean">
  Set to `true` to route requests to the US backend environment

  **Default**: `false` (international environment)
</ParamField>

### Body Parameters

<ParamField body="email" type="string" required>
  User's email address

  **Format**: Valid email

  **Example**: `user@example.com`
</ParamField>

<ParamField body="password" type="string" required>
  User's password

  **Format**: Password string

  **Example**: `SecurePassword123!`
</ParamField>

<ParamField body="otpCode" type="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 phone

  **Length**: 6 digits

  **Example**: `123456`
</ParamField>

## Response

<ResponseField name="accessToken" type="string" required>
  Access token for API authentication

  Use this in `Authorization: Bearer` header for authenticated endpoints

  **Expiry**: 6 hours

  **Example**: `US_b6b9168a-bb56-4c6a-9c0d-4650ea74f5f9`
</ResponseField>

<ResponseField name="userId" type="string">
  User's unique identifier

  **Format**: UUID

  **Example**: `b6b9168c-bb56-4c6a-9c0d-4650ea74f5f9`
</ResponseField>

<ResponseField name="isOtpRequired" type="boolean">
  Indicates if user requires OTP verification

  If `true`, you must:

  1. Call `POST /v1/auth/login/otp` to send OTP code
  2. Retry this endpoint with `otpCode` parameter

  **Example**: `false`
</ResponseField>

<ResponseField name="phoneNumber" type="string">
  Masked phone number (only returned if `isOtpRequired` is `true`)

  **Example**: `+445*****225`
</ResponseField>

<ResponseField name="phase" type="string">
  User onboarding phase (only returned during onboarding)

  **Possible values**:

  * `ACCOUNT`
  * `PHONE_NUMBER`
  * `PERSONAL_INFORMATION`
  * `PHYSICAL_ADDRESS`
  * `MAILING_ADDRESS`

  **Example**: `null`
</ResponseField>

<ResponseField name="verificationState" type="string">
  User's KYC verification status

  **Possible values**:

  * `UNVERIFIED` - No verification submitted
  * `PENDING` - Verification in progress
  * `VERIFIED` - Successfully verified
  * `REJECTED` - Verification failed

  **Example**: `VERIFIED`
</ResponseField>

<ResponseField name="isLinked" type="boolean">
  Indicates if client has valid permission to access user's account

  In OAuth context, `false` means authorization hasn't been granted yet

  **Example**: `false`
</ResponseField>

### Success Response

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

### Error Responses

<Accordion title="400 Bad Request - Invalid Credentials">
  ```json theme={null}
  {
    "message": "Invalid email or password"
  }
  ```

  **Common causes**:

  * Incorrect email or password
  * Account doesn't exist
  * Account is locked or disabled
</Accordion>

<Accordion title="400 Bad Request - OTP Required">
  ```json theme={null}
  {
    "message": "OTP verification required",
    "isOtpRequired": true
  }
  ```

  **Solution**: Call `POST /v1/auth/login/otp` first, then retry with `otpCode`
</Accordion>

<Accordion title="422 Validation Error">
  ```json theme={null}
  {
    "message": "email must be a valid email"
  }
  ```

  **Common causes**:

  * Invalid email format
  * Missing required parameters
  * Invalid parameter types
</Accordion>

## Code Examples

<CodeGroup>
  ```bash cURL theme={null}
  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": "user@example.com",
      "password": "SecurePassword123!"
    }'
  ```

  ```javascript JavaScript theme={null}
  const 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: 'user@example.com',
      password: 'SecurePassword123!'
    })
  });

  const data = await response.json();

  if (data.isOtpRequired) {
    console.log('OTP required. Send to:', data.phoneNumber);
    // Call /v1/auth/login/otp and retry with otpCode
  } else {
    console.log('Access token:', data.accessToken);
    // Store token for API requests
    localStorage.setItem('access_token', data.accessToken);
  }
  ```

  ```python Python theme={null}
  import requests

  response = requests.post(
      'https://dev.api.baanx.com/v1/auth/login',
      headers={
          'x-client-key': 'your-client-key',
          'Content-Type': 'application/json'
      },
      json={
          'email': 'user@example.com',
          'password': 'SecurePassword123!'
      }
  )

  data = response.json()

  if response.status_code == 200:
      if data.get('isOtpRequired'):
          print(f"OTP required. Send to: {data.get('phoneNumber')}")
          # Call /v1/auth/login/otp and retry with otpCode
      else:
          print(f"Access token: {data['accessToken']}")
          # Store token for API requests
          session['access_token'] = data['accessToken']
  else:
      print(f"Login failed: {data.get('message')}")
  ```

  ```typescript TypeScript theme={null}
  interface LoginRequest {
    email: string;
    password: string;
    otpCode?: string;
  }

  interface LoginResponse {
    accessToken: string;
    userId: string;
    isOtpRequired: boolean;
    phoneNumber?: string;
    phase?: string | null;
    verificationState?: string;
    isLinked: boolean;
  }

  async function login(credentials: LoginRequest): Promise<LoginResponse> {
    const 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(credentials)
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Login failed');
    }

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

    if (data.isOtpRequired && !credentials.otpCode) {
      throw new Error('OTP_REQUIRED');
    }

    return data;
  }
  ```
</CodeGroup>

## OTP Flow Example

If the user has OTP enabled, you need to handle a two-step login process:

<CodeGroup>
  ```javascript OTP Flow theme={null}
  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;
  }
  ```

  ```python OTP Flow theme={null}
  import requests

  def login_with_otp(email: str, password: str) -> dict:
      base_url = 'https://dev.api.baanx.com'
      headers = {
          'x-client-key': 'your-client-key',
          'Content-Type': 'application/json'
      }

      # Step 1: Initial login attempt
      response = requests.post(
          f'{base_url}/v1/auth/login',
          headers=headers,
          json={'email': email, 'password': password}
      )

      data = response.json()

      # Step 2: If OTP required, send OTP and retry
      if data.get('isOtpRequired'):
          user_id = data['userId']

          # Send OTP code to user's phone
          requests.post(
              f'{base_url}/v1/auth/login/otp',
              headers=headers,
              json={'userId': user_id}
          )

          # Get OTP code from user
          otp_code = input(f"Enter OTP sent to {data.get('phoneNumber')}: ")

          # Retry login with OTP code
          response = requests.post(
              f'{base_url}/v1/auth/login',
              headers=headers,
              json={'email': email, 'password': password, 'otpCode': otp_code}
          )

          data = response.json()

      return data
  ```
</CodeGroup>

## OAuth Context: Step 2

When using this endpoint as Step 2 in OAuth API-mode flow:

```javascript OAuth Step 2 theme={null}
// 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: 'user@example.com',
    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

<Warning>
  **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.
</Warning>

<Note>
  **Onboarding Phase**: If `phase` is not `null`, the user hasn't completed registration. Guide them through the remaining onboarding steps before allowing full access.
</Note>

<Tip>
  **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.
</Tip>

### Failed Login Attempts

After multiple failed login attempts, accounts may be temporarily locked:

```javascript theme={null}
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:

| accessToken | isOtpRequired | phase    | Action Required                                                             |
| ----------- | ------------- | -------- | --------------------------------------------------------------------------- |
| non-null    | false         | null     | **Success**: Store token, proceed with API calls                            |
| null        | true          | null     | **OTP Flow**: Call `/v1/auth/login/otp`, collect code, retry with `otpCode` |
| null        | false         | non-null | **Onboarding**: Direct user to complete registration at specified phase     |
| non-null    | false         | non-null | **Partial Onboarding**: Token valid but registration incomplete             |

<Info>
  For complete flow explanations with examples, see the [Authentication Guide](/guides/user/authentication#edge-cases-and-response-scenarios).
</Info>

### HTTP 200: Successful Login (No OTP)

User authenticated successfully with no additional steps required.

**Response:**

```json theme={null}
{
  "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:**

```bash theme={null}
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":"user@example.com","password":"SecurePassword123!"}'
```

### HTTP 200: OTP Required

User has 2FA enabled. Initial authentication succeeded but OTP verification required.

**Response:**

```json theme={null}
{
  "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:**

```bash theme={null}
# 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":"user@example.com",
    "password":"SecurePassword123!",
    "otpCode":"123456"
  }'
```

<Note>
  See [Send OTP](/api-reference/auth/login-otp) for detailed OTP endpoint documentation.
</Note>

### HTTP 200: User Onboarding Incomplete

User account exists but hasn't completed registration.

**Response:**

```json theme={null}
{
  "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

<Warning>
  API endpoints requiring authentication will fail with 401 until onboarding completes and a valid `accessToken` is issued.
</Warning>

### HTTP 401: Invalid Credentials

Incorrect email/password combination or account doesn't exist.

**Response:**

```json theme={null}
{
  "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:**

```javascript theme={null}
// 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:**

```json theme={null}
{
  "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:**

```json theme={null}
{
  "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:**

```javascript theme={null}
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:**

```json theme={null}
{
  "message": "Too many failed OTP attempts. Please try again later.",
  "retryAfter": 1800
}
```

**Handling:**

```javascript theme={null}
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:**

```json theme={null}
{
  "message": "OTP code has expired",
  "isOtpRequired": true
}
```

**Handling:**

```javascript theme={null}
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:**

```javascript theme={null}
// 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:**

```javascript theme={null}
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:**

```javascript theme={null}
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:**

```javascript theme={null}
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:**

```json theme={null}
{
  "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`:

```javascript theme={null}
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.

<CodeGroup>
  ```typescript TypeScript theme={null}
  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);
      }
    };
  }
  ```

  ```python Python theme={null}
  import time
  import requests
  from typing import Optional, Callable, TypedDict
  from enum import Enum
  from dataclasses import dataclass

  class LoginPhase(str, Enum):
      ACCOUNT = "ACCOUNT"
      PHONE_NUMBER = "PHONE_NUMBER"
      PERSONAL_INFORMATION = "PERSONAL_INFORMATION"
      PHYSICAL_ADDRESS = "PHYSICAL_ADDRESS"
      MAILING_ADDRESS = "MAILING_ADDRESS"

  class VerificationState(str, Enum):
      UNVERIFIED = "UNVERIFIED"
      PENDING = "PENDING"
      VERIFIED = "VERIFIED"
      REJECTED = "REJECTED"

  class LoginResponse(TypedDict, total=False):
      accessToken: Optional[str]
      userId: str
      isOtpRequired: bool
      phoneNumber: Optional[str]
      phase: Optional[str]
      verificationState: Optional[str]
      isLinked: bool

  @dataclass
  class AuthenticationError(Exception):
      message: str
      code: str
      status_code: int
      retryable: bool = False

  class LoginService:
      TOKEN_LIFETIME_SECONDS = 6 * 60 * 60

      def __init__(self, api_base: str, client_key: str):
          self.api_base = api_base
          self.client_key = client_key
          self.session = requests.Session()
          self.session.headers.update({
              'x-client-key': client_key,
              'Content-Type': 'application/json'
          })

      def login(
          self,
          email: str,
          password: str,
          otp_code: Optional[str] = None
      ) -> LoginResponse:
          body = {'email': email, 'password': password}
          if otp_code:
              body['otpCode'] = otp_code

          try:
              response = self.session.post(
                  f'{self.api_base}/v1/auth/login',
                  json=body,
                  timeout=30
              )

              data = response.json()

              if not response.ok:
                  raise self._handle_error_response(response.status_code, data)

              return data

          except requests.exceptions.Timeout:
              raise AuthenticationError(
                  'Request timed out',
                  'TIMEOUT',
                  0,
                  retryable=True
              )
          except requests.exceptions.ConnectionError:
              raise AuthenticationError(
                  'Connection failed',
                  'CONNECTION_ERROR',
                  0,
                  retryable=True
              )

      def send_otp(self, user_id: str) -> None:
          response = self.session.post(
              f'{self.api_base}/v1/auth/login/otp',
              json={'userId': user_id},
              timeout=30
          )

          if not response.ok:
              data = response.json()
              raise self._handle_error_response(response.status_code, data)

      def login_with_otp_flow(
          self,
          email: str,
          password: str,
          otp_callback: Callable[[], str]
      ) -> LoginResponse:
          initial_response = self.login(email, password)

          if not initial_response.get('isOtpRequired'):
              return initial_response

          self.send_otp(initial_response['userId'])

          print(f"OTP sent to {initial_response.get('phoneNumber')}")
          otp_code = otp_callback()

          return self.login(email, password, otp_code)

      def _handle_error_response(
          self,
          status_code: int,
          data: dict
      ) -> AuthenticationError:
          message = data.get('message', 'Authentication failed')

          error_map = {
              401: ('INVALID_CREDENTIALS', False),
              403: ('ACCOUNT_LOCKED', False),
              422: ('VALIDATION_ERROR', False),
              429: ('RATE_LIMITED', True),
          }

          code, retryable = error_map.get(
              status_code,
              ('UNKNOWN_ERROR', status_code >= 500)
          )

          return AuthenticationError(message, code, status_code, retryable)

  # Usage Example
  def main():
      login_service = LoginService(
          'https://dev.api.baanx.com',
          'your-client-key'
      )

      try:
          response = login_service.login_with_otp_flow(
              'user@example.com',
              'SecurePassword123!',
              otp_callback=lambda: input('Enter OTP code: ')
          )

          # Check onboarding status
          if response.get('phase'):
              print(f"Complete onboarding phase: {response['phase']}")
              return

          # Check token
          if not response.get('accessToken'):
              raise Exception('No access token received')

          print('Login successful!')
          store_token(response['accessToken'])

      except AuthenticationError as error:
          handle_auth_error(error)
      except Exception as error:
          print(f'Unexpected error: {error}')

  def handle_auth_error(error: AuthenticationError):
      if error.code == 'INVALID_CREDENTIALS':
          print('Invalid email or password')
          offer_password_reset()

      elif error.code == 'ACCOUNT_LOCKED':
          print('Account is locked. Please contact support.')
          show_support_link()

      elif error.code == 'RATE_LIMITED':
          print('Too many attempts. Please wait and try again.')

      elif error.code in ('TIMEOUT', 'CONNECTION_ERROR') and error.retryable:
          print('Connection issue. Please try again.')

      else:
          print(f'Login failed: {error.message}')

  def store_token(token: str):
      print(f'Storing token: {token[:20]}...')

  def offer_password_reset():
      print('Password reset link offered')

  def show_support_link():
      print('Support contact: support@example.com')

  if __name__ == '__main__':
      main()
  ```
</CodeGroup>

## Testing Different Scenarios

Use these test cases to verify your integration handles all scenarios correctly:

<Accordion title="Test Case 1: Successful Login">
  **Setup:**

  * Valid email and password
  * No OTP enabled
  * Onboarding complete

  **Expected Result:**

  * HTTP 200
  * Valid `accessToken` returned
  * `isOtpRequired: false`
  * `phase: null`

  **Verification:**

  ```bash theme={null}
  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":"test@example.com","password":"ValidPass123!"}'

  # Should return accessToken immediately
  ```
</Accordion>

<Accordion title="Test Case 2: OTP Flow">
  **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:**

  ```bash theme={null}
  # Step 1: Initial login
  curl -X POST "https://dev.api.baanx.com/v1/auth/login" \
    -H "x-client-key: your-client-key" \
    -d '{"email":"otp-user@example.com","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":"otp-user@example.com",
      "password":"ValidPass123!",
      "otpCode":"123456"
    }'
  # Returns: accessToken
  ```
</Accordion>

<Accordion title="Test Case 3: Invalid Credentials">
  **Setup:**

  * Invalid email or password

  **Expected Result:**

  * HTTP 401
  * Error message: "Invalid email or password"

  **Verification:**

  ```bash theme={null}
  curl -X POST "https://dev.api.baanx.com/v1/auth/login" \
    -H "x-client-key: your-client-key" \
    -d '{"email":"wrong@example.com","password":"WrongPass123!"}'

  # Should return 401 with error message
  ```
</Accordion>

<Accordion title="Test Case 4: User Onboarding">
  **Setup:**

  * Valid credentials
  * User has not completed onboarding

  **Expected Result:**

  * HTTP 200
  * `accessToken: null`
  * `phase: "PHONE_NUMBER"` (or other phase)

  **Verification:**

  ```bash theme={null}
  curl -X POST "https://dev.api.baanx.com/v1/auth/login" \
    -H "x-client-key: your-client-key" \
    -d '{"email":"incomplete@example.com","password":"ValidPass123!"}'

  # Should return phase indicating incomplete onboarding
  ```
</Accordion>

<Accordion title="Test Case 5: Account Locked">
  **Setup:**

  * Account locked due to failed attempts

  **Expected Result:**

  * HTTP 403
  * Error message about account lock

  **Verification:**

  ```bash theme={null}
  # 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":"test@example.com","password":"WrongPassword"}'
  done

  # Final attempt should return 403
  ```
</Accordion>

## Related Endpoints

<CardGroup cols={2}>
  <Card title="Send OTP" icon="mobile" href="/api-reference/auth/login-otp">
    Send OTP verification code via SMS for 2FA authentication
  </Card>

  <Card title="Logout" icon="right-from-bracket" href="/api-reference/auth/logout">
    Invalidate access token and end user session
  </Card>

  <Card title="OAuth Authorize" icon="key" href="/api-reference/auth/oauth-authorize">
    Generate authorization code for OAuth 2.0 token exchange
  </Card>

  <Card title="Get User Profile" icon="user" href="/api-reference/user/get-user">
    Retrieve authenticated user's profile and verification status
  </Card>
</CardGroup>

<Note>
  For detailed authentication flows and patterns, see the [Authentication Guide](/guides/user/authentication).
</Note>
