Users
Per-endpoint reference for the Users resource. Users in the Tasklife data model are org-membership records (rows in the org_users table), not raw system accounts — every org_user_id you see in any Tasklife URL or payload is the id of the join row between a person and the API key's org. Each section below shows the exact path, every parameter, a full request example, and the response shape with a worked example.
Quickstart
Base URL
https://api.tasklife.com/v1/
Auth (all endpoints)
Every request must include a bearer token in the Authorization header. The token is an API key bound to a specific organization — endpoints return only data scoped to that org.
Authorization: Bearer ***
Content-Type: application/json
Response envelope
All successful responses share a common envelope. Error responses are described in Errors below.
{
"success": true,
"data": { /* endpoint-specific payload */ }
}
Note: Users endpoints nest list results under data.users[], single-user results under data.user.<field> (with a nested data.user.webhook.<field> block for webhook settings — there is no sibling webhook_settings object), and the team-membership endpoints under data.user + data.teams[]. Each endpoint below shows its actual response shape — trust that.
Errors
Failed responses always have success: false and an error object with a stable code and a human-readable message:
{
"success": false,
"error": {
"code": "API_ERROR",
"message": "User not found"
}
}
| HTTP status | When | What to do |
|---|---|---|
400 | Request body failed validation: team_ids missing or wrong type on PUT /v1/users/{id}/teams, malformed JSON, etc. | Read error.message; fix the offending field. |
403 | Only on the two /users/{id}/teams endpoints. The router enters require_api_org_admin($conn, $key_data) before the handler runs; non-admin tokens receive 403 — Org admin required. Note the asymmetry: the listing endpoints GET /v1/users and GET /v1/users/{id} are not admin-gated. | Use an API key whose owning org_users.role is 1 (admin) and status = 'active'. |
404 | No org_users row matches the supplied {org_user_id} for this org, or (for the team endpoints) the user exists but is status != 'active'. | Verify the id and that it belongs to the API key's org. |
500 | PUT /v1/users/{id}/teams with one or more team ids that don't belong to the API key's org — replaceUserTeamMemberships() throws "One or more teams do not belong to this organization", the transaction rolls back, and the router converts it to 500. | Read error.message; remove any ids that don't belong to the org. |
Users
Users in the Tasklife data model are org-membership records stored in the org_users table. Every {org_user_id} in any Tasklife URL or payload is the id of the join row between a person and a specific organization — the same person can hold a different org_user_id in a different org. Each row carries an org_id, an optional user_id pointing at the global users account, an email, a role (1 = admin, 0 = member), a status, and an org-scoped handle (an optional short identifier unique within the org). The listing endpoints return rows from org_users joined against users — the global users table itself is not directly exposed.
⚠️ Users-specific gotchas
org_user_idis the org-membership row id, not the systemusers.id— the path parameter is namedorg_user_idfor a reason. InGET /v1/users/{org_user_id}the response surfaces both:data.user.idis the globalusers.id(may benullfor org-only accounts) whiledata.user.org_user_idis theorg_users.idyou put in the URL. Always useorg_user_idwhen referring to a user inside the API key's org — the same person can hold a differentorg_user_idin a different org, andusers.idis org-agnostic.- Admin gating is asymmetric —
GET /v1/usersandGET /v1/users/{id}are not admin-gated (any active API key for the org can call them). The two/users/{id}/teamsendpoints are admin-gated: the router entersrequire_api_org_admin($conn, $key_data)on line 1158 / 1168 ofexternal-api/v1/index.phpbefore any handler runs, and a non-admin key gets403 — Org admin required. This split exists because listing users is used for day-to-day task assignment, but assigning people to teams is an admin operation. - Webhook settings live inside
data.user.webhook, not as a sibling object — the router embeds webhook config as a nested object underdata.user.webhook.{enabled,url,events,throttle_per_minute,has_secret}. There is no top-leveldata.webhook_settingskey. The list endpoint omitshas_secret(only the single-user GET surfaces it); both list and single-user GET exposeenabled,url,events, andthrottle_per_minute. - List endpoint returns only
status = 'active'rows —GET /v1/usersfilters withWHERE ou.org_id = ? AND ou.status = 'active'. Inactive users are invisible to the list.GET /v1/users/{id}, by contrast, returns the row regardless of status — the response includes astatusfield that lets you tell"active"from"inactive". PUT /v1/users/{id}/teamsis a full-roster replace, not additive —replaceUserTeamMemberships()inincludes/org_teams.phpruns aDELETE FROM team_user_assignmentsscoped to the user before inserting the new rows, all inside one transaction. To remove a single team from the user's roster, re-submit the desired ids minus that one (or pass{"team_ids": []}to clear all teams). The write also verifies every supplied id belongs to the org — if any id doesn't, the transaction rolls back with"One or more teams do not belong to this organization"and the response is500.PUT /v1/users/{id}/teamsis bidirectional withPUT /v1/teams/{id}/users— both endpoints write to the sameteam_user_assignmentstable and each is a transactional full-replace on its own axis (user ↔ teams). Pick whichever axis is more convenient — the two are kept consistent because each transactionally replaces the entire membership set on its own axis. Note: unlike the team-side roster endpoint, the user-side endpoint does not accept an alias: it reads only$payload['team_ids'], and a bare string is auto-wrapped into a one-element array viais_array()guard.- The list endpoint sorts by
u.name ASCand the team-membershipuseris sorted viadisplay_name— the team-membership helpers (fetchOrgUserForTeamAssignments) compute adisplay_nameviaCOALESCE(NULLIF(u.name, ''), NULLIF(ou.handle, ''), NULLIF(ou.user_email, ''), CONCAT('User #', ou.id)), so the same person may show up with a differentnamevs.display_namedepending on which columns are populated.
Endpoints at a glance
| Action | Method + Path | Summary |
|---|---|---|
| List org users | GET /users | List every active org user, sorted by name. Includes nested webhook settings. Not admin-gated. |
| Get one org user | GET /users/{org_user_id} | Fetch a single org user with full webhook config including has_secret. Not admin-gated. |
| List a user's teams | GET /users/{org_user_id}/teams | List a user's team memberships. Org-admin API key required. |
| Replace a user's teams | PUT /users/{org_user_id}/teams | Replace the full team-membership roster. Org-admin API key required. Body: {"team_ids": [10, 11]}. |
Heads up: the users table itself (global account records) is not directly exposed by any /v1/users endpoint — it is only reachable through org_users joins. The webhook-management PATCH /v1/users/{org_user_id} endpoint is documented in a separate section because it is a mutation (changes the handle, webhook_* fields) rather than a read.
GET /users
List every active org user for the API key's org, sorted by u.name ASC. Each entry is a org_users row joined against users, with the global users.id exposed as id and the org-membership row id exposed as org_user_id. Each entry also includes a nested webhook block (enabled, url, events, throttle_per_minute) but not has_secret — see the single-user GET for that. There is no pagination and no filter parameters on this endpoint — it returns the full active roster on every call.
Request example
curl -sS https://api.tasklife.com/v1/users \
-H "Authorization: Bearer *** \
-H "Content-Type: application/json"
Response 200 OK
The user array is nested under data.users[]:
| Field | Type | Description |
|---|---|---|
data.users[].id | integer | null | Global users.id. May be null for org-only accounts that have no users row. |
data.users[].org_user_id | integer | org_users.id — the org-membership row id. Use this in GET /users/{org_user_id} and in PUT /v1/users/{org_user_id}/teams. |
data.users[].name | string | null | Display name from the users row. |
data.users[].email | string | Email from the users row (falls back to org_users.user_email). |
data.users[].user_type | string | "human" by default; "ai" or other values on special accounts. |
data.users[].handle | string | null | Org-scoped short identifier, unique within the org. Matches /^[a-z0-9][a-z0-9._-]{1,63}$/i. |
data.users[].webhook.enabled | boolean | Whether the user's webhook is enabled. |
data.users[].webhook.url | string | Webhook target URL. Empty string when unset. |
data.users[].webhook.events | array<string> | List of event names the user is subscribed to. |
data.users[].webhook.throttle_per_minute | integer | Per-minute throttle. Defaults to 30 when unset. |
{
"success": true,
"data": {
"users": [
{
"id": 1042,
"org_user_id": 318,
"name": "Alex Rivera",
"email": "[email protected]",
"user_type": "human",
"handle": "alex",
"webhook": {
"enabled": true,
"url": "https://hooks.acme.dev/tasklife/alex",
"events": ["task.created", "task.completed"],
"throttle_per_minute": 30
}
},
{
"id": 1184,
"org_user_id": 422,
"name": "Priya Shah",
"email": "[email protected]",
"user_type": "human",
"handle": "priya",
"webhook": {
"enabled": false,
"url": "",
"events": [],
"throttle_per_minute": 30
}
},
{
"id": 1255,
"org_user_id": 501,
"name": "Jordan Lee",
"email": "[email protected]",
"user_type": "human",
"handle": null,
"webhook": {
"enabled": false,
"url": "",
"events": [],
"throttle_per_minute": 30
}
}
]
}
}
Errors
This endpoint has no documented error cases beyond a missing/invalid API key (handled before the router reaches the users block).
{
"success": false,
"error": {
"code": "UNAUTHORIZED",
"message": "Invalid or missing API key"
}
}
GET /users/{org_user_id}
Fetch a single org-membership row by id, including the full webhook configuration with the has_secret flag. The path parameter is the org_users.id — the response surfaces both ids: data.user.id (global users.id) and data.user.org_user_id (org_users.id). The status field is included regardless of value (the list endpoint, by contrast, filters out inactive rows); the single-user GET does not apply the status = 'active' filter. Returns 404 if the id doesn't exist or belongs to a different org.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
org_user_id | integer | yes | org_users.id — the org-membership row id, not the global users.id. |
Request example
curl -sS https://api.tasklife.com/v1/users/318 \
-H "Authorization: Bearer *** \
-H "Content-Type: application/json"
Response 200 OK
The user is nested under data.user, with webhook config under data.user.webhook:
| Field | Type | Description |
|---|---|---|
data.user.id | integer | Global users.id (always populated on this endpoint because the join is required). |
data.user.org_user_id | integer | org_users.id. Echoes the id in the URL. |
data.user.org_id | integer | Owning org id. |
data.user.name | string | null | Display name from the users row. |
data.user.email | string | Email from org_users.user_email. |
data.user.user_type | string | "human" by default; "ai" or other values on special accounts. |
data.user.department | string | null | Department label from org_users.department. |
data.user.role | integer | null | 1 = org admin, 0 = regular member. Drives admin gating on other endpoints. |
data.user.status | string | "active" or "inactive". Not filtered on this endpoint. |
data.user.handle | string | null | Org-scoped short identifier (matches /^[a-z0-9][a-z0-9._-]{1,63}$/i), or null. |
data.user.webhook.enabled | boolean | Whether the user's webhook is enabled. |
data.user.webhook.url | string | Webhook target URL. Empty string when unset. |
data.user.webhook.events | array<string> | List of event names the user is subscribed to. |
data.user.webhook.throttle_per_minute | integer | Per-minute throttle. Defaults to 30 when unset. |
data.user.webhook.has_secret | boolean | Whether a webhook signing secret is currently configured. Only present on this endpoint; the list endpoint omits it. |
{
"success": true,
"data": {
"user": {
"id": 1042,
"org_user_id": 318,
"org_id": 42,
"name": "Alex Rivera",
"email": "[email protected]",
"user_type": "human",
"department": "Engineering",
"role": 1,
"status": "active",
"handle": "alex",
"webhook": {
"enabled": true,
"url": "https://hooks.acme.dev/tasklife/alex",
"events": ["task.created", "task.completed"],
"throttle_per_minute": 30,
"has_secret": true
}
}
}
}
Errors
{
"success": false,
"error": {
"code": "NOT_FOUND",
"message": "User not found"
}
}
GET /users/{org_user_id}/teams
List every team a single org user belongs to. Rows come from team_user_assignments joined against teams, ordered by team_name ASC. The response is a two-key envelope: data.user (a freshly-fetched summary of the org user — same fields as fetchOrgUserForTeamAssignments()) and data.teams[] (the membership roster, an empty array if the user has none). The user lookup uses $activeOnly = true, so an inactive user returns 404 even if a team_user_assignments row exists for them.
require_api_org_admin($conn, $key_data) on line 1158 of external-api/v1/index.php before any handler runs. Non-admin tokens receive 403 — Org admin required from the helper. (The plain GET /v1/users and GET /v1/users/{id} endpoints are not admin-gated — the asymmetry is intentional.)
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
org_user_id | integer | yes | org_users.id. Must be status = 'active' or this returns 404. |
Request example
curl -sS https://api.tasklife.com/v1/users/318/teams \
-H "Authorization: Bearer *** \
-H "Content-Type: application/json"
Response 200 OK
The user summary is under data.user and the team list under data.teams[]:
| Field | Type | Description |
|---|---|---|
data.user.org_user_id | integer | Org-membership row id. Echoes the id in the URL. |
data.user.org_id | integer | Owning org id. |
data.user.user_id | integer | null | Global users.id, or null for org-only users. |
data.user.user_email | string | Email stored on the org_users row. |
data.user.department | string | null | Department label from org_users.department. |
data.user.role | integer | null | 1 = org admin, 0 = regular member. |
data.user.is_section_manager | integer (0 or 1) | Whether the user manages a section. |
data.user.status | string | Always "active" on this endpoint (inactive users return 404). |
data.user.name | string | null | Display name from the users row (may be empty). |
data.user.email | string | Email from the users row (falls back to user_email). |
data.user.display_name | string | Best-available display name: users.name → org_users.handle → user_email → "User #<id>". |
data.user.user_type | string | "human" by default; "ai" or other values on special accounts. |
data.teams[].id | integer | Team id. Use this in PUT /teams/{id}/users. |
data.teams[].team_name | string | Display name. |
data.teams[].shortcode | string | null | Normalized uppercase shortcode, or null. |
data.teams[].color | string | Lowercase hex #rrggbb used for UI badges. Defaults to #007bff. |
{
"success": true,
"data": {
"user": {
"org_user_id": 318,
"org_id": 42,
"user_id": 1042,
"user_email": "[email protected]",
"department": "Engineering",
"role": 1,
"is_section_manager": 1,
"status": "active",
"name": "Alex Rivera",
"email": "[email protected]",
"display_name": "Alex Rivera",
"user_type": "human"
},
"teams": [
{
"id": 17,
"team_name": "Mobile Platform",
"shortcode": "MOB",
"color": "#0d6efd"
},
{
"id": 18,
"team_name": "Platform Infrastructure",
"shortcode": "PLAT",
"color": "#198754"
}
]
}
}
Errors
{
"success": false,
"error": {
"code": "FORBIDDEN",
"message": "Org admin required"
}
}
PUT /users/{org_user_id}/teams
Replace the full team-membership roster for one user in a single transactional write. The body is a JSON object with a team_ids array of integer ids; a bare string is auto-wrapped into a one-element array. replaceUserTeamMemberships() in includes/org_teams.php first verifies the user is status = 'active', then verifies every supplied id belongs to the API key's org (the transaction rolls back if any id is foreign — error: "One or more teams do not belong to this organization"), then issues a DELETE FROM team_user_assignments scoped to the user before inserting the new rows. The response envelope mirrors GET /users/{id}/teams: a freshly-fetched data.user summary plus the canonical data.teams[] roster.
require_api_org_admin($conn, $key_data) on line 1168 of external-api/v1/index.php before any handler runs. Non-admin tokens receive 403 — Org admin required from the helper. (The plain GET /v1/users and GET /v1/users/{id} endpoints are not admin-gated — the asymmetry is intentional.)
replaceUserTeamMemberships() issues a DELETE FROM team_user_assignments scoped to the user before any inserts, all inside one transaction. To remove a single team from the user's roster, re-submit the desired ids minus that one (or pass {"team_ids": []} to clear all teams).
/teams/{id}/users. The team-side roster endpoint writes the same team_user_assignments table. Pick whichever axis is more convenient — the two are kept consistent because each transactionally replaces the entire membership set on its own axis. Note: unlike the team-side endpoint, the user-side endpoint reads only team_ids — there is no org_team_ids alias.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
org_user_id | integer | yes | org_users.id. Must be status = 'active' or this returns 404. |
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
team_ids |
array<integer> |
yes | Full desired roster (replaces existing rows). Deduped and intval-cast server-side; non-positive entries are filtered out. Empty array clears the user's team memberships. Every id must belong to the API key's org — foreign ids cause the transaction to roll back with a 500 + "One or more teams do not belong to this organization". |
Request example
curl -sS -X PUT https://api.tasklife.com/v1/users/318/teams \
-H "Authorization: Bearer *** \
-H "Content-Type: application/json" \
-d '{
"team_ids": [17, 18]
}'
Response 200 OK
The user summary and team roster mirror the list endpoint:
| Field | Type | Description |
|---|---|---|
data.user.org_user_id | integer | Org-membership row id. Echoes the id in the URL. |
data.user.org_id | integer | Owning org id. |
data.user.user_id | integer | null | Global users.id, or null for org-only users. |
data.user.user_email | string | Email stored on the org_users row. |
data.user.department | string | null | Department label from org_users.department. |
data.user.role | integer | null | 1 = org admin, 0 = regular member. |
data.user.is_section_manager | integer (0 or 1) | Whether the user manages a section. |
data.user.status | string | Always "active" on this endpoint (inactive users return 404). |
data.user.name | string | null | Display name from the users row (may be empty). |
data.user.email | string | Email from the users row (falls back to user_email). |
data.user.display_name | string | Best-available display name: users.name → org_users.handle → user_email → "User #<id>". |
data.user.user_type | string | "human" by default; "ai" or other values on special accounts. |
data.teams[] | object[] | Full fetchTeamsForOrgUser() output — same shape as GET /users/{id}/teams. |
{
"success": true,
"data": {
"user": {
"org_user_id": 318,
"org_id": 42,
"user_id": 1042,
"user_email": "[email protected]",
"department": "Engineering",
"role": 1,
"is_section_manager": 1,
"status": "active",
"name": "Alex Rivera",
"email": "[email protected]",
"display_name": "Alex Rivera",
"user_type": "human"
},
"teams": [
{
"id": 17,
"team_name": "Mobile Platform",
"shortcode": "MOB",
"color": "#0d6efd"
},
{
"id": 18,
"team_name": "Platform Infrastructure",
"shortcode": "PLAT",
"color": "#198754"
}
]
}
}
Errors
{
"success": false,
"error": {
"code": "API_ERROR",
"message": "One or more teams do not belong to this organization"
}
}