Authentication API
REST endpoints for authentication — request and response reference with examples.
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 instead.
Base URL: https://api.actuallycare.com/v1 · Errors use the standard envelope — see Errors.
Register new user#
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) |
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 |
Example request#
curl -X POST "https://api.actuallycare.com/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"username": "sarahchen",
"password": "your-password",
"firstName": "Sarah",
"lastName": "Chen",
"phone": "(661) 555-0142"
}'const res = await fetch("https://api.actuallycare.com/v1/auth/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
"email": "[email protected]",
"username": "sarahchen",
"password": "your-password",
"firstName": "Sarah",
"lastName": "Chen",
"phone": "(661) 555-0142"
}),
});
const data = await res.json();import requests
resp = requests.post(
"https://api.actuallycare.com/v1/auth/register",
json={
"email": "[email protected]",
"username": "sarahchen",
"password": "your-password",
"firstName": "Sarah",
"lastName": "Chen",
"phone": "(661) 555-0142"
},
)
data = resp.json()Example response (201)#
{
"success": true,
"data": {
"user": {
"id": "b7e9d2c4-5f1a-4e8b-9c3d-2a6f8e0b4d17",
"email": "[email protected]",
"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"
}Login user#
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 |
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 |
Example request#
curl -X POST "https://api.actuallycare.com/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"username": "sarahchen",
"password": "YourPassword123!"
}'const res = await fetch("https://api.actuallycare.com/v1/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
"email": "[email protected]",
"username": "sarahchen",
"password": "YourPassword123!"
}),
});
const data = await res.json();import requests
resp = requests.post(
"https://api.actuallycare.com/v1/auth/login",
json={
"email": "[email protected]",
"username": "sarahchen",
"password": "YourPassword123!"
},
)
data = resp.json()Example response (200)#
{
"success": true,
"data": {
"user": {
"id": "b7e9d2c4-5f1a-4e8b-9c3d-2a6f8e0b4d17",
"email": "[email protected]",
"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"
}Refresh access token#
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) |
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 |
Example request#
curl -X POST "https://api.actuallycare.com/v1/auth/refresh" \
-H "Content-Type: application/json" \
-d '{
"refreshToken": "your-refreshToken"
}'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();import requests
resp = requests.post(
"https://api.actuallycare.com/v1/auth/refresh",
json={
"refreshToken": "your-refreshToken"
},
)
data = resp.json()Example response (200)#
{
"success": true,
"data": {
"accessToken": "example accessToken",
"expiresIn": "8h",
"refreshTokenExpiresIn": "30d"
}
}Logout user#
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.
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 |
Example request#
curl -X POST "https://api.actuallycare.com/v1/auth/logout"const res = await fetch("https://api.actuallycare.com/v1/auth/logout", {
method: "POST",
});
const data = await res.json();import requests
resp = requests.post(
"https://api.actuallycare.com/v1/auth/logout",
)
data = resp.json()Example response (200)#
{
"success": true,
"message": "Logged out successfully"
}Verify token validity#
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).
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 |
Example request#
curl "https://api.actuallycare.com/v1/auth/verify" \
-H "Authorization: Bearer YOUR_JWT"const res = await fetch("https://api.actuallycare.com/v1/auth/verify", {
headers: {
"Authorization": "Bearer YOUR_JWT",
},
});
const data = await res.json();import requests
resp = requests.get(
"https://api.actuallycare.com/v1/auth/verify",
headers={"Authorization": "Bearer YOUR_JWT"},
)
data = resp.json()Example response (200)#
{
"success": true,
"data": {
"user": {
"id": "b7e9d2c4-5f1a-4e8b-9c3d-2a6f8e0b4d17",
"email": "[email protected]",
"role": [
"agent"
]
}
}
}Get user profile#
Returns authenticated user's profile information
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 |
Example request#
curl "https://api.actuallycare.com/v1/auth/profile" \
-H "Authorization: Bearer YOUR_JWT"const res = await fetch("https://api.actuallycare.com/v1/auth/profile", {
headers: {
"Authorization": "Bearer YOUR_JWT",
},
});
const data = await res.json();import requests
resp = requests.get(
"https://api.actuallycare.com/v1/auth/profile",
headers={"Authorization": "Bearer YOUR_JWT"},
)
data = resp.json()Example response (200)#
{
"success": true,
"data": {
"user": {
"id": "b7e9d2c4-5f1a-4e8b-9c3d-2a6f8e0b4d17",
"email": "[email protected]",
"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": [
{}
]
}
}
}Update user 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 |
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 |
Example request#
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"
}'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();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)#
{
"success": true,
"data": {
"id": "b7e9d2c4-5f1a-4e8b-9c3d-2a6f8e0b4d17",
"email": "[email protected]",
"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"
}Logout from all devices#
Invalidates all refresh tokens for the authenticated user, logging them out from all sessions
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 |
Example request#
curl -X POST "https://api.actuallycare.com/v1/auth/logout-all" \
-H "Authorization: Bearer YOUR_JWT"const res = await fetch("https://api.actuallycare.com/v1/auth/logout-all", {
method: "POST",
headers: {
"Authorization": "Bearer YOUR_JWT",
},
});
const data = await res.json();import requests
resp = requests.post(
"https://api.actuallycare.com/v1/auth/logout-all",
headers={"Authorization": "Bearer YOUR_JWT"},
)
data = resp.json()Example response (200)#
{
"success": true,
"message": "Logged out from all devices successfully"
}List active sessions#
Returns list of active refresh tokens/sessions for authenticated user
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 |
Example request#
curl "https://api.actuallycare.com/v1/auth/sessions" \
-H "Authorization: Bearer YOUR_JWT"const res = await fetch("https://api.actuallycare.com/v1/auth/sessions", {
headers: {
"Authorization": "Bearer YOUR_JWT",
},
});
const data = await res.json();import requests
resp = requests.get(
"https://api.actuallycare.com/v1/auth/sessions",
headers={"Authorization": "Bearer YOUR_JWT"},
)
data = resp.json()Example response (200)#
{
"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
}
]
}
}