ClouisleClouisle

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:

PermissionPurpose
admin:user:readList, detail, user statistics, password expiration statistics, and the expiring-password list
admin:user:createCreate users
admin:user:updateUpdate users, activate/deactivate, bulk email, all password lifecycle operations
admin:user:deleteDelete users

Superusers bypass the permission-code check. Insufficient permission returns HTTP 403 / 3000.

Current-User Endpoints

Get the current user

GET /api/v1/users/me
curl -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
FieldTypeRequiredDescription
usernamestringNoNew username, globally unique
emailstringNoNew email, globally unique; when email verification is enabled a code is required in the same request
email_verification_codestringNoVerification code for the email change; validated only, never stored on the profile
avatar_urlstringNoAvatar URL
localestringNoUI 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:

HTTPCodeScenario
4005002Username already taken
4005003Email already taken
4005005Incorrect 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
FieldTypeRequiredDescription
current_passwordstringYesCurrent password
new_passwordstringYesNew 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:

HTTPCodeScenario
4002003Current password is incorrect
4005306Minimum password age (password_min_age_days) not reached; cannot change yet
4001001New 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-status

Use 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"
}
FieldTypeDescription
is_exemptbooleanWhether the account is exempt from the expiry policy
password_changed_atstring | nullLast change time (ISO 8601)
password_expires_atstring | nullComputed expiry time; null when the expiry policy is disabled
is_expiredbooleanWhether the password has expired
days_until_expirationinteger | nullDays until expiry; null when there is no expiry time
force_change_requiredbooleanWhether an administrator forced a password change

Delete your own account

DELETE /api/v1/users/me

The 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!"}'
HTTPCodeScenario
4002003Password is incorrect
4003000Self-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
ParameterTypeDefaultDescription
pageinteger1Page number
page_sizeinteger20Items per page
statusstring[]—active (is_active = true), inactive (deactivated and approved), pending (deactivated and awaiting approval); repeatable
searchstring—Case-insensitive match on username or email
rolestring[]—Filter by role name (repeatable, de-duplicated)
exclude_user_idUUID[]—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/stats

Requires 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
FieldTypeRequiredDefaultDescription
usernamestringYes—Username, must be unique
emailstringYes—Email, must be unique
passwordstringYes—Initial password, validated against the password policy
is_activebooleanNotrueWhether the account starts enabled
is_superuserbooleanNofalseWhether the account is a superuser
avatar_urlstringNonullAvatar URL
localestringNoSite default languageUI 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:

FieldTypeDescription
emailstringNew email
passwordstringReset the password (validated against the policy, but history is not checked)
is_activebooleanEnable or deactivate
avatar_urlstringAvatar URL
localestringUI language
rolesstring[]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}/deactivate

Both return the updated user object. Calling either in the current state is an error:

HTTPCodeScenario
4005212activate on an already-active user
4005213deactivate on an already-inactive user
4005214Attempting to deactivate a superuser
4044001User not found

Send bulk email

POST /api/v1/admin/users/send-email

Requires admin:user:update and an enabled SMTP configuration.

FieldTypeRequiredDescription
subjectstringYesEmail subject
contentstringYesEmail body (plain text, rendered with line breaks into the HTML template)
user_idsUUID[]YesRecipient 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"
}
HTTPCodeScenario
4005007SMTP is not configured
4005400Not enough sending quota; data is {"limit": 100, "period": "hour"} or {"requested": 3, "remaining": 1}
4004000No 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-change

Sets 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-expiration

Recomputes 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-stats

Requires 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=expiring

Requires admin:user:read.

ParameterTypeDefaultDescription
filterstringallOne of all, expired, expiring, force_change
pageinteger1Page number, ≥ 1
page_sizeinteger20Items 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

HTTPCodeScenario
4001001Request validation failed (password policy, empty user_ids)
4002003Current password is wrong (change password, self-deletion)
4032003JWT cannot be decoded or has expired
4003000Self-service deletion is disabled, or deleting your own superuser account
4033000Missing an admin:user:* permission, or a role replacement that would strip team:manage from a team administrator
4044001User not found
4005002Username already taken
4005003Email already taken
4005005Invalid verification code for the email change
4005007SMTP is not configured during bulk email
4005201Superusers cannot be deleted
4005212User is already active
4005213User is already inactive
4005214Superusers cannot be deactivated
4005306Minimum password age not reached
4005400Insufficient 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-status to warn about an imminent expiry instead of forcing a redirect at login.
  • Before allowing self-deletion, confirm the deployment's allow_account_deletion setting; 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:manage constraint.
  • Check user_ids before 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);

How is this guide?

On this page