---
title: User API
tags: [api, user, reference]
---

# User API

> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))

Two routers are mounted for users:

- `/api/user/*` - the new controller pattern in [`backend/src/services/user/userControllerRoutes.ts`](../../backend/src/services/user/userControllerRoutes.ts) wired to `userController`.
- `/api/users/*` - the legacy router in [`backend/src/services/user/userRoutes.ts`](../../backend/src/services/user/userRoutes.ts) (kept for backward compatibility, primarily used by the admin console).

Address-book CRUD lives on its own service: [`/api/addresses/*`](../../backend/src/services/address/addressRoutes.ts). All endpoints require `Bearer JWT` unless noted. Source of truth model: [[User]].

## Profile (current user)

### GET /api/user/profile

**Description:** Returns the caller's profile.
**Auth required:** Bearer JWT
**Response 200:**
```json
{ "success": true, "data": { /* User without password / verification tokens */ } }
```
**Source:** `userController.getCurrentUserProfile`

### PUT /api/user/profile

**Description:** Updates the caller's profile.
**Auth required:** Bearer JWT
**Request body (whitelisted fields):**
```ts
{
  firstName?: string;
  lastName?: string;
  name?: string;            // alias for profile.name
  phone?: string;
  bio?: string;
  website?: string;
  photoURL?: string;        // also mirrored to profile.avatar
  isPublic?: boolean;
  address?: {
    street?: string;
    city?: string;
    state?: string;
    country?: string;
    postalCode?: string;
  };
  preferences?: {
    language?: "en" | "fa" | "ar";
    currency?: "USD" | "EUR" | "IRR" | "AED";
    notifications?: { email?: boolean; sms?: boolean; push?: boolean };
  };
}
```
**Response 200:** Updated user.

### GET /api/users/profile

**Description:** Legacy equivalent of the above. Returns the full sanitized user document.
**Auth required:** Bearer JWT

### GET /api/users/profile/:userId

**Description:** Public profile by id. If `profile.isPublic === false` and caller is not the owner or admin, only `firstName`, `lastName`, `avatar`, `role` are returned.
**Auth required:** Bearer JWT

## Avatar upload

Avatar upload is handled by the [[File API]]:

`POST /api/files/upload/avatar` (multipart `avatar`) returns a URL that the caller then writes to `profile.avatar` via `PUT /api/user/profile`.

## Wallet address

### GET /api/user/wallet-address

**Description:** Returns the caller's stored wallet address plus its chain type and provider (each `null` if unset).
**Auth required:** Bearer JWT
**Response 200:**
```json
{
  "success": true,
  "data": {
    "walletAddress": "0x..." ,   // or null
    "walletType": "evm" ,        // "evm" | "ton" | null  (the chain family)
    "walletProvider": "evm"      // e.g. "evm" | "telegram-wallet" | null
  }
}
```
(Earlier docs listed only `walletAddress`; the endpoint also returns `walletType` and `walletProvider`.)

### PATCH /api/user/wallet-address

**Description:** Stores a verified wallet address. Supports **both EVM and TON**:
- **EVM** (`walletType` omitted or not `'ton'`): the address must pass `ethers.isAddress`, and the body must include `signature` + `message`. The server runs `ethers.verifyMessage(message, signature)` (EIP-191) and rejects if the recovered address does not match.
- **TON** (`walletType: 'ton'`): the address is validated against a TON address regex. An optional `tonProof` payload is verified via `verifyTonProof`; if valid, `profile.walletProofVerified` is set to `true` and `profile.walletProofTimestamp` is stamped.

On success the server writes `profile.walletAddress`, `profile.walletType` (`'evm'` or `'ton'`), `profile.walletProvider`, and `profile.walletProofVerified`.
**Auth required:** Bearer JWT
**Request body:**
```ts
{
  walletAddress: string;        // EVM 0x-address, or TON address
  walletType?: "evm" | "ton";   // defaults to "evm"
  walletProvider?: string;      // defaults to "telegram-wallet" for ton, "evm" otherwise
  // EVM only:
  signature?: string;           // required for EVM — signed `message`
  message?: string;             // required for EVM — human-readable challenge text
  // TON only:
  tonProof?: TonProofPayload;   // optional; when valid sets walletProofVerified=true
}
```
**Response 200:** `{ "success": true, "data": { "user": { /* sanitized user */ }, "walletProofVerified": boolean } }`
**Errors:**
- `400` missing/invalid fields, malformed address, EVM signature mismatch, invalid TON proof
- `404` user not found

The legacy alias `PATCH /api/users/wallet-address` performs the same logic.

### POST /api/user/wallet-address/ton-proof/challenge

**Description:** Generates a TON proof nonce/challenge for TON wallet address verification. The returned challenge is then signed by the client and submitted for verification.
**Auth required:** Bearer JWT
**Response 200:** `{ "success": true, "data": { /* challenge/nonce payload */ } }`
**Source:** Backend implements this endpoint for TON proof nonce generation.

## Email verification

### POST /api/user/profile/email/verify

