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 statusWhenWhat to do
400Request 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.
403Only 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'.
404No 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.
500PUT /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.

Endpoints at a glance

ActionMethod + PathSummary
List org usersGET /usersList every active org user, sorted by name. Includes nested webhook settings. Not admin-gated.
Get one org userGET /users/{org_user_id}Fetch a single org user with full webhook config including has_secret. Not admin-gated.
List a user's teamsGET /users/{org_user_id}/teamsList a user's team memberships. Org-admin API key required.
Replace a user's teamsPUT /users/{org_user_id}/teamsReplace 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[]:

FieldTypeDescription
data.users[].idinteger | nullGlobal users.id. May be null for org-only accounts that have no users row.
data.users[].org_user_idintegerorg_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[].namestring | nullDisplay name from the users row.
data.users[].emailstringEmail from the users row (falls back to org_users.user_email).
data.users[].user_typestring"human" by default; "ai" or other values on special accounts.
data.users[].handlestring | nullOrg-scoped short identifier, unique within the org. Matches /^[a-z0-9][a-z0-9._-]{1,63}$/i.
data.users[].webhook.enabledbooleanWhether the user's webhook is enabled.
data.users[].webhook.urlstringWebhook target URL. Empty string when unset.
data.users[].webhook.eventsarray<string>List of event names the user is subscribed to.
data.users[].webhook.throttle_per_minuteintegerPer-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

FieldTypeRequiredDescription
org_user_idintegeryesorg_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:

FieldTypeDescription
data.user.idintegerGlobal users.id (always populated on this endpoint because the join is required).
data.user.org_user_idintegerorg_users.id. Echoes the id in the URL.
data.user.org_idintegerOwning org id.
data.user.namestring | nullDisplay name from the users row.
data.user.emailstringEmail from org_users.user_email.
data.user.user_typestring"human" by default; "ai" or other values on special accounts.
data.user.departmentstring | nullDepartment label from org_users.department.
data.user.roleinteger | null1 = org admin, 0 = regular member. Drives admin gating on other endpoints.
data.user.statusstring"active" or "inactive". Not filtered on this endpoint.
data.user.handlestring | nullOrg-scoped short identifier (matches /^[a-z0-9][a-z0-9._-]{1,63}$/i), or null.
data.user.webhook.enabledbooleanWhether the user's webhook is enabled.
data.user.webhook.urlstringWebhook target URL. Empty string when unset.
data.user.webhook.eventsarray<string>List of event names the user is subscribed to.
data.user.webhook.throttle_per_minuteintegerPer-minute throttle. Defaults to 30 when unset.
data.user.webhook.has_secretbooleanWhether 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.

Path parameters

FieldTypeRequiredDescription
org_user_idintegeryesorg_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[]:

FieldTypeDescription
data.user.org_user_idintegerOrg-membership row id. Echoes the id in the URL.
data.user.org_idintegerOwning org id.
data.user.user_idinteger | nullGlobal users.id, or null for org-only users.
data.user.user_emailstringEmail stored on the org_users row.
data.user.departmentstring | nullDepartment label from org_users.department.
data.user.roleinteger | null1 = org admin, 0 = regular member.
data.user.is_section_managerinteger (0 or 1)Whether the user manages a section.
data.user.statusstringAlways "active" on this endpoint (inactive users return 404).
data.user.namestring | nullDisplay name from the users row (may be empty).
data.user.emailstringEmail from the users row (falls back to user_email).
data.user.display_namestringBest-available display name: users.name → org_users.handle → user_email → "User #<id>".
data.user.user_typestring"human" by default; "ai" or other values on special accounts.
data.teams[].idintegerTeam id. Use this in PUT /teams/{id}/users.
data.teams[].team_namestringDisplay name.
data.teams[].shortcodestring | nullNormalized uppercase shortcode, or null.
data.teams[].colorstringLowercase 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.

Path parameters

FieldTypeRequiredDescription
org_user_idintegeryesorg_users.id. Must be status = 'active' or this returns 404.

Body parameters

FieldTypeRequiredDescription
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:

FieldTypeDescription
data.user.org_user_idintegerOrg-membership row id. Echoes the id in the URL.
data.user.org_idintegerOwning org id.
data.user.user_idinteger | nullGlobal users.id, or null for org-only users.
data.user.user_emailstringEmail stored on the org_users row.
data.user.departmentstring | nullDepartment label from org_users.department.
data.user.roleinteger | null1 = org admin, 0 = regular member.
data.user.is_section_managerinteger (0 or 1)Whether the user manages a section.
data.user.statusstringAlways "active" on this endpoint (inactive users return 404).
data.user.namestring | nullDisplay name from the users row (may be empty).
data.user.emailstringEmail from the users row (falls back to user_email).
data.user.display_namestringBest-available display name: users.name → org_users.handle → user_email → "User #<id>".
data.user.user_typestring"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"
  }
}