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 status | When | What to do |
|---|---|---|
400 | Request 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. |
403 | API 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'. |
404 | Team doesn't exist or belongs to a different org. | Verify the ID; the team resource is org-scoped. |
409 | shortcode 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. |
500 | Unexpected 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).
⚠️ Teams-specific gotchas
- Every Teams endpoint requires an org-admin API key — the router enters
require_api_org_admin($conn, $key_data)on line 1070 ofexternal-api/v1/index.phpbefore any handler runs. That helper rejects keys whose owningorg_users.roleis not1(admin) or whosestatusisn't'active'with403 — Org admin required. Non-admin tokens can list no teams and create no teams — they get403onGET /v1/teamsjust like on writes. shortcodeis normalized to uppercase, alphanumeric/underscore/dash only, and must be unique within the org —orgTeamsValidateShortcode()strips everything except[A-Za-z0-9_-], upper-cases the result, and truncates to50chars. The DB has a unique index on(org_id, shortcode)so duplicates return409. Sending"plat-design"is stored as"PLAT-DESIGN"; sending"plat design!"is stored as"PLATDESIGN".colormust be a 6-digit hex string of the form#RRGGBB—orgTeamsNormalizeColor()accepts only a string that matches/^#[0-9a-fA-F]{6}$/. It thenstrtolowers the value. Anything else (empty string, 3-digit shorthand, named colors like"blue", plain"007bff"with no leading#) silently falls back to the default#007bff— not a validation error.team_nameis required on POST and on PUT — PATCH makes it optional —orgTeamsBuildPayload()uses a$requireNameflag that maps totrueon POST and to!$partialon PUT/PATCH. On PATCH, omitted fields are not overwritten:updateOrgTeam()re-reads the row and merges the existing values under any key absent from the request. Server-sideteam_nameis capped at255chars viamb_substr()and the DB column isVARCHAR(255) NOT NULLwith a unique index on(org_id, team_name)(rename collisions =409too).DELETE /v1/teams/{id}cascades three ways —deleteOrgTeamSafelyV1()runs all four operations inside a single DB transaction: (1)DELETE FROM team_user_assignments WHERE team_id = ?clears the membership roster, (2)UPDATE project_products SET team_id = NULL WHERE org_id = ? AND team_id = ?clears team ownership on products, (3)UPDATE projects SET team_id = NULLon projects, (4)UPDATE cms_pages SET team_id = NULLon CMS pages, then (5) theDELETE FROM teamsitself. The response returns the pre-delete team snapshot alongside"deleted": true.- Membership endpoints are bidirectional with
/users/{id}/teams—PUT /v1/teams/{id}/userswrites to the sameteam_user_assignmentstable that the user-side endpoint (PUT /v1/users/{id}/teams) writes to. Both endpoints fully replace the roster on their respective axis (team ↔ users) in one transaction, so updating one and immediately reading the other is safe. Inactive org users are excluded by both writes:replaceTeamAssignmentsForTeam()requires every supplied id to currently be an active member of the org, otherwise it throws"One or more users do not belong to this organization or are inactive". PUT /v1/teams/{id}/usersaccepts the aliasuser_ids— the router reads$payload['org_user_ids']first, falls back to$payload['user_ids'], and wraps a bare string in an array viais_array()guard. After dedup /intval/> 0filtering the response echoes back the canonicalorg_user_idslist, anassigned_count, and the freshly-fetcheddata.users[]roster.
Endpoints at a glance
| Action | Method + Path | Summary |
|---|---|---|
| List teams | GET /teams | List every team for the API key's org, with member_count. Sorted by team_name ASC. |
| Create a team | POST /teams | Create a team. Required: team_name. Returns 201. |
| Get a team | GET /teams/{id} | Fetch a single team by id, including member_count. |
| Update a team | PUT /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 team | DELETE /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 members | GET /teams/{id}/users | List the users currently assigned to a team, ordered by display name. |
| Replace team roster | PUT /teams/{id}/users | Replace 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.
403 — Org admin required from require_api_org_admin() before the handler runs.
Request example
curl -sS https://api.tasklife.com/v1/teams \
-H "Authorization: Bearer ***
Response 200 OK
The array is nested under data.teams[]:
| Field | Type | Description |
|---|---|---|
data.teams[].id | integer | Team id. Use this for follow-up get/update/delete/user calls. |
data.teams[].org_id | integer | Owning org id (echoes the API key's org_id). |
data.teams[].team_name | string | Display name. Truncated server-side at 255 chars. |
data.teams[].shortcode | string | null | Uppercase alphanumeric / dash / underscore; max 50 chars; unique within the org. null if unset. |
data.teams[].color | string | Lowercase hex #rrggbb used for badges in the UI. Defaults to #007bff. |
data.teams[].description | string | Free-form description. Empty string when unset. |
data.teams[].member_count | integer | null | Number of rows in team_user_assignments for this team, computed by the list query. 0 for teams with no members. |
data.teams[].created_at | string (ISO 8601 datetime) | Team creation timestamp. |
data.teams[].updated_at | string (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.
team_name. orgTeamsBuildPayload() throws "Team name is required" (router converts this to 400 BAD_REQUEST) when the field is missing or empty. All other fields (shortcode, color, description) are optional.
Body parameters
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
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:
| Field | Type | Description |
|---|---|---|
data.team.id | integer | New team id. |
data.team.org_id | integer | Owning org id. |
data.team.team_name | string | Echo of the (normalized) name. |
data.team.shortcode | string | null | Echo of the (normalized-uppercase) shortcode, or null. |
data.team.color | string | Echo of the (lower-cased) hex color, or #007bff. |
data.team.description | string | Echo of the description. |
data.team.member_count | integer | null | Computed by the same LEFT JOIN as the list endpoint. Always 0 immediately after create. |
data.team.created_at | string (ISO 8601 datetime) | Server-assigned creation timestamp. |
data.team.updated_at | string (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
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | yes | Team 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:
| Field | Type | Description |
|---|---|---|
data.team.id | integer | Team id. |
data.team.org_id | integer | Owning org id. |
data.team.team_name | string | Display name. |
data.team.shortcode | string | null | Normalized uppercase shortcode, or null. |
data.team.color | string | Hex #rrggbb. |
data.team.description | string | Free-form description. |
data.team.member_count | integer | null | Number of users currently assigned to the team. |
data.team.created_at | string (ISO 8601 datetime) | Creation timestamp. |
data.team.updated_at | string (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}.
team_name; PATCH makes all fields optional. A PUT that omits team_name returns 400 — Team name is required. Use PATCH for surgical updates like "just change the color".
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | yes | Team id. |
Body parameters
| Field | Type | Required | Default on PATCH | Description |
|---|---|---|---|---|
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().
DELETE FROM team_user_assignments WHERE team_id = ?— membership roster is wiped.UPDATE project_products SET team_id = NULL WHERE org_id = ? AND team_id = ?— any product owned by this team is detached (not deleted).UPDATE projects SET team_id = NULL WHERE org_id = ? AND team_id = ?— same for projects.UPDATE cms_pages SET team_id = NULL WHERE org_id = ? AND team_id = ?— same for CMS pages.DELETE FROM teams WHERE id = ? AND org_id = ?— the team row itself.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | yes | Team 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:
| Field | Type | Description |
|---|---|---|
data.deleted | boolean | Always true on success. |
data.team.id | integer | The team id that was just deleted. |
data.team.org_id | integer | Owning org id (echoes data.team.id's org). |
data.team.team_name | string | Pre-delete team name. |
data.team.shortcode | string | null | Pre-delete shortcode. |
data.team.color | string | Pre-delete color. |
data.team.description | string | Pre-delete description. |
data.team.member_count | integer | null | Pre-delete member count (snapshot). |
data.team.created_at | string (ISO 8601 datetime) | Team's original creation timestamp. |
data.team.updated_at | string (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
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | yes | Team 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[]:
| Field | Type | Description |
|---|---|---|
data.users[].org_user_id | integer | Org-scoped user id. Use this in PUT /teams/{id}/users. |
data.users[].org_id | integer | Owning org id. |
data.users[].user_id | integer | null | Global users.id, or null for org-only users. |
data.users[].user_email | string | Email stored on the org_users row. |
data.users[].department | string | null | Department label from org_users.department. |
data.users[].role | integer | null | 1 = org admin, 0 = regular member. |
data.users[].is_section_manager | integer (0 or 1) | Whether the user manages a section. |
data.users[].status | string | "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[].name | string | null | Display name from the users row (may be empty). |
data.users[].email | string | Email from the users row (falls back to user_email). |
data.users[].display_name | string | Best-available display name: users.name → org_users.handle → user_email → "User #<id>". |
data.users[].user_type | string | "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.
replaceTeamAssignmentsForTeam() issues a DELETE FROM team_user_assignments WHERE team_id = ? before any inserts. To remove a single member, re-submit the desired roster minus that id (or submit {"org_user_ids": []} to clear entirely).
/users/{id}/teams. The user-side roster endpoint writes the same team_user_assignments table. Pick whichever axis is more convenient — the two are kept consistent because each operates as a transactional full-replace on its own axis.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | yes | Team id. |
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
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:
| Field | Type | Description |
|---|---|---|
data.team_id | integer | Echo of the team id in the path. |
data.assigned_count | integer | Number of ids in the canonical (deduped) roster — same as data.org_user_ids' length. |
data.org_user_ids | array<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"
}
}