# Authentication API

> REST endpoints for authentication — request and response reference with examples.

<!-- Source: https://docs.actuallycare.com/api/reference/authentication -->

Session management for user-facing apps: register, log in, refresh tokens, and manage sessions. If you are building a server-side integration, you usually want an [API key](/api/authentication) instead.

Base URL: `https://api.actuallycare.com/v1` · Errors use the [standard envelope](/api/reference#error-responses) — see [Errors](/api/errors).

## Register new user

```http
POST /v1/auth/register
```

Creates a new user account and always returns a signed JWT.

**Waitlist gate:** when the platform is accepting signups (`ACCEPT_NEW_USERS=true`,
or the registration arrives via a brokerage invite) the account is created with
status `active` and onboarding starts immediately. Otherwise the account is created
with status `waitlisted` — the returned JWT can only reach `/auth/*` and `/waitlist/*`
endpoints; every other endpoint returns 403 with error.code `ACCOUNT_NOT_ACTIVE`
until an admin approves the account. Both outcomes return 201 with the same shape;
check `data.status` and follow `data.redirect`.

### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string | Yes | User's email address (login identifier, stored lowercase) · Format: email |
| `username` | string | Yes | Unique username — letters, numbers, and underscores only (stored lowercase) |
| `password` | string | Yes | Account password — min 8 chars, at least one uppercase letter and one number |
| `firstName` | string | Yes | User's first name |
| `lastName` | string | Yes | User's last name |
| `role` | enum | No | Optional starting role for the account · One of: `agent`, `broker`, `lender`, `vendor` · Default: `"agent"` |
| `emailKind` | enum | No | Optional classification of the provided email address · One of: `personal`, `work`, `offers` |
| `phone` | string | No | Optional phone number (required for SMS consent to be recorded) |
| `smsConsent` | boolean | No | Optional explicit consent to receive SMS (recorded only when phone is provided) |

### Example request

```bash title="cURL"
curl -X POST "https://api.actuallycare.com/v1/auth/register" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "sarah.chen@gmail.com",
    "username": "sarahchen",
    "password": "your-password",
    "firstName": "Sarah",
    "lastName": "Chen",
    "phone": "(661) 555-0142"
  }'
```

```javascript title="JavaScript"
const res = await fetch("https://api.actuallycare.com/v1/auth/register", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "email": "sarah.chen@gmail.com",
    "username": "sarahchen",
    "password": "your-password",
    "firstName": "Sarah",
    "lastName": "Chen",
    "phone": "(661) 555-0142"
  }),
});
const data = await res.json();
```

```python title="Python"
import requests

resp = requests.post(
    "https://api.actuallycare.com/v1/auth/register",
    json={
        "email": "sarah.chen@gmail.com",
        "username": "sarahchen",
        "password": "your-password",
        "firstName": "Sarah",
        "lastName": "Chen",
        "phone": "(661) 555-0142"
    },
)
data = resp.json()
```

### Example response (201)

```json
{
  "success": true,
  "data": {
    "user": {
      "id": "b7e9d2c4-5f1a-4e8b-9c3d-2a6f8e0b4d17",
      "email": "sarah.chen@gmail.com",
      "firstName": "Sarah",
      "lastName": "Chen",
      "role": [
        "agent"
      ],
      "isActive": true,
      "status": "active"
    },
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImI3ZTlkMmM0LTVmMWEtNGU4Yi05YzNkLTJhNmY4ZTBiNGQxNyJ9.Qm4x8pT2cVhLk9wYzR0aXNlY3VyZQ",
    "status": "active",
    "redirect": "/get-started",
    "onboarding": {
      "sampleDataGenerated": true,
      "tutorialAvailable": true,
      "nextStep": "/onboarding/welcome"
    }
  },
  "message": "User registered successfully"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | User registered — `data.status` is `active` or `waitlisted` depending on the ACCEPT_NEW_USERS gate |
| `400` | Invalid request data |
| `409` | A user with this email or username already exists (case-insensitive check) |
| `429` | Rate limit exceeded — too many requests in the current window. Wait for the window to reset (see Retry-After) before retrying. |
| `500` | Internal server error |

## Login user

```http
POST /v1/auth/login
```

Authenticate with email OR username plus password. On full success, returns a JWT
access token whose lifetime is role-based (`expiresIn` duration string: `15m` for
system_admin and default, `4h` for broker/team_lead, `8h` for agent) and sets a
`refreshToken` httpOnly cookie (default 30 days, `JWT_REFRESH_TOKEN_EXPIRY_DAYS`).

The 200 body has four possible `data` shapes: full login, TOTP challenge
(`requiresTOTP`), SMS challenge (`requiresSMS`), or waitlisted account.

### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string | No | User's email address (provide email OR username; case-insensitive) |
| `username` | string | No | Username (provide email OR username; case-insensitive) |
| `password` | string | Yes | Account password |

### Example request

```bash title="cURL"
curl -X POST "https://api.actuallycare.com/v1/auth/login" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "sarah.chen@gmail.com",
    "username": "sarahchen",
    "password": "YourPassword123!"
  }'
