Teams

Per-endpoint reference for the Teams resource. Teams are an org-level construct: they group users together and can be attached (optionally) to a single product, project, or CMS page so that team membership drives downstream gating. 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: Teams endpoints nest single-team responses under data.team.<field> (get/create/update/delete) and lists under data.teams[].<field> (list), data.users[].<field> (team-members), or data.<count|ids|users> for the membership-replace response. 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": "Team name is required"
  }
}
HTTP statusWhenWhat to do
400Request body failed validation: missing team_name on POST/PUT, malformed color (non-hex string), invalid shortcode after normalization, bad JSON.Read error.message; fix the offending field.
403API key is not an org-admin key. Every Teams endpoint is gated by require_api_org_admin(); non-admin keys receive 403 — Org admin required before the rest of the handler runs.Use an API key whose user has org_users.role = 1 (admin) and status = 'active'.
404Team doesn't exist or belongs to a different org.Verify the ID; the team resource is org-scoped.
409shortcode is already in use by another team in the same org (checked on create and update). The DB also enforces a unique index on (org_id, shortcode).Read error.message and choose a different shortcode.
500Unexpected server error — e.g. a replace-membership transaction that touched a non-member or inactive user, etc.Retry with backoff; report if persistent.

Teams

Teams are org-scoped groups of users. A team has a display team_name, an optional uppercase shortcode (must be unique per org), an optional color (hex) used for UI badges, and an optional free-form description. Teams can be assigned to products (project_products.team_id), projects (projects.team_id), or CMS pages (cms_pages.team_id) — in those contexts the team's membership roster is used to gate downstream operations (e.g. assignee checks on team-gated tasks).

Endpoints at a glance

ActionMethod + PathSummary
List teamsGET /teamsList every team for the API key's org, with member_count. Sorted by team_name ASC.
Create a teamPOST /teamsCreate a team. Required: team_name. Returns 201.
Get a teamGET /teams/{id}Fetch a single team by id, including member_count.
Update a teamPUT /teams/{id} or PATCH /teams/{id}Update one or more fields. PUT requires team_name; PATCH makes all fields optional (omitted fields are preserved).
Delete a teamDELETE /teams/{id}Delete a team and cascade-clear all team_user_assignments, project_products.team_id, projects.team_id, and cms_pages.team_id. Returns the pre-delete snapshot.
List team membersGET /teams/{id}/usersList the users currently assigned to a team, ordered by display name.
Replace team rosterPUT /teams/{id}/usersReplace the membership roster. Body: {"org_user_ids": [1,2,3]}. Alias user_ids also accepted.

Heads up: there is no /v1/teams/{id}/users/{org_user_id} sub-resource — membership manipulation is only available via the full-roster PUT on either axis (/teams/{id}/users or /users/{id}/teams). Removing a single member is a PUT with the desired ids minus the one being removed.

GET /teams

List every team that belongs to the API key's org, sorted by team_name ASC (ties broken by id ASC). Each entry includes a member_count computed by a LEFT JOIN against team_user_assignments. There is no pagination and no filter parameters on this endpoint — it returns the full org team roster on every call.

Request example

curl -sS https://api.tasklife.com/v1/teams \
  -H "Authorization: Bearer ***

Response 200 OK

The array is nested under data.teams[]:

FieldTypeDescription
data.teams[].idintegerTeam id. Use this for follow-up get/update/delete/user calls.
data.teams[].org_idintegerOwning org id (echoes the API key's org_id).
data.teams[].team_namestringDisplay name. Truncated server-side at 255 chars.
data.teams[].shortcodestring | nullUppercase alphanumeric / dash / underscore; max 50 chars; unique within the org. null if unset.
data.teams[].colorstringLowercase hex #rrggbb used for badges in the UI. Defaults to #007bff.
data.teams[].descriptionstringFree-form description. Empty string when unset.
data.teams[].member_countinteger | nullNumber of rows in team_user_assignments for this team, computed by the list query. 0 for teams with no members.
data.teams[].created_atstring (ISO 8601 datetime)Team creation timestamp.
data.teams[].updated_atstring (ISO 8601 datetime)Last update timestamp.
{
  "success": true,
  "data": {
    "teams": [
      {
        "id":           17,
        "org_id":       42,
        "team_name":    "Mobile Platform",
        "shortcode":    "MOB",
        "color":        "#0d6efd",
        "description":  "iOS + Android client work.",
        "member_count": 4,
        "created_at":   "2026-04-02T11:14:08Z",
        "updated_at":   "2026-06-12T09:31:55Z"
      },
      {
        "id":           18,
        "org_id":       42,
        "team_name":    "Platform Infrastructure",
        "shortcode":    "PLAT",
        "color":        "#198754",
        "description":  "Backend services, CI, observability.",
        "member_count": 7,
        "created_at":   "2026-03-08T16:02:11Z",
        "updated_at":   "2026-07-01T08:14:23Z"
      },
      {
        "id":           19,
        "org_id":       42,
        "team_name":    "Web Frontend",
        "shortcode":    "WEB",
        "color":        "#007bff",
        "description":  "",
        "member_count": 0,
        "created_at":   "2026-05-22T19:48:02Z",
        "updated_at":   "2026-05-22T19:48:02Z"
      }
    ]
  }
}

Errors

{
  "success": false,
  "error": {
    "code":    "FORBIDDEN",
    "message": "Org admin required"
  }
}

POST /teams

Create a new team for the API key's org. The response is the full team object (same envelope as GET /teams/{id}) with the freshly-assigned id and timestamps.

Body parameters

FieldTypeRequiredDefaultDescription
team_name string yes — Display name. Trimmed; truncated to 255 chars by mb_substr(). Must be unique within the org (DB unique index on (org_id, team_name); collisions = 409).
shortcode string | null no null Short identifier (e.g. "PLAT"). Normalized: stripped of everything except [A-Za-z0-9_-], upper-cased, truncated to 50 chars. Must be unique within the org (DB unique index on (org_id, shortcode); collisions = 409).
color string no #007bff Hex color in #RRGGBB format. Anything that doesn't match /^#[0-9a-fA-F]{6}$/ silently falls back to #007bff. Stored lower-cased.
description string no "" Free-form description. Trimmed; no length cap on the wire (DB column is TEXT). Empty string when unset.

Request example

curl -sS -X POST https://api.tasklife.com/v1/teams \
  -H "Authorization: Bearer *** \
  -H "Content-Type: application/json" \
  -d '{
    "team_name":   "Design Systems",
    "shortcode":   "ds",
    "color":       "#6f42c1",
    "description": "Cross-product UI primitives, tokens, and Storybook."
  }'

Response 201 Created

The created team is nested under data.team:

FieldTypeDescription
data.team.idintegerNew team id.
data.team.org_idintegerOwning org id.
data.team.team_namestringEcho of the (normalized) name.
data.team.shortcodestring | nullEcho of the (normalized-uppercase) shortcode, or null.
data.team.colorstringEcho of the (lower-cased) hex color, or #007bff.
data.team.descriptionstringEcho of the description.
data.team.member_countinteger | nullComputed by the same LEFT JOIN as the list endpoint. Always 0 immediately after create.
data.team.created_atstring (ISO 8601 datetime)Server-assigned creation timestamp.
data.team.updated_atstring (ISO 8601 datetime)Server-assigned update timestamp (matches created_at on a brand-new team).
{
  "success": true,
  "data": {
    "team": {
      "id":           21,
      "org_id":       42,
      "team_name":    "Design Systems",
      "shortcode":    "DS",
      "color":        "#6f42c1",
      "description":  "Cross-product UI primitives, tokens, and Storybook.",
      "member_count": 0,
      "created_at":   "2026-07-03T16:22:48Z",
      "updated_at":   "2026-07-03T16:22:48Z"
    }
  }
}

Errors

{
  "success": false,
  "error": {
    "code":    "BAD_REQUEST",
    "message": "Team name is required"
  }
}

GET /teams/{id}

Fetch a single team by id, including its member_count. Returns 404 if the team doesn't exist or belongs to a different org.

Path parameters

FieldTypeRequiredDescription
idintegeryesTeam id.

Request example

curl -sS https://api.tasklife.com/v1/teams/18 \
  -H "Authorization: Bearer ***

Response 200 OK

The single team object is nested under data.team:

FieldTypeDescription
data.team.idintegerTeam id.
data.team.org_idintegerOwning org id.
data.team.team_namestringDisplay name.
data.team.shortcodestring | nullNormalized uppercase shortcode, or null.
data.team.colorstringHex #rrggbb.
data.team.descriptionstringFree-form description.
data.team.member_countinteger | nullNumber of users currently assigned to the team.
data.team.created_atstring (ISO 8601 datetime)Creation timestamp.
data.team.updated_atstring (ISO 8601 datetime)Last update timestamp.
{
  "success": true,
  "data": {
    "team": {
      "id":           18,
      "org_id":       42,
      "team_name":    "Platform Infrastructure",
      "shortcode":    "PLAT",
      "color":        "#198754",
      "description":  "Backend services, CI, observability.",
      "member_count": 7,
      "created_at":   "2026-03-08T16:02:11Z",
      "updated_at":   "2026-07-01T08:14:23Z"
    }
  }
}