**Description:** Re-verifies the caller's email address after an email change using a 6-digit code sent to the new address.
**Auth required:** Bearer JWT
**Request body:**
```ts
{
  code: string;  // 6-digit verification code
}
```
**Response 200:** `{ "success": true, "data": { /* updated user */ } }`
**Source:** `axios.ts` defines this endpoint; used after email change flow.

### POST /api/user/profile/email/resend-verification

**Description:** Resends the 6-digit email verification code to the caller's (new) email address.
**Auth required:** Bearer JWT
**Response 200:** `{ "success": true }`
**Source:** `axios.ts` defines this endpoint; used in email change / re-verification flow.

## Contacts and search

### GET /api/users/contacts

**Description:** Returns the users the caller is allowed to chat with based on role:
- `buyer` → sees `seller` + `admin`
- `seller` → sees `buyer` + `admin`
- `admin` → sees everyone
**Auth required:** Bearer JWT
**Response 200:** `{ "success": true, "data": { "contacts": [...], "count": N } }`

### GET /api/users/search?q=<term>&role=<role>

**Description:** Case-insensitive search across `firstName`/`lastName`/`email`. Returns up to 20 active users.
**Auth required:** Bearer JWT
**Errors:** `400` if `q.length < 2`.

### GET /api/users?role=...&isActive=true&search=...&page=1&limit=50

**Description:** Paginated user directory (legacy, no admin gate). See pagination conventions in [[API Overview]].
**Auth required:** Bearer JWT

## Admin: user management

> **Note on the two admin route groups (prefix inconsistency).** There are TWO parallel admin route groups:
> - **Singular `/api/user/admin/*`** — the NEW controller (`userControllerRoutes.ts` → `userController`). This is where create / delete / status / role / list / dependencies are actually *registered* on the new controller.
> - **Plural `/api/users/admin/*`** — the LEGACY router (`userRoutes.ts`), which also mounts admin sub-routes (status, role, password, single-user fetch/update, resend-verification, stats).
>
> ⚠️ **The frontend consistently calls the PLURAL `/api/users/admin/*`** (see `frontend/src/lib/axios.ts`, all paths under `endpoints.users.admin.*`). So the singular create/delete/status/role/list paths below are *documented*, but in practice the frontend hits the legacy plural group. Both are listed; treat the plural group as the frontend-effective reality.
>
> ⚠️ **Note on HTTP verbs (KNOWN BUG):** The frontend `updateUserStatus` and `updateUserRole` calls (`frontend/src/actions/user.ts`) use **`PUT`** (`PUT /api/users/admin/:id/status`, `PUT /api/users/admin/:id/role`). The backend registers these as **`PATCH`** only (both the legacy and new routers). The verbs do not match — treat `PATCH` as the authoritative backend verb; the `PUT` calls will not route.
>
> ⚠️ **Note on status values (KNOWN BUG):** The frontend `updateUserStatus` TypeScript type is `'active' | 'inactive' | 'pending'`. The backend `User.status` enum is `'active' | 'suspended' | 'deleted'`. So:
> - `'inactive'` and `'pending'` are **rejected/ignored** by the backend (the new controller only applies `status` when it is one of `active`/`suspended`/`deleted`).
> - `'suspended'` — the actually-usable suspend value — is **missing from the frontend type**, so the admin UI cannot send it.

### POST /api/user/admin/create

**Description:** Admin creates a user with a chosen role and verification state.
**Auth required:** Bearer JWT (admin)
**Request body:**
```ts
{
  email: string;
  password: string;
  firstName: string;
  lastName: string;
  role?: "buyer" | "seller" | "admin";  // default "buyer"
  isActive?: boolean;                    // default true
  isVerified?: boolean;                  // default false
  profile?: { /* free-form */ };
}
```
**Response 201:** `{ success, data: { user } }`
**Errors:** `400` missing fields, `403` non-admin, `409` email exists.

### DELETE /api/user/admin/:userId (new controller — SOFT delete)

**Description:** **Soft-delete** — sets `status = 'deleted'` via `findByIdAndUpdate` (the user document is retained). Only blocks **self-deletion** (`userId === req.user.id`).
**Auth required:** Bearer JWT (admin)
**Response 200:** `{ success, data: { deletedUserId } }`
**Errors:** `400` self-delete, `404` not found.

> ⚠️ **Behavior diverges from the legacy DELETE — and a privilege concern.** The new controller’s soft-delete does **NOT** block an admin from deleting *other* admins (it only blocks deleting yourself). By contrast, the legacy `DELETE /api/users/admin/:id` (below) is a **HARD delete** (`findByIdAndDelete`, removes the document) and **does** block admin-on-admin deletion. The two endpoints behave differently in both deletion semantics (soft vs hard) and authorization (self-only vs admin-on-admin block).

### DELETE /api/users/admin/:id (legacy router — HARD delete)

**Description:** **Hard-delete** — permanently removes the user document via `findByIdAndDelete`. Blocks deleting other admins.
**Auth required:** Bearer JWT (admin)
**Errors:** `403` admin-on-admin, `404` not found.

### PATCH /api/user/admin/:userId/status (and legacy PATCH /api/users/admin/:id/status)

