Users Endpoints
Base path: /api/v1/users
All endpoints require a valid Authorization: Bearer <access_token> header. See Authentication for the full auth flow.
Role-Based Access Summary
| Endpoint | member | admin | owner |
|---|---|---|---|
GET /me | ✅ | ✅ | ✅ |
PATCH /me | ✅ | ✅ | ✅ |
PATCH /me/password | ✅ | ✅ | ✅ |
POST /me/photo | ✅ | ✅ | ✅ |
GET / (list team) | ✅ | ✅ | ✅ |
POST /invite | ❌ | ✅ | ✅ |
PATCH /:id/role | ❌ | ❌ | ✅ |
POST /:id/deactivate | ❌ | ❌ | ✅ |
POST /:id/reactivate | ❌ | ❌ | ✅ |
POST /:id/reset-password | ❌ | ❌ | ✅ |
DELETE /:id | ❌ | ❌ | ✅ |
GET /api/v1/users/me
Returns the authenticated user's own profile. password_hash is never included in any response.
Response — 200 OK
{
"id": "<uuid>",
"accountId": "<uuid>",
"firstName": "Jane",
"lastName": "Doe",
"email": "jane@acme.com",
"phone": "+254712345678",
"role": "owner",
"isVerified": true,
"isActive": true,
"profilePhotoUrl": "https://pub-xxx.r2.dev/photos/user-id.jpg",
"lastLoginAt": "2025-01-01T00:00:00.000Z",
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
}PATCH /api/v1/users/me
Updates the authenticated user's profile fields. All fields are optional — supply only what you want to change.
Request Body
{
"firstName": "Jane",
"lastName": "Smith",
"email": "jane.smith@acme.com",
"phone": "+254712345678"
}| Field | Type | Notes |
|---|---|---|
firstName | string | |
lastName | string | |
email | string | Must be unique; triggers re-verification if changed |
phone | string | Optional phone number |
Response — 200 OK
Returns the updated user object (same shape as GET /me).
PATCH /api/v1/users/me/password
Changes the authenticated user's password. Requires the current password for verification.
Request Body
{
"currentPassword": "oldPassword123",
"newPassword": "newPassword456"
}| Field | Type | Required | Notes |
|---|---|---|---|
currentPassword | string | Yes | Must match the stored bcrypt hash |
newPassword | string | Yes | Minimum 8 characters |
Response — 204 No Content
Errors
| Code | Cause |
|---|---|
401 Unauthorized | currentPassword is incorrect |
400 Bad Request | newPassword under 8 characters |
POST /api/v1/users/me/photo
Uploads a profile photo. The file is stored in Cloudflare R2 and the profile_photo_url field on the user is updated to the public R2 URL.
Content-Type: multipart/form-data
Request
| Field | Type | Notes |
|---|---|---|
file | File | JPEG, PNG, or WebP. Max 5 MB |
Response — 200 OK
{
"url": "https://pub-xxx.r2.dev/profile-photos/<user-id>.jpg"
}Why Cloudflare R2?
R2 was chosen over AWS S3 for photo storage because:
- Zero egress fees — files served via Cloudflare's CDN have no bandwidth charges
- Same SDK — R2 is S3-compatible; the
@aws-sdk/client-s3library works unchanged - Consistent vendor — we already use Cloudflare for DNS, CDN, and Pages; reducing the number of vendors simplifies billing and IAM
Errors
| Code | Cause |
|---|---|
400 Bad Request | File too large or unsupported format |
GET /api/v1/users
Lists all active team members belonging to the authenticated user's account. Deactivated members are excluded.
Response — 200 OK
[
{
"id": "<uuid>",
"accountId": "<uuid>",
"firstName": "Jane",
"lastName": "Doe",
"email": "jane@acme.com",
"role": "owner",
"isVerified": true,
"isActive": true,
"lastLoginAt": "2025-01-01T00:00:00.000Z",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]POST /api/v1/users/invite
Creates a new team member under the caller's account. An invitation email with credentials can optionally be sent.
Required role: owner or admin
Request Body
{
"firstName": "John",
"lastName": "Smith",
"email": "john@acme.com",
"password": "tempPassword1",
"role": "member"
}| Field | Type | Required | Notes |
|---|---|---|---|
firstName | string | Yes | |
lastName | string | Yes | |
email | string | Yes | Must be unique across all users |
password | string | Yes | Minimum 8 characters |
role | string | Yes | admin or member — cannot invite owner |
Response — 201 Created
Returns the created user object.
Errors
| Code | Cause |
|---|---|
403 Forbidden | Caller is a member and lacks permission |
409 Conflict | Email already registered |
422 Unprocessable | Validation failure |
PATCH /api/v1/users/:id/role
Changes the role of another team member. The owner role cannot be changed.
Required role: owner only
Path Parameters
| Parameter | Description |
|---|---|
id | UUID of the user whose role is being changed |
Request Body
{
"role": "admin"
}Allowed values: admin, member
Response — 200 OK
Returns the updated user object.
Errors
| Code | Cause |
|---|---|
403 Forbidden | Caller is not owner |
400 Bad Request | Attempting to change the owner's role |
404 Not Found | User not found in this account |
POST /api/v1/users/:id/deactivate
Deactivates a team member. Deactivated users cannot log in. Their data is preserved.
Required role: owner only
Response — 200 OK
Returns the updated user object with isActive: false.
Errors
| Code | Cause |
|---|---|
400 Bad Request | Attempting to deactivate yourself or the owner |
404 Not Found | User not in this account |
POST /api/v1/users/:id/reactivate
Reactivates a previously deactivated team member.
Required role: owner only
Response — 200 OK
Returns the updated user object with isActive: true.
POST /api/v1/users/:id/reset-password
Admin-initiated password reset. Sets a temporary password on behalf of a team member. The member should change it on next login.
Required role: owner only
Request Body
{
"newPassword": "TempPass123!"
}Response — 200 OK
{
"message": "Password reset successfully."
}Errors
| Code | Cause |
|---|---|
400 Bad Request | newPassword under 8 characters or resetting own password |
404 Not Found | User not found in this account |
DELETE /api/v1/users/:id
Permanently removes a team member from the account.
Required role: owner only
Response — 204 No Content
Errors
| Code | Cause |
|---|---|
403 Forbidden | Caller is not owner |
400 Bad Request | Attempting to remove the owner or yourself |
404 Not Found | User not found in this account |