Users API
Manage your own profile and password, plus administrator user CRUD, email, and password lifecycle
The Users API splits into two groups: self-service for the current user (/api/v1/users/me/*, any authenticated user) and administrator user management (/api/v1/admin/users/*, requiring admin:user:* permission codes).
Overview
- Current user:
GET|PUT /api/v1/users/me,POST /api/v1/users/me/change-password,GET /api/v1/users/me/password-status,DELETE /api/v1/users/me - Administrators: list, statistics, detail, create, update, delete, activate/deactivate, bulk email, and password lifecycle endpoints under
/api/v1/admin/users
Authentication
All endpoints use Authorization: Bearer <JWT>. API keys cannot be used on user-management endpoints.
Administrator endpoints additionally require these permission codes:
| Permission | Purpose |
|---|---|
admin:user:read | List, detail, user statistics, password expiration statistics, and the expiring-password list |
admin:user:create | Create users |
admin:user:update | Update users, activate/deactivate, bulk email, all password lifecycle operations |
admin:user:delete | Delete users |
Superusers bypass the permission-code check. Insufficient permission returns HTTP 403 / 3000.
Current-User Endpoints
Get the current user
GET /api/v1/users/mecurl -X GET "https://your-domain.com/api/v1/users/me" \
-H "Authorization: Bearer YOUR_TOKEN"Response (200 OK):
{
"code": 0,
"data": {
"id": "3f2a1b0c-9d8e-4f7a-8b6c-5d4e3f2a1b0c",
"username": "johndoe",
"email": "john.doe@example.com",
"is_active": true,
"approval_status": "approved",
"is_superuser": false,
"email_verified": true,
"avatar_url": "https://example.com/avatars/johndoe.png",
"locale": "en",
"created_at": "2026-01-15T10:00:00Z",
"last_login": "2026-09-24T14:30:00Z",
"auth_source": "local",
"external_id": null,
"force_password_change": false,
"password_expiration_exempt": false,
"status": "active",
"roles": [
{
"id": "9d8e4f7a-8b6c-4d5e-a1f2-3b4c5d6e7f80",
"name": "user",
"description": null,
"is_system_role": true,
"permissions": []
}
],
"sso_connections": [
{
"id": "4d5e6f70-1a2b-4c3d-9e8f-7a6b5c4d3e2f",
"provider_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"provider_name": "github",
"provider_display_name": "GitHub",
"provider_icon_url": null,
"provider_user_id": "12345678",
"provider_username": "johndoe",
"provider_email": "john.doe@example.com",
"first_login": "2026-02-01T08:00:00Z",
"last_login": "2026-09-24T14:30:00Z"
}
]
},
"msg": "success"
}The status field in this response is the response model's default value — these endpoints always return "active" and it does not reflect the real account state. Use is_active (enabled or not) combined with approval_status (approved / pending) instead; front-end lists derive the active / inactive / pending buckets from those two fields.
Update the current user
PUT /api/v1/users/me| Field | Type | Required | Description |
|---|---|---|---|
username | string | No | New username, globally unique |
email | string | No | New email, globally unique; when email verification is enabled a code is required in the same request |
email_verification_code | string | No | Verification code for the email change; validated only, never stored on the profile |
avatar_url | string | No | Avatar URL |
locale | string | No | UI language, for example en or zh |
curl -X PUT "https://your-domain.com/api/v1/users/me" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"username": "johnsmith", "locale": "en"}'Failure paths:
| HTTP | Code | Scenario |
|---|---|---|
400 | 5002 | Username already taken |
400 | 5003 | Email already taken |
400 | 5005 | Incorrect verification code for the email change (when email verification is enabled) |
The response returns the full updated user object (same shape as “Get the current user”) with msg set to Profile updated successfully.
Changing the email does not silently refresh email_verified: when email verification is enabled you must complete the code check before submitting the new address, otherwise the next login can be blocked by 5004 (EMAIL_NOT_VERIFIED).
Change the password
POST /api/v1/users/me/change-password| Field | Type | Required | Description |
|---|---|---|---|
current_password | string | Yes | Current password |
new_password | string | Yes | New password |
curl -X POST "https://your-domain.com/api/v1/users/me/change-password" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"current_password": "OldPassword123!", "new_password": "NewPassword456!"}'A successful change also clears the force_password_change flag, records the new password-change timestamp, and sends a security notification.
Failure paths:
| HTTP | Code | Scenario |
|---|---|---|
400 | 2003 | Current password is incorrect |
400 | 5306 | Minimum password age (password_min_age_days) not reached; cannot change yet |
400 | 1001 | New password violates the policy; data.errors.new_password lists the reasons |
The password policy comes from site settings (defaults): at least min_password_length = 8 characters, at least one uppercase letter (require_uppercase = true), at least one digit (require_number = true), and no special-character requirement by default (require_special_char = false). A change is also compared against the last password_history_count = 5 passwords and rejected with password_recently_used on a match.
Response (200 OK):
{ "code": 0, "data": null, "msg": "Password changed successfully" }Check password status
GET /api/v1/users/me/password-statusUse this on the account-security screen to warn about an imminent expiry instead of forcing a redirect at login time.
{
"code": 0,
"data": {
"is_exempt": false,
"password_changed_at": "2026-06-01T08:00:00+00:00",
"password_expires_at": "2026-08-30T08:00:00+00:00",
"is_expired": false,
"days_until_expiration": 12,
"force_change_required": false
},
"msg": "success"
}| Field | Type | Description |
|---|---|---|
is_exempt | boolean | Whether the account is exempt from the expiry policy |
password_changed_at | string | null | Last change time (ISO 8601) |
password_expires_at | string | null | Computed expiry time; null when the expiry policy is disabled |
is_expired | boolean | Whether the password has expired |
days_until_expiration | integer | null | Days until expiry; null when there is no expiry time |
force_change_required | boolean | Whether an administrator forced a password change |
Delete your own account
DELETE /api/v1/users/meThe body requires the password as a second confirmation:
{ "password": "CurrentPassword123!" }curl -X DELETE "https://your-domain.com/api/v1/users/me" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"password": "CurrentPassword123!"}'| HTTP | Code | Scenario |
|---|---|---|
400 | 2003 | Password is incorrect |
400 | 3000 | Self-service deletion is disabled (allow_account_deletion = false), or the account is a superuser |
Deletion is irreversible. Success returns {"code": 0, "data": null, "msg": "Account deleted successfully"} and the account's tokens and API keys stop working. To keep the account for auditing, deactivate it instead.
Administrator Endpoints
List users
GET /api/v1/admin/users| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number |
page_size | integer | 20 | Items per page |
status | string[] | — | active (is_active = true), inactive (deactivated and approved), pending (deactivated and awaiting approval); repeatable |
search | string | — | Case-insensitive match on username or email |
role | string[] | — | Filter by role name (repeatable, de-duplicated) |
exclude_user_id | UUID[] | — | Exclude specific users (repeatable) |
curl -X GET "https://your-domain.com/api/v1/admin/users?page=1&page_size=20&status=pending" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"The paginated body is { items, total, page, page_size }, and each item matches “Get the current user”.
User statistics
GET /api/v1/admin/users/statsRequires admin:user:read:
{
"code": 0,
"data": { "total": 156, "active": 150, "inactive": 2, "pending": 4 },
"msg": "success"
}total counts every user while active counts only is_active = true; both inactive and pending require is_active = false and are split by approval_status.
Get user detail
GET /api/v1/admin/users/{user_id}Returns a single user object; a missing user returns HTTP 404 / 4001.
Create a user
POST /api/v1/admin/users| Field | Type | Required | Default | Description |
|---|---|---|---|---|
username | string | Yes | — | Username, must be unique |
email | string | Yes | — | Email, must be unique |
password | string | Yes | — | Initial password, validated against the password policy |
is_active | boolean | No | true | Whether the account starts enabled |
is_superuser | boolean | No | false | Whether the account is a superuser |
avatar_url | string | No | null | Avatar URL |
locale | string | No | Site default language | UI language |
curl -X POST "https://your-domain.com/api/v1/admin/users" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"username": "alice", "email": "alice@example.com", "password": "SecurePassword123"}'Users created by an administrator are always approval_status = "approved": an approval_status in the request is ignored. If you want the new account to wait for approval, use the open registration flow instead. terms_accepted, captcha_id, and captcha_token are registration-endpoint fields — do not send them here.
Failure paths: duplicate username HTTP 400 / 5002; duplicate email HTTP 400 / 5003; password outside the policy HTTP 400 / 1001.
A successful creation returns the full user object (with an empty roles array) and writes an audit log entry.
Update a user
PUT /api/v1/admin/users/{user_id}All fields are optional:
| Field | Type | Description |
|---|---|---|
email | string | New email |
password | string | Reset the password (validated against the policy, but history is not checked) |
is_active | boolean | Enable or deactivate |
avatar_url | string | Avatar URL |
locale | string | UI language |
roles | string[] | Role names, replacing the current set entirely |
Administrators cannot change username, is_superuser, or approval_status here — those fields are not part of the request model and any values sent are ignored. The username can only be changed by the user through PUT /users/me.
Role replacement has one safety constraint: if the target user is currently an owner or admin of any team and the new role set contains no role carrying the team:manage permission, the request is rejected with HTTP 403 / 3000 (the permission field is team:manage). This prevents leaving a team with nobody able to manage it.
Delete a user
DELETE /api/v1/admin/users/{user_id}- Missing user: HTTP
404/4001 - Target is a superuser: HTTP
400/5201(CANNOT_DELETE_SUPERUSER)
data in the response is the deleted user object (returned for audit echo), not null.
Activate / deactivate a user
POST /api/v1/admin/users/{user_id}/activate
POST /api/v1/admin/users/{user_id}/deactivateBoth return the updated user object. Calling either in the current state is an error:
| HTTP | Code | Scenario |
|---|---|---|
400 | 5212 | activate on an already-active user |
400 | 5213 | deactivate on an already-inactive user |
400 | 5214 | Attempting to deactivate a superuser |
404 | 4001 | User not found |
Send bulk email
POST /api/v1/admin/users/send-emailRequires admin:user:update and an enabled SMTP configuration.
| Field | Type | Required | Description |
|---|---|---|---|
subject | string | Yes | Email subject |
content | string | Yes | Email body (plain text, rendered with line breaks into the HTML template) |
user_ids | UUID[] | Yes | Recipient user list |
curl -X POST "https://your-domain.com/api/v1/admin/users/send-email" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"subject": "Maintenance notice", "content": "The system is down on Saturday.", "user_ids": ["3f2a1b0c-9d8e-4f7a-8b6c-5d4e3f2a1b0c"]}'Quotas and throttling: up to 100 emails per hour per administrator, and up to 5 per day to the same recipient address. Over-quota recipients are skipped rather than failing the whole batch.
{
"code": 0,
"data": { "sent_count": 1, "skipped_count": 0, "total": 1 },
"msg": "Emails have been queued for sending"
}| HTTP | Code | Scenario |
|---|---|---|
400 | 5007 | SMTP is not configured |
400 | 5400 | Not enough sending quota; data is {"limit": 100, "period": "hour"} or {"requested": 3, "remaining": 1} |
400 | 4000 | No valid user in user_ids |
Sending happens in a background task, so the response only means “queued”, not “delivered”.
Password Lifecycle Management (Administrators)
There is no dedicated “administrator resets password” endpoint. To reset a password, set the password field on PUT /api/v1/admin/users/{user_id}. The endpoints below manage the password expiry policy and require admin:user:update unless noted.
Force a password change
POST /api/v1/admin/users/{user_id}/force-password-changeSets force_password_change, so the next login asks for a new password (login still returns code: 0 but with force_password_change: true and a reason). Responds 200 OK with data: null.
Reset the password expiration
POST /api/v1/admin/users/{user_id}/reset-password-expirationRecomputes the expiry from the current time — effectively “confirm this user just changed the password”. Responds 200 OK with data: null.
Set the expiry exemption
POST /api/v1/admin/users/{user_id}/exempt-password-expiration{ "exempt": true }Useful for service accounts that should not be forced to rotate. Responds 200 OK with data: null.
Bulk force password change
POST /api/v1/admin/users/bulk-force-password-change{ "user_ids": ["3f2a1b0c-...", "9d8e4f7a-..."] }data.count is the number of users actually updated. An empty user_ids returns 400 / 1001; when none of the IDs exist the response is 400 / 4001.
Password expiration statistics
GET /api/v1/admin/users/password-expiration-statsRequires admin:user:read. The response contains total_users, expired_count, expiring_soon_count, force_change_count, and exempt_count.
When the deployment has the expiry policy disabled (password_expiration_enabled = false), this endpoint returns all zeros without computing anything. Front-end code should also check the policy switch so that a zero is not read as “nobody is expiring”. The statistics cover only auth_source = "local" accounts; SSO accounts are outside the expiry policy.
List users with expiring passwords
GET /api/v1/admin/users/expiring-passwords?page=1&page_size=20&filter=expiringRequires admin:user:read.
| Parameter | Type | Default | Description |
|---|---|---|---|
filter | string | all | One of all, expired, expiring, force_change |
page | integer | 1 | Page number, ≥ 1 |
page_size | integer | 20 | Items per page, 1–100 |
Route order matters: /api/v1/admin/users/stats, /send-email, /bulk-force-password-change, /password-expiration-stats, and /expiring-passwords are fixed paths that must appear before /{user_id}, otherwise they would be parsed as user IDs. SDKs handle this for you; when hand-writing paths, keep the fixed segments.
Error Codes
| HTTP | Code | Scenario |
|---|---|---|
400 | 1001 | Request validation failed (password policy, empty user_ids) |
400 | 2003 | Current password is wrong (change password, self-deletion) |
403 | 2003 | JWT cannot be decoded or has expired |
400 | 3000 | Self-service deletion is disabled, or deleting your own superuser account |
403 | 3000 | Missing an admin:user:* permission, or a role replacement that would strip team:manage from a team administrator |
404 | 4001 | User not found |
400 | 5002 | Username already taken |
400 | 5003 | Email already taken |
400 | 5005 | Invalid verification code for the email change |
400 | 5007 | SMTP is not configured during bulk email |
400 | 5201 | Superusers cannot be deleted |
400 | 5212 | User is already active |
400 | 5213 | User is already inactive |
400 | 5214 | Superusers cannot be deactivated |
400 | 5306 | Minimum password age not reached |
400 | 5400 | Insufficient email sending quota |
Best Practices
Profile and security
- Complete the verification code before changing an email, so the next login is not blocked by
5004. - Use
GET /me/password-statusto warn about an imminent expiry instead of forcing a redirect at login. - Before allowing self-deletion, confirm the deployment's
allow_account_deletionsetting; enterprise deployments usually disable it and deactivate accounts instead.
Do not share accounts, use weak passwords, skip email verification, or run day-to-day work from a superuser account.
Administrator operations
- Use strong initial passwords and enable “force password change on first login” where appropriate.
- Prefer deactivation over deletion: deactivation is reversible, while deletion also drops audit associations.
- Before changing roles, confirm whether the target user manages a team, to avoid tripping the
team:manageconstraint. - Check
user_idsbefore bulk email, and remember the 5-per-recipient-per-day cap.
Code Examples
Python
import os
import requests
BASE = "https://your-domain.com/api/v1"
def get_password_status(token: str) -> dict:
"""Check password status so you can warn before expiry."""
response = requests.get(
f"{BASE}/users/me/password-status",
headers={"Authorization": f"Bearer {token}"},
)
result = response.json()
if result["code"] != 0:
raise RuntimeError(f"{result['code']} {result['msg']}")
return result["data"]
def change_password(token: str, current_password: str, new_password: str) -> None:
"""Change the current user's password."""
response = requests.post(
f"{BASE}/users/me/change-password",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"current_password": current_password, "new_password": new_password},
)
result = response.json()
if result["code"] != 0:
# on 1001, data.errors.new_password lists the policy reasons
raise RuntimeError(f"{result['code']} {result['msg']} {result.get('data')}")
def bulk_send_email(admin_token: str, subject: str, content: str, user_ids: list[str]) -> dict:
"""Send bulk email as an administrator; returns queue statistics."""
response = requests.post(
f"{BASE}/admin/users/send-email",
headers={"Authorization": f"Bearer {admin_token}", "Content-Type": "application/json"},
json={"subject": subject, "content": content, "user_ids": user_ids},
)
result = response.json()
if result["code"] != 0:
raise RuntimeError(f"{result['code']} {result['msg']}")
return result["data"]
# Usage
token = os.environ["USER_TOKEN"]
status = get_password_status(token)
print(f"password expires in {status['days_until_expiration']} days")JavaScript
const BASE = 'https://your-domain.com/api/v1';
async function getCurrentUser(token) {
const response = await fetch(`${BASE}/users/me`, {
headers: { Authorization: `Bearer ${token}` },
});
const result = await response.json();
if (result.code !== 0) throw new Error(`${result.code} ${result.msg}`);
return result.data;
}
async function updateProfile(token, { username, locale, avatarUrl } = {}) {
const response = await fetch(`${BASE}/users/me`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, locale, avatar_url: avatarUrl }),
});
const result = await response.json();
if (result.code !== 0) throw new Error(`${result.code} ${result.msg}`);
return result.data;
}
async function setUserActive(adminToken, userId, active) {
const action = active ? 'activate' : 'deactivate';
const response = await fetch(`${BASE}/admin/users/${userId}/${action}`, {
method: 'POST',
headers: { Authorization: `Bearer ${adminToken}` },
});
const result = await response.json();
if (result.code !== 0) throw new Error(`${result.code} ${result.msg}`);
return result.data;
}
// Usage
const token = process.env.USER_TOKEN;
const user = await getCurrentUser(token);
console.log('user:', user.username);Related Documentation
- Authentication & Login API — login, registration, email verification, and password recovery
- Teams API — team member roles and management
- API Errors and Retries — unified handling for
4001,5002,5003, and5212-5214
How is this guide?