```

```javascript title="JavaScript"
const res = await fetch("https://api.actuallycare.com/v1/auth/login", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "email": "sarah.chen@gmail.com",
    "username": "sarahchen",
    "password": "YourPassword123!"
  }),
});
const data = await res.json();
```

```python title="Python"
import requests

resp = requests.post(
    "https://api.actuallycare.com/v1/auth/login",
    json={
        "email": "sarah.chen@gmail.com",
        "username": "sarahchen",
        "password": "YourPassword123!"
    },
)
data = resp.json()
```

### Example response (200)

```json
{
  "success": true,
  "data": {
    "user": {
      "id": "b7e9d2c4-5f1a-4e8b-9c3d-2a6f8e0b4d17",
      "email": "sarah.chen@gmail.com",
      "username": "sarahchen",
      "firstName": "Sarah",
      "lastName": "Chen",
      "role": [
        "agent"
      ],
      "isActive": true,
      "teamId": "3c1e8f5a-7d2b-4a9c-b6e4-0f8d2c5a7e91",
      "teamName": "Chen Home Team",
      "brokerageId": "9e4b2d7f-1a6c-4e8b-a3d5-6c0f9b2e4d78",
      "corporationName": "Golden Empire Realty",
      "scopeLevel": "team",
      "verticals": [
        "realtor"
      ],
      "managedTeams": []
    },
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImI3ZTlkMmM0LTVmMWEtNGU4Yi05YzNkLTJhNmY4ZTBiNGQxNyJ9.Qm4x8pT2cVhLk9wYzR0aXNlY3VyZQ",
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImI3ZTlkMmM0LTVmMWEtNGU4Yi05YzNkLTJhNmY4ZTBiNGQxNyJ9.Qm4x8pT2cVhLk9wYzR0aXNlY3VyZQ",
    "refreshToken": "9c2f8e4a1d6b3c5e7f0a2b4d6e8f1a3c5b7d9e0f2a4c6e8b1d3f5a7c9e0b2d4f",
    "expiresIn": "8h",
    "tokenType": "Bearer",
    "requiresTOTPSetup": false
  },
  "message": "Login successful"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `200` | Password verified — data is one of four shapes (full login, TOTP required, SMS required, waitlisted) |
| `401` | Invalid credentials (INVALID_CREDENTIALS) or disabled account (ACCOUNT_DISABLED) |
| `403` | Account suspended (ACCOUNT_SUSPENDED) |
| `423` | Account locked (ACCOUNT_LOCKED). The 5th consecutive failed attempt locks the account for 30 minutes; attempts made WHILE locked return this 423 with details.locked_until (the failure that triggers the lock itself returns 401). |
| `429` | Rate limit exceeded — too many requests in the current window. Wait for the window to reset (see Retry-After) before retrying. |
| `500` | Internal server error |

## Refresh access token

```http
POST /v1/auth/refresh
```

Exchanges the refresh token (httpOnly `refreshToken` cookie, or `refreshToken` in the request body for cookie-less clients) for a new access token. The refresh token is rotated on every call — the previous one is invalidated and the new one is set on the same httpOnly cookie. A device-fingerprint mismatch (different IP + user agent than the token was issued to) revokes the whole token family and returns 401 FINGERPRINT_MISMATCH.

### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `refreshToken` | string | No | Refresh token — only needed when the httpOnly cookie is unavailable (e.g. native clients) |

### Example request

```bash title="cURL"
curl -X POST "https://api.actuallycare.com/v1/auth/refresh" \
  -H "Content-Type: application/json" \
  -d '{
    "refreshToken": "your-refreshToken"
  }'
```

```javascript title="JavaScript"
const res = await fetch("https://api.actuallycare.com/v1/auth/refresh", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "refreshToken": "your-refreshToken"
  }),
});
const data = await res.json();
```

```python title="Python"
import requests

resp = requests.post(
    "https://api.actuallycare.com/v1/auth/refresh",
    json={
        "refreshToken": "your-refreshToken"
    },
)
data = resp.json()
```

### Example response (200)

```json
{
  "success": true,
  "data": {
    "accessToken": "example accessToken",
    "expiresIn": "8h",
    "refreshTokenExpiresIn": "30d"
  }
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `200` | Token refreshed (a rotated refreshToken cookie is also set) |
| `401` | Missing, invalid, expired, or revoked refresh token (NO_REFRESH_TOKEN, INVALID_REFRESH_TOKEN, or FINGERPRINT_MISMATCH) |
| `429` | Rate limit exceeded — too many requests in the current window. Wait for the window to reset (see Retry-After) before retrying. |
| `500` | Internal server error |

## Logout user

```http
POST /v1/auth/logout
```

Revokes the current refresh token (if present) and clears the refreshToken cookie. Succeeds even without a token — the endpoint requires no authentication and always returns 200 for a well-formed request.

### Example request

```bash title="cURL"
curl -X POST "https://api.actuallycare.com/v1/auth/logout"
```

```javascript title="JavaScript"
const res = await fetch("https://api.actuallycare.com/v1/auth/logout", {
  method: "POST",
});
const data = await res.json();
```

```python title="Python"
import requests

resp = requests.post(
    "https://api.actuallycare.com/v1/auth/logout",
)
data = resp.json()
```

### Example response (200)

```json
{
  "success": true,
  "message": "Logged out successfully"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `200` | Logged out successfully (refreshToken cookie cleared) |
| `429` | Rate limit exceeded — too many requests in the current window. Wait for the window to reset (see Retry-After) before retrying. |
| `500` | Internal server error |

## Verify token validity

```http
GET /v1/auth/verify
```

Checks if the JWT access token is valid. Returns the same full profile payload as GET /auth/profile (both are served by the same handler).

### Example request

```bash title="cURL"
curl "https://api.actuallycare.com/v1/auth/verify" \
  -H "Authorization: Bearer YOUR_JWT"
```

```javascript title="JavaScript"
const res = await fetch("https://api.actuallycare.com/v1/auth/verify", {
  headers: {
    "Authorization": "Bearer YOUR_JWT",
  },
});
const data = await res.json();
```

```python title="Python"
import requests

resp = requests.get(
    "https://api.actuallycare.com/v1/auth/verify",
    headers={"Authorization": "Bearer YOUR_JWT"},
)
data = resp.json()
```

### Example response (200)

```json
{
  "success": true,
  "data": {
    "user": {
      "id": "b7e9d2c4-5f1a-4e8b-9c3d-2a6f8e0b4d17",
      "email": "sarah.chen@gmail.com",
      "role": [
        "agent"
      ]
    }
  }
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `200` | Token is valid — body matches GET /auth/profile |
| `401` | Token missing, invalid, or expired |
| `429` | Rate limit exceeded — too many requests in the current window. Wait for the window to reset (see Retry-After) before retrying. |
| `500` | Internal server error |

## Get user profile

```http
GET /v1/auth/profile
```

Returns authenticated user's profile information

### Example request

```bash title="cURL"
curl "https://api.actuallycare.com/v1/auth/profile" \
  -H "Authorization: Bearer YOUR_JWT"
```

```javascript title="JavaScript"
const res = await fetch("https://api.actuallycare.com/v1/auth/profile", {
  headers: {
    "Authorization": "Bearer YOUR_JWT",
  },
});
const data = await res.json();
```

```python title="Python"
import requests

resp = requests.get(
    "https://api.actuallycare.com/v1/auth/profile",
    headers={"Authorization": "Bearer YOUR_JWT"},
)
data = resp.json()
```

### Example response (200)

```json
{
  "success": true,
  "data": {
    "user": {
      "id": "b7e9d2c4-5f1a-4e8b-9c3d-2a6f8e0b4d17",
      "email": "sarah.chen@gmail.com",
      "username": "sarahchen",
      "firstName": "Sarah",
      "lastName": "Chen",
      "role": [
        "agent"
      ],
      "isActive": true,
      "status": "active",
      "emailVerified": true,
      "licenseVerified": false,
      "lastLogin": "2026-07-15T14:32:10.000Z",
      "createdAt": "2026-07-15T14:32:10.000Z",
      "updatedAt": "2026-07-15T14:32:10.000Z",
      "timezone": "America/Los_Angeles",
      "teamId": "550e8400-e29b-41d4-a716-446655440000",
      "teamName": "example teamName",
      "brokerageId": "550e8400-e29b-41d4-a716-446655440000",
      "aiPlanTier": "free",
      "subscriptionTier": "free",
      "subscriptionStatus": "none",
      "hasStripeCustomer": false,
      "scopeLevel": "personal",
      "managedTeams": [
        {}
      ]
    }
  }
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `200` | Profile retrieved successfully (payload is nested under data.user) |
| `401` | Unauthorized |
| `429` | Rate limit exceeded — too many requests in the current window. Wait for the window to reset (see Retry-After) before retrying. |
| `500` | Internal server error |

## Update user profile

```http
PUT /v1/auth/profile
```

Updates authenticated user's profile information

### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `firstName` | string | No |  |
| `lastName` | string | No |  |
| `currentPassword` | string | No | Required when changing the password · Format: password |
| `newPassword` | string | No | New password — changing it revokes ALL refresh tokens (every session is logged out) · Format: password |
| `homeCity` | string | No |  |
| `homeState` | string | No |  |
| `homeZip` | string | No |  |
| `homeLat` | number | No |  |
| `homeLng` | number | No |  |
| `licensedStates` | array of strings | No |  |
| `searchRadiusMiles` | integer | No |  |
| `timezone` | string | No |  |

### Example request

```bash title="cURL"
curl -X PUT "https://api.actuallycare.com/v1/auth/profile" \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "firstName": "Sarah",
    "lastName": "Chen",
    "currentPassword": "your-currentPassword",
    "newPassword": "your-newPassword",
    "homeCity": "Bakersfield",
    "homeState": "CA",
    "homeZip": "93309",
    "homeLat": 35.3433,
    "homeLng": -119.0587,
    "licensedStates": [
      "CA"
    ],
    "searchRadiusMiles": 25,
    "timezone": "America/Los_Angeles"
  }'
```

```javascript title="JavaScript"
const res = await fetch("https://api.actuallycare.com/v1/auth/profile", {
  method: "PUT",
  headers: {
    "Authorization": "Bearer YOUR_JWT",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "firstName": "Sarah",
    "lastName": "Chen",
    "currentPassword": "your-currentPassword",
    "newPassword": "your-newPassword",
    "homeCity": "Bakersfield",
    "homeState": "CA",
    "homeZip": "93309",
    "homeLat": 35.3433,
    "homeLng": -119.0587,
    "licensedStates": [
      "CA"
    ],
    "searchRadiusMiles": 25,
    "timezone": "America/Los_Angeles"
  }),
});
const data = await res.json();
```

```python title="Python"
import requests

resp = requests.put(
    "https://api.actuallycare.com/v1/auth/profile",
    headers={"Authorization": "Bearer YOUR_JWT"},
    json={
        "firstName": "Sarah",
        "lastName": "Chen",
        "currentPassword": "your-currentPassword",
        "newPassword": "your-newPassword",
        "homeCity": "Bakersfield",
        "homeState": "CA",
        "homeZip": "93309",
        "homeLat": 35.3433,
        "homeLng": -119.0587,
        "licensedStates": [
            "CA"
        ],
        "searchRadiusMiles": 25,
        "timezone": "America/Los_Angeles"
    },
)
data = resp.json()
```

### Example response (200)

```json
{
  "success": true,
  "data": {
    "id": "b7e9d2c4-5f1a-4e8b-9c3d-2a6f8e0b4d17",
    "email": "sarah.chen@gmail.com",
    "firstName": "Sarah",
    "lastName": "Chen",
    "role": [
      "agent"
    ],
    "isActive": true,
    "home_city": "Bakersfield",
    "home_state": "CA",
    "home_zip": "93309",
    "home_lat": 35.3433,
    "home_lng": -119.0587,
    "licensed_states": [
      "CA"
    ],
    "search_radius_miles": 25
  },
  "message": "Profile updated successfully"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `200` | Profile updated successfully |
| `400` | Validation error (no fields to update, missing currentPassword, or incorrect current password) |
| `401` | Unauthorized |
| `429` | Rate limit exceeded — too many requests in the current window. Wait for the window to reset (see Retry-After) before retrying. |
| `500` | Internal server error |

## Logout from all devices

```http
POST /v1/auth/logout-all
```

Invalidates all refresh tokens for the authenticated user, logging them out from all sessions

### Example request

```bash title="cURL"
curl -X POST "https://api.actuallycare.com/v1/auth/logout-all" \
  -H "Authorization: Bearer YOUR_JWT"
```

```javascript title="JavaScript"
const res = await fetch("https://api.actuallycare.com/v1/auth/logout-all", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_JWT",
  },
});
const data = await res.json();
```

```python title="Python"
import requests

resp = requests.post(
    "https://api.actuallycare.com/v1/auth/logout-all",
    headers={"Authorization": "Bearer YOUR_JWT"},
)
data = resp.json()
```

### Example response (200)

```json
{
  "success": true,
  "message": "Logged out from all devices successfully"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `200` | Logged out from all devices successfully |
| `401` | Unauthorized |
| `429` | Rate limit exceeded — too many requests in the current window. Wait for the window to reset (see Retry-After) before retrying. |
| `500` | Internal server error |

## List active sessions

```http
GET /v1/auth/sessions
```

Returns list of active refresh tokens/sessions for authenticated user

### Example request

```bash title="cURL"
curl "https://api.actuallycare.com/v1/auth/sessions" \
  -H "Authorization: Bearer YOUR_JWT"
```

```javascript title="JavaScript"
const res = await fetch("https://api.actuallycare.com/v1/auth/sessions", {
  headers: {
    "Authorization": "Bearer YOUR_JWT",
  },
});
const data = await res.json();
```

```python title="Python"
import requests

resp = requests.get(
    "https://api.actuallycare.com/v1/auth/sessions",
    headers={"Authorization": "Bearer YOUR_JWT"},
)
data = resp.json()
```

### Example response (200)

```json
{
  "success": true,
  "data": {
    "sessions": [
      {
        "id": "3f8c1d7a-9e2b-4c6f-a1d8-7b4e2c9f5a30",
        "createdAt": "2026-06-28T17:03:22.481Z",
        "expiresAt": "2026-07-28T17:03:22.481Z",
        "ipAddress": "203.0.113.42",
        "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
        "deviceInfo": {},
        "isCurrent": true
      }
    ]
  }
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `200` | Sessions retrieved successfully (array is nested under data.sessions) |
| `401` | Unauthorized |
| `429` | Rate limit exceeded — too many requests in the current window. Wait for the window to reset (see Retry-After) before retrying. |
| `500` | Internal server error |