Errors

{
  "success": false,
  "error": {
    "code":    "NOT_FOUND",
    "message": "Team not found"
  }
}

PUT /teams/{id} (PATCH is also accepted)

Update one or more fields on a team. The router accepts both PUT (full update) and PATCH (partial update); the difference is the $requireName flag inside orgTeamsBuildPayload(). On PATCH, any field you omit is preserved verbatim — updateOrgTeam() re-reads the row first and merges the existing values under any absent keys. The response is the same envelope as GET /teams/{id}.

Path parameters

FieldTypeRequiredDescription
idintegeryesTeam id.

Body parameters

FieldTypeRequiredDefault on PATCHDescription
team_name string PUT yes / PATCH no existing Display name. Trimmed; truncated to 255 chars. Must be unique within the org.
shortcode string | null no existing Normalize uppercase; strip non [A-Za-z0-9_-]; max 50 chars. Must be unique within the org (collisions with other teams = 409).
color string no existing Hex #rrggbb. Invalid formats silently fall back to #007bff.
description string no existing Free-form description. Trimmed; no wire-side length cap.

Request example (PATCH)

curl -sS -X PATCH https://api.tasklife.com/v1/teams/18 \
  -H "Authorization: Bearer *** \
  -H "Content-Type: application/json" \
  -d '{
    "color":       "#198754",
    "description": "Backend services, CI, observability, and on-call rotation."
  }'

Response 200 OK

The updated team is nested under data.team, same shape as the get endpoint:

{
  "success": true,
  "data": {
    "team": {
      "id":           18,
      "org_id":       42,
      "team_name":    "Platform Infrastructure",
      "shortcode":    "PLAT",
      "color":        "#198754",
      "description":  "Backend services, CI, observability, and on-call rotation.",
      "member_count": 7,
      "created_at":   "2026-03-08T16:02:11Z",
      "updated_at":   "2026-07-03T16:41:09Z"
    }
  }
}

Errors

{
  "success": false,
  "error": {
    "code":    "BAD_REQUEST",
    "message": "Team name is required"
  }
}

DELETE /teams/{id}

Permanently delete a team and cascade-clear every reference to it. The response carries the pre-delete team snapshot alongside "deleted": true — the call returns {"deleted": true, "team": {...}} rather than an empty envelope. All four DB writes happen in one transaction inside deleteOrgTeamSafelyV1().

Path parameters

FieldTypeRequiredDescription
idintegeryesTeam id.

Request example

curl -sS -X DELETE https://api.tasklife.com/v1/teams/19 \
  -H "Authorization: Bearer ***

Response 200 OK

The deleted-team envelope lives under data:

FieldTypeDescription
data.deletedbooleanAlways true on success.
data.team.idintegerThe team id that was just deleted.
data.team.org_idintegerOwning org id (echoes data.team.id's org).
data.team.team_namestringPre-delete team name.
data.team.shortcodestring | nullPre-delete shortcode.
data.team.colorstringPre-delete color.
data.team.descriptionstringPre-delete description.
data.team.member_countinteger | nullPre-delete member count (snapshot).
data.team.created_atstring (ISO 8601 datetime)Team's original creation timestamp.
data.team.updated_atstring (ISO 8601 datetime)Pre-delete update timestamp.
{
  "success": true,
  "data": {
    "deleted": true,
    "team": {
      "id":           19,
      "org_id":       42,
      "team_name":    "Web Frontend",
      "shortcode":    "WEB",
      "color":        "#007bff",
      "description":  "",
      "member_count": 0,
      "created_at":   "2026-05-22T19:48:02Z",
      "updated_at":   "2026-05-22T19:48:02Z"
    }
  }
}

Errors

{
  "success": false,
  "error": {
    "code":    "NOT_FOUND",
    "message": "Team not found"
  }
}

GET /teams/{id}/users

List every user currently assigned to a team. Rows come from team_user_assignments joined against org_users and users, ordered by display_name ASC (with email / id as tie-breakers). Returns 404 if the team doesn't exist or doesn't belong to the API key's org. An empty roster is not an error — it returns 200 with data.users: [].

Path parameters

FieldTypeRequiredDescription
idintegeryesTeam id.

Request example

curl -sS https://api.tasklife.com/v1/teams/18/users \
  -H "Authorization: Bearer ***

Response 200 OK

The user array is nested under data.users[]:

FieldTypeDescription
data.users[].org_user_idintegerOrg-scoped user id. Use this in PUT /teams/{id}/users.
data.users[].org_idintegerOwning org id.
data.users[].user_idinteger | nullGlobal users.id, or null for org-only users.
data.users[].user_emailstringEmail stored on the org_users row.
data.users[].departmentstring | nullDepartment label from org_users.department.
data.users[].roleinteger | null1 = org admin, 0 = regular member.
data.users[].is_section_managerinteger (0 or 1)Whether the user manages a section.
data.users[].statusstring"active" or "inactive". Note: the membership endpoint surfaces inactive users, but PUT /teams/{id}/users rejects inactive ids as a write-side error.
data.users[].namestring | nullDisplay name from the users row (may be empty).
data.users[].emailstringEmail from the users row (falls back to user_email).
data.users[].display_namestringBest-available display name: users.name → org_users.handle → user_email → "User #<id>".
data.users[].user_typestring"human" by default; "ai" or other values on special accounts.
{
  "success": true,
  "data": {
    "users": [
      {
        "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"
      },
      {
        "org_user_id":         422,
        "org_id":              42,
        "user_id":             1184,
        "user_email":          "[email protected]",
        "department":          "Engineering",
        "role":                0,
        "is_section_manager":  0,
        "status":              "active",
        "name":                "Priya Shah",
        "email":               "[email protected]",
        "display_name":        "Priya Shah",
        "user_type":           "human"
      },
      {
        "org_user_id":         501,
        "org_id":              42,
        "user_id":             1255,
        "user_email":          "[email protected]",
        "department":          "Design",
        "role":                0,
        "is_section_manager":  0,
        "status":              "active",
        "name":                "Jordan Lee",
        "email":               "[email protected]",
        "display_name":        "Jordan Lee",
        "user_type":           "human"
      }
    ]
  }
}

Errors

{
  "success": false,
  "error": {
    "code":    "NOT_FOUND",
    "message": "Team not found"
  }
}

PUT /teams/{id}/users

Replace the full membership roster for a team in a single transactional write. The body is a JSON object with an org_user_ids array of integer ids; the alias user_ids is also accepted. Any id that doesn't belong to the API key's org or that isn't currently status = 'active' causes the call to fail with 500 + "One or more users do not belong to this organization or are inactive" (the transaction rolls back). The response returns team_id, assigned_count, the canonical (deduped + integer-cast) org_user_ids array, and the freshly-fetched users[] roster.

Path parameters

FieldTypeRequiredDescription
idintegeryesTeam id.

Body parameters

FieldTypeRequiredDescription
org_user_ids array<integer> yes Full desired roster (replaces existing rows). Deduped and intval-cast server-side. Empty array clears the roster. Every id must belong to the API key's org and have status = 'active'.
user_ids array<integer> no Alias for org_user_ids. Ignored if org_user_ids is also present. Pass as an array even for one id — the router wraps a bare string into an array via is_array() guard.

Request example

curl -sS -X PUT https://api.tasklife.com/v1/teams/18/users \
  -H "Authorization: Bearer *** \
  -H "Content-Type: application/json" \
  -d '{
    "org_user_ids": [318, 422, 501]
  }'

Response 200 OK

The result envelope lives under data with four sibling keys:

FieldTypeDescription
data.team_idintegerEcho of the team id in the path.
data.assigned_countintegerNumber of ids in the canonical (deduped) roster — same as data.org_user_ids' length.
data.org_user_idsarray<integer>The canonical roster the server actually persisted (deduped, integer-cast, > 0-filtered).
data.users[]object[]Full fetchUsersForOrgTeam() output — same shape as GET /teams/{id}/users.
{
  "success": true,
  "data": {
    "team_id":        18,
    "assigned_count": 3,
    "org_user_ids":   [318, 422, 501],
    "users": [
      {
        "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"
      },
      {
        "org_user_id":         422,
        "org_id":              42,
        "user_id":             1184,
        "user_email":          "[email protected]",
        "department":          "Engineering",
        "role":                0,
        "is_section_manager":  0,
        "status":              "active",
        "name":                "Priya Shah",
        "email":               "[email protected]",
        "display_name":        "Priya Shah",
        "user_type":           "human"
      },
      {
        "org_user_id":         501,
        "org_id":              42,
        "user_id":             1255,
        "user_email":          "[email protected]",
        "department":          "Design",
        "role":                0,
        "is_section_manager":  0,
        "status":              "active",
        "name":                "Jordan Lee",
        "email":               "[email protected]",
        "display_name":        "Jordan Lee",
        "user_type":           "human"
      }
    ]
  }
}

Errors

{
  "success": false,
  "error": {
    "code":    "API_ERROR",
    "message": "One or more users do not belong to this organization or are inactive"
  }
}