**Description:** Update a user's status and/or email-verified flag. Registered on the new controller as `/api/user/admin/:userId/status`; the legacy plural `/api/users/admin/:id/status` is what the frontend actually calls.
**Auth required:** Bearer JWT (admin)
**Request body:**
```ts
{
  status?: "active" | "suspended" | "deleted";  // applied only if one of these three values
  isEmailVerified?: boolean;                     // new controller also accepts this — sets User.isEmailVerified
  reason?: string;
}
```
The new controller only writes `status` when it is exactly `active`, `suspended`, or `deleted`; any other value (e.g. the frontend's `inactive`/`pending`) is silently ignored. It additionally accepts an `isEmailVerified` boolean to flip the user's email-verified flag.
**Response 200:** `{ success, data: { user } }` (sanitized user without password)
**⚠️ Frontend discrepancy (KNOWN BUG):** Frontend calls this with the `PUT` verb and sends `status: 'active' | 'inactive' | 'pending'`; the backend registers `PATCH` and only honors `active`/`suspended`/`deleted`. See the admin routing note above.

### PATCH /api/user/admin/:userId/toggle-status

**Description:** Flip active/suspended without explicit body.
**Auth required:** Bearer JWT (admin)

### PATCH /api/users/admin/:userId/role

**Description:** Change a user's role.
**Auth required:** Bearer JWT (admin)
**Request body:** `{ role: "buyer" | "seller" | "admin"; reason?: string }`
**Errors:** `400` invalid role.
**Frontend discrepancy:** Frontend calls this with `PUT` verb; backend only accepts `PATCH`.

### GET /api/user/admin/list

**Description:** Paginated admin user list with filters.
**Auth required:** Bearer JWT (admin)
**Query params:** `role`, `isActive`, `isVerified`, `search`, `page`, `limit`, `sortBy`, `sortOrder`
**Response 200:** `{ success, data: { users, pagination, stats: { totalUsers, activeUsers, verifiedUsers, buyers, sellers, admins } } }`

### GET /api/user/admin/:userId/dependencies

**Description:** Returns a count of related entities (purchase requests, offers, payments) before a destructive admin operation.
**Auth required:** Bearer JWT (admin)

### GET /api/users/admin/stats

**Description:** Aggregated user stats — total/active/verified counts, role distribution, activity buckets (24h / 7d / 30d). (Undocumented previously.)
**Auth required:** Bearer JWT (admin)
**Note:** No frontend UI actually consumes this. The endpoint path exists in `axios.ts` (`endpoints.users.admin.stats`), but the admin overview computes its figures client-side from `getPurchaseRequests()`, not from this endpoint.

### GET /api/users/admin/:userId

**Description:** Fetch a single user by id (admin view, includes preferences and last-login).
**Auth required:** Bearer JWT (admin)

### PUT /api/users/admin/:userId

**Description:** Mass update a user document (admin override).
**Auth required:** Bearer JWT (admin)

### PUT /api/users/admin/update/:email

**Description:** Same as above but keyed on email.
**Auth required:** Bearer JWT (admin)

### PATCH /api/users/admin/:userId/password

**Description:** Admin forces a new password. Wipes `refreshTokens` so all sessions are invalidated.
**Auth required:** Bearer JWT (admin)
**Request body:** `{ newPassword: string; reason?: string }`

### POST /api/users/admin/:userId/resend-verification

**Description:** Regenerate the email verification code and re-send the verification email.
**Auth required:** Bearer JWT (admin)
**Errors:** `400` user already verified.

> ⚠️ **Email code length inconsistency.** The legacy `userRoutes.ts` generates an **8-digit** code (`10000000 + Math.random() * 90000000`), while the new `userController` (used by `POST /api/user/profile/email/verify` and the email-change flow) generates a **6-digit** code (`crypto.randomInt(100000, 1000000)`). Code length therefore depends on which path issued it.

## Address book

Source: [`backend/src/services/address/addressRoutes.ts`](../../backend/src/services/address/addressRoutes.ts), model: [[Address]].

### GET /api/addresses

**Description:** List the caller's addresses.
**Auth required:** Bearer JWT
**Response 200:** `{ success: true, data: [Address, ...] }`

### POST /api/addresses

**Description:** Create a new address. First address auto-becomes primary.
**Auth required:** Bearer JWT
**Request body:**
```ts
{
  fullName: string;
  phone: string;
  street: string;
  city: string;
  state?: string;
  country: string;
  postalCode?: string;
  isPrimary?: boolean;
  notes?: string;
}
```

### PUT /api/addresses/:addressId

**Description:** Update an address.
**Auth required:** Bearer JWT
**Errors:** `404` not owned by user.

### DELETE /api/addresses/:addressId

**Description:** Delete an address. If it was the primary, another address is promoted.
**Auth required:** Bearer JWT

### PATCH /api/addresses/:addressId/primary

**Description:** Promote an address to primary; demotes the previous primary.
**Auth required:** Bearer JWT

See [[Address]] for the schema, and [[Marketplace API]] for how purchase requests reference an address.
