| name | clickup |
| description | Skill: clickup. See SKILL.md body for details. Use when this domain is relevant. |
| domain | operations |
| author | oyi77 |
| license | Apache-2.0 |
| subdomain | business-operations |
| tags | ["business-ops","clickup","management","operations"] |
| version | 1.0.0 |
When to Use
Trigger phrases:
- "clickup"
- "clickup api"
- "task management", "project management"
- "automate tasks"
- "clickup workflow"
- "clickup webhook"
Situations:
- Automating task creation, updates, and status transitions via the ClickUp API
- Building integrations that read/write ClickUp tasks, lists, folders, or spaces
- Setting up webhook-driven workflows for real-time ClickUp event processing
- Managing sprints, dependencies, or time tracking programmatically
- Migrating from another tool (Jira, Asana, Trello) to ClickUp
- Bulk operations on tasks — updating custom fields, reassigning, batch status changes
API version reference: This skill targets the ClickUp REST API v2 (/api/v2/). The API base URL is https://api.clickup.com/api/v2.
When NOT to Use
- Real-time collaborative editing of ClickUp Docs (the API does not support inline doc editing — only create/read)
- ClickUp automations that are simpler to configure in the UI (the built-in Automation module handles triggers/actions without code)
- Large-scale data migration without testing rate limits first (100 req/min per workspace)
- When the team uses a fundamentally different paradigm (e.g., linear kanban-only flow) — ClickUp is optimized for hierarchy (Workspace → Space → Folder → List → Task)
- For embedding ClickUp in a public-facing product (the API is designed for internal tooling, not customer-facing SaaS)
Overview
ClickUp is a unified project management platform built on a hierarchical structure: Workspace → Space → Folder → List → Task. The REST API v2 exposes CRUD operations at every level, plus specialized endpoints for time tracking, goals, docs, dependencies, and custom fields.
API base URL: https://api.clickup.com/api/v2
Authentication: Pass API token via Authorization: pk_XXXXXXXX header, or use OAuth 2.0 for user-installed integrations.
Key design constraints:
- Rate limit: 100 requests per minute per workspace. Exceeding this returns HTTP 429 with a
Retry-After header.
- All task IDs, list IDs, folder IDs, and space IDs are workspace-scoped — never assume IDs are global.
- The hierarchy path from Workspace down to Task is required for most write operations. Many endpoints accept either the location ID (list/folder/space) or a direct task ID.
- ClickUp uses statuses as strings, but they must match exactly the status names configured in the workspace (case-sensitive).
Hierarchy Map
Workspace (team_id)
└── Space (space_id)
├── Folder (folder_id)
│ └── List (list_id)
│ └── Task (task_id)
└── List (list_id) — a list can exist directly in a space without a folder
└── Task (task_id)
Tasks can optionally have:
- Subtasks — child tasks linked to a parent
- Checklists — inline checklists within a task
- Dependencies — blocking/blocked-by relationships between tasks
- Custom Fields — typed fields (text, number, date, dropdown, labels, etc.)
- Time Estimates & Time Tracked — duration tracking per task
Authentication
API Token (Recommended for server-side automation)
- Go to ClickUp Settings → ClickUp API → Generate API Token
- The token is a string starting with
pk_
- Pass it in every request as a header:
Authorization: pk_XXXXXXXXXXXXXX
The API token has the same permissions as the user who generated it.
OAuth 2.0 (For multi-user integrations)
OAuth is required if your integration will be installed by multiple ClickUp users (e.g., a marketplace app).
- Register an app in ClickUp Settings → Integrations → OAuth Apps
- Redirect URI:
https://your-app.com/oauth/callback
- Request URL:
https://app.clickup.com/api?client_id=CLIENT_ID&redirect_uri=REDIRECT_URI
- Token exchange:
POST https://api.clickup.com/api/v2/oauth/token with client_id, client_secret, code
The OAuth token is used identically to an API token (passed as Authorization: Bearer <token>).
Workflow
1. Identify Workspace Context
Before any API call, resolve the workspace and location IDs.
import requests
API_TOKEN = "pk_xxxxxxxx"
HEADERS = {"Authorization": API_TOKEN}
resp = requests.get("https://api.clickup.com/api/v2/team", headers=HEADERS)
teams = resp.json()["teams"]
2. Resolve Space / Folder / List IDs
Map from names to IDs. Collect these once and cache them (they rarely change).
def get_spaces(workspace_id: str) -> list[dict]:
resp = requests.get(
f"https://api.clickup.com/api/v2/team/{workspace_id}/space",
headers=HEADERS
)
return resp.json()["spaces"]
def get_folders(space_id: str) -> list[dict]:
resp = requests.get(
f"https://api.clickup.com/api/v2/space/{space_id}/folder",
headers=HEADERS
)
return resp.json()["folders"]
def get_lists(folder_id: str) -> list[dict]:
resp = requests.get(
f"https://api.clickup.com/api/v2/folder/{folder_id}/list",
headers=HEADERS
)
return resp.json()["lists"]
3. Execute Task Operations
Create, read, update, delete, and query tasks.
4. Verify
Confirm the operation via the ClickUp UI or a GET request.
Core API Endpoints
Tasks
| Method | Endpoint | Purpose |
|---|
GET | /api/v2/task/{task_id} | Get a single task |
POST | /api/v2/list/{list_id}/task | Create a task in a list |
PUT | /api/v2/task/{task_id} | Update a task |
DELETE | /api/v2/task/{task_id} | Delete a task |
GET | /api/v2/list/{list_id}/task | Get tasks in a list (with filters) |
POST | /api/v2/task/{task_id}/checklist/{checklist_id}/checklist_item | Add checklist item |
POST | /api/v2/task/{task_id}/link | Create task dependency |
Lists, Folders, Spaces
| Method | Endpoint | Purpose |
|---|
GET | /api/v2/list/{list_id} | Get list details |
POST | /api/v2/folder/{folder_id}/list | Create a list |
PUT | /api/v2/list/{list_id} | Update a list |
GET | /api/v2/space/{space_id} | Get space details |
PUT | /api/v2/space/{space_id} | Update space |
Time Tracking
| Method | Endpoint | Purpose |
|---|
GET | /api/v2/task/{task_id}/time | Get time entries for a task |
POST | /api/v2/task/{task_id}/time | Add time entry |
PUT | /api/v2/time/{time_entry_id} | Update time entry |
DELETE | /api/v2/time/{time_entry_id} | Delete time entry |
Goals (formerly OKRs)
| Method | Endpoint | Purpose |
|---|
GET | /api/v2/team/{team_id}/goal | List goals in workspace |
POST | /api/v2/team/{team_id}/goal | Create a goal |
PUT | /api/v2/goal/{goal_id} | Update a goal |
POST | /api/v2/goal/{goal_id}/key_result | Add a key result to a goal |
Custom Fields
| Method | Endpoint | Purpose |
|---|
GET | /api/v2/list/{list_id}/field | Get custom fields for a list |
POST | /api/v2/task/{task_id}/field/{field_id} | Set a custom field value |
Dependencies
| Method | Endpoint | Purpose |
|---|
POST | /api/v2/task/{task_id}/link | Link tasks with dependency |
DELETE | /api/v2/task/{task_id}/link/{links_to} | Remove a dependency link |
Webhooks
| Method | Endpoint | Purpose |
|---|
GET | /api/v2/team/{team_id}/webhook | List registered webhooks |
POST | /api/v2/team/{team_id}/webhook | Create a webhook |
DELETE | /api/v2/webhook/{webhook_id} | Delete a webhook |
Code Examples
Python — Create a Task with Custom Fields
import requests
import json
API_TOKEN = "pk_xxxxxxxx"
LIST_ID = "901234567890"
HEADERS = {
"Authorization": API_TOKEN,
"Content-Type": "application/json"
}
def create_task(list_id: str, name: str, description: str = "",
assignees: list[int] | None = None,
priority: int | None = None,
due_date: int | None = None,
custom_fields: list[dict] | None = None) -> dict:
"""Create a ClickUp task.
Args:
list_id: ID of the target list
name: Task name (required)
description: Markdown task description
assignees: List of ClickUp user IDs
priority: 1 (urgent), 2 (high), 3 (normal), 4 (low)
due_date: Unix timestamp in milliseconds
custom_fields: List of {id, value} dicts
"""
payload = {"name": name}
if description:
payload["description"] = description
if assignees:
payload["assignees"] = assignees
if priority:
payload["priority"] = priority
if due_date:
payload["due_date"] = due_date
resp = requests.post(
f"https://api.clickup.com/api/v2/list/{list_id}/task",
headers=HEADERS,
json=payload
)
resp.raise_for_status()
task = resp.json()
custom_fields:
field custom_fields:
set_custom_field(task[], field[], field[])
task
():
resp = requests.post(
,
headers=HEADERS,
json={: value}
)
resp.raise_for_status()
():
resp = requests.put(
,
headers=HEADERS,
json={: status}
)
resp.raise_for_status()
task = create_task(
list_id=LIST_ID,
name=,
description=,
assignees=[],
priority=,
due_date=(pd.Timestamp().timestamp() * ),
custom_fields=[{: , : }]
)
()
Python — Query Tasks with Filters
def get_tasks(list_id: str, status: str | None = None,
assignee: int | None = None,
page: int = 0) -> list[dict]:
"""Get tasks from a list with optional filters.
ClickUp paginates at 100 tasks per page. Use `page` to iterate.
"""
params = {"page": page, "order_by": "updated"}
if status:
params["statuses[0]"] = status
if assignee:
params["assignees[0]"] = str(assignee)
resp = requests.get(
f"https://api.clickup.com/api/v2/list/{list_id}/task",
headers=HEADERS,
params=params
)
resp.raise_for_status()
return resp.json()["tasks"]
tasks = get_tasks(LIST_ID, status="in progress")
for t in tasks:
print(f"{t['id']}: {t['name']} (updated {t['date_updated']})")
JavaScript / Node.js — Task Management
const API_TOKEN = 'pk_xxxxxxxx';
const LIST_ID = '901234567890';
const BASE = 'https://api.clickup.com/api/v2';
async function clickupFetch(endpoint, options = {}) {
const url = `${BASE}${endpoint}`;
const resp = await fetch(url, {
...options,
headers: {
'Authorization': API_TOKEN,
'Content-Type': 'application/json',
...options.headers,
},
});
if (!resp.ok) {
const body = await resp.text();
throw new Error(`ClickUp API ${resp.status}: ${body}`);
}
return resp.json();
}
async function createTask({ name, description, assignees, priority, dueDate }) {
return clickupFetch(`/list/${LIST_ID}/task`, {
method: 'POST',
: .({
name,
description,
assignees,
priority,
: dueDate ? (dueDate).() : ,
}),
});
}
() {
results = [];
( id taskIds) {
task = (, {
: ,
: .({ status }),
});
results.(task);
( (r, ));
}
results;
}
( () => {
task = ({
: ,
: ,
: [],
: ,
});
.();
})();
Bash (curl) — Quick API Operations
API_TOKEN="pk_xxxxxxxx"
WORKSPACE_ID="12345678"
LIST_ID="901234567890"
BASE="https://api.clickup.com/api/v2"
curl -s -H "Authorization: $API_TOKEN" \
"$BASE/team/$WORKSPACE_ID/space" | jq '.spaces[] | {id, name}'
curl -s -X POST \
-H "Authorization: $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Fix login bug","priority":1,"assignees":[123456]}' \
"$BASE/list/$LIST_ID/task" | jq '{id, name, url}'
curl -s -X PUT \
-H "Authorization: $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status":"in review"}' \
"$BASE/task/9abc1234" | jq '.status.status'
curl -s -X POST \
-H "Authorization: $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"duration":3600000,"description":"Code review session"}' \
"$BASE/task/9abc1234/time" | jq '.id'
curl -s -X DELETE \
-H "Authorization: $API_TOKEN" \
"$BASE/task/9abc1234"
Webhook Integration
ClickUp webhooks fire HTTP POST requests to your endpoint when specified events occur. They are registered per workspace.
Registering a Webhook
def register_webhook(workspace_id: str, endpoint_url: str,
events: list[str] | None = None) -> dict:
"""Register a ClickUp webhook.
Events: taskCreated, taskUpdated, taskDeleted, taskStatusUpdated,
taskPriorityUpdated, taskAssigneedUpdated, listCreated,
listUpdated, listDeleted, folderCreated, folderUpdated,
folderDeleted, spaceCreated, spaceUpdated, spaceDeleted,
goalCreated, goalUpdated, goalDeleted, goalTargetCreated,
goalTargetUpdated, goalTargetDeleted
"""
payload = {
"endpoint": endpoint_url,
"events": events or ["taskCreated", "taskUpdated", "taskDeleted"]
}
resp = requests.post(
f"https://api.clickup.com/api/v2/team/{workspace_id}/webhook",
headers=HEADERS,
json=payload
)
resp.raise_for_status()
return resp.json()
webhook = register_webhook("12345678", "https://my-app.com/clickup-webhook")
print(f"Webhook ID: {webhook['id']} — secret: {webhook.get('secret', 'N/A')}")
Webhook Payload Structure
ClickUp sends the following JSON body via POST to your endpoint:
{
"webhook_id": "abc-123-def",
"event": "taskUpdated",
"task_id": "9abc1234",
"history_items": [
{
"id": "12345",
"type": 1,
"field": "status",
"before": {"status": "to do"},
"after": {"status": "in progress"}
}
]
}
Verifying Webhook Signature
ClickUp signs webhook payloads with HMAC-SHA256 using the webhook secret returned during registration.
import hmac
import hashlib
def verify_clickup_webhook(payload_body: bytes, signature: str,
secret: str) -> bool:
"""Verify a ClickUp webhook HMAC signature.
The signature is in the X-Signature header of the webhook request.
"""
expected = hmac.new(
secret.encode(), payload_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
Common Patterns
Pattern 1: Sprint Management with Status Workflow
ClickUp does not have native "sprints" as a first-class concept. The pattern is to use a list per sprint or a custom field for sprint name with status transitions.
def setup_sprint_list(workspace_id: str, space_id: str,
sprint_name: str) -> dict:
"""Create a sprint list inside a folder or space."""
folder_resp = requests.post(
f"https://api.clickup.com/api/v2/space/{space_id}/folder",
headers=HEADERS,
json={"name": f"Sprint {sprint_name}"}
)
folder = folder_resp.json()
for list_name in ["Backlog", "Current Sprint", "Done"]:
requests.post(
f"https://api.clickup.com/api/v2/folder/{folder['id']}/list",
headers=HEADERS,
json={"name": list_name}
)
return folder
def move_task_to_sprint(task_id: str, sprint_list_id: str):
"""Move an existing task into a sprint list."""
resp = requests.put(
f"https://api.clickup.com/api/v2/task/{task_id}",
headers=HEADERS,
json={"list": {"id": sprint_list_id}}
)
resp.raise_for_status()
Pattern 2: Task Dependencies
Tasks can be linked with "waiting on" / "blocking" relationships.
def add_dependency(task_id: str, depends_on_id: str):
"""Make task_id depend on depends_on_id (task_id blocked by depends_on_id)."""
resp = requests.post(
f"https://api.clickup.com/api/v2/task/{task_id}/link",
headers=HEADERS,
json={
"depends_on": depends_on_id,
"depends_on_links_to": task_id
}
)
resp.raise_for_status()
def get_dependent_tasks(task_id: str) -> dict:
"""Get dependency info for a task (included in the task response)."""
resp = requests.get(
f"https://api.clickup.com/api/v2/task/{task_id}",
headers=HEADERS,
params={"include": ["dependencies"]}
)
return resp.json().get("dependencies", {})
Pattern 3: Time Tracking Automation
def log_time(task_id: str, duration_minutes: int,
description: str = "", billable: bool = True) -> dict:
"""Log time against a task.
Duration is in milliseconds for the API.
"""
duration_ms = duration_minutes * 60 * 1000
resp = requests.post(
f"https://api.clickup.com/api/v2/task/{task_id}/time",
headers=HEADERS,
json={
"duration": duration_ms,
"description": description,
"billable": billable
}
)
resp.raise_for_status()
return resp.json()
def get_time_for_task(task_id: str) -> int:
"""Get total tracked time for a task in milliseconds."""
resp = requests.get(
f"https://api.clickup.com/api/v2/task/{task_id}/time",
headers=HEADERS
)
entries = resp.json().get("data", [])
return sum(e["duration"] for e in entries)
Pattern 4: Permission Hierarchy Access
ClickUp permission levels cascade: Workspace → Space → Folder → List → Task. A user with access to a space automatically has access to all folders, lists, and tasks within it, unless restricted by sharing settings.
def get_space_members(space_id: str) -> list[dict]:
"""Get all members of a space (includes all sub-folders/lists/tasks)."""
resp = requests.get(
f"https://api.clickup.com/api/v2/space/{space_id}",
headers=HEADERS,
params={"include": ["members"]}
)
return resp.json().get("members", [])
Pattern 5: Bulk Operations with Rate Limit Handling
import time
def bulk_update_tasks(tasks: list[dict], batch_size: int = 20):
"""Update multiple tasks with rate-limit awareness.
Each update counts toward the 100 req/min per workspace limit.
With n tasks and batch_size items per request (requires 1 req per task
since ClickUp doesn't support batch update natively), space requests
with batch_size × min_delay to stay under the limit.
"""
results = []
for i, task_update in enumerate(tasks):
task_id = task_update.pop("id")
resp = requests.put(
f"https://api.clickup.com/api/v2/task/{task_id}",
headers=HEADERS,
json=task_update
)
resp.raise_for_status()
results.append(resp.json())
if i < len(tasks) - 1:
time.sleep(0.7)
return results
def find_tasks_by_custom_field(list_id: str, field_id: str,
value) -> list[dict]:
"""Find tasks by a custom field value.
ClickUp doesn't support filtering by custom field directly.
Fetch all tasks and filter client-side.
"""
tasks = []
page = 0
while True:
resp = requests.get(
f"https://api.clickup.com/api/v2/list/{list_id}/task",
headers=HEADERS,
params={"page": page, "order_by": }
)
batch = resp.json()[]
batch:
t batch:
cf = t.get(, [])
f cf:
f[] == field_id f.get() == value:
tasks.append(t)
page +=
time.sleep()
tasks
Pattern 6: Recurring Task Creation
from datetime import datetime, timedelta
def create_recurring_tasks(list_id: str, base_name: str,
template: dict, weeks: int = 4) -> list[dict]:
"""Create a series of recurring tasks with offset due dates."""
tasks = []
for week in range(weeks):
due = datetime.now() + timedelta(weeks=week)
task_data = {
"name": f"{base_name} — Week {week + 1}",
"due_date": int(due.timestamp() * 1000),
**template
}
resp = requests.post(
f"https://api.clickup.com/api/v2/list/{list_id}/task",
headers=HEADERS,
json=task_data
)
resp.raise_for_status()
tasks.append(resp.json())
time.sleep(0.7)
return tasks
Red Flags
| Risk | Symptom | Mitigation |
|---|
| Rate limit (HTTP 429) | "Rate limit exceeded" with Retry-After header | Implement exponential backoff. Start with 700ms between write requests, 350ms between reads. Cache list/folder/space IDs. |
| Stale API token | HTTP 401 after token regeneration | Rotate tokens in a shared config (env var, vault) and restart any long-running processes. |
| Wrong workspace ID | HTTP 404 on team endpoints | The team_id is the workspace ID from /team endpoint. Always resolve dynamically rather than hardcoding. |
| Invalid status name | HTTP 400 "Invalid status" | Statuses are case-sensitive and must match exactly what's configured in the ClickUp workspace. Use GET /list/{id} to fetch available statuses. |
| Assignee not in workspace | HTTP 400 on assignee field | Verify user IDs belong to the workspace membership list before assigning. |
| Missing custom field | HTTP 400 "Field not found" | Custom field IDs are list-scoped. Fetch valid fields with GET /list/{list_id}/field before referencing them. |
| Task moved to different list | Task returns with different list_id | Always re-fetch the task before updating, or pass list in the update payload. |
| Webhook secret changes | Signature verification fails | Webhook secret is returned only at creation. Store it securely immediately. If lost, delete and re-create the webhook. |
| Nested subtask depth limit | Can't create subtask of subtask | ClickUp allows only one level of subtasks. Use checklists for deeper nesting within a subtask. |
| ID type confusion | Mixing up task vs. list vs. folder IDs | Prefix or track the type alongside the ID in your code. A task ID and a list ID can look identical (numeric string). |
| Markdown in descriptions | Formatting not rendering | ClickUp accepts markdown in descriptions. Test your markdown rendering — ClickUp's parser may differ from GitHub's. |
Verification
Anti-Rationalization Table
| Rationalization | Reality |
|---|
| "ClickUp's API is just like Jira's" | ClickUp uses a hierarchical model (Space→Folder→List→Task). Jira uses a flat project→issue model. ID scoping, permission inheritance, and custom fields work differently. |
| "We can hardcode the workspace ID" | Workspace IDs change when migrating environments or restructuring. Always resolve /team dynamically. |
| "Rate limits won't affect us at our scale" | 100 req/min per workspace is tight. Two concurrent integrations can exhaust the limit. Every read operation counts. |
| "Status names are the same for all lists" | Each list can have its own set of statuses with different names across lists in the same workspace. Always verify per list. |
| "Custom field IDs are globally unique" | Custom field IDs are scoped to a list. The same field name in different lists has different IDs. Always fetch per list. |
| "OAuth is always better than API token" | For server-side automation, API tokens are simpler. OAuth adds redirect handling, token refresh, and scope management overhead. Use API tokens unless you need per-user authorization. |
| "ClickUp's API supports batch operations" | There is no batch endpoint for tasks. Each task create/update is a separate request. Batch must be implemented client-side with rate-limit pacing. |
| "Webhook guarantees delivery" | Webhooks are at-most-once delivery. If your endpoint is down, the event is lost. Build idempotent handlers and implement periodic reconciliation syncs. |
| "ClickUp vs Asana: Asana has better dependencies" | Asana has superior multi-level dependency tracking. ClickUp dependencies are one-to-one linking. For complex Gantt-style dependency chains, evaluate whether Asana is a better fit. |
| "ClickUp Docs API allows full editing" | The Docs API is read-only for content. You can create a new doc from a markdown template, but inline editing of existing docs requires the UI. |
| "We can move tasks between workspaces via API" | Tasks cannot be moved between workspaces via the API. Export/import is the only option for cross-workspace migration. |
| "The API token has no limits" | The API token inherits the user's role permissions. If the user lacks access to a space, the token can't access it either. |
Process
Phase 1: Discovery
- Authenticate — Generate API token or complete OAuth flow
- Map workspace structure — Resolve team_id, space IDs, folder IDs, list IDs
- Inspect schemas — Fetch available statuses per list (
GET /list/{id}), custom fields (GET /list/{id}/field), and members (GET /space/{id})
- Set up environment — Store API token in env var (
CLICKUP_API_TOKEN), verify connectivity with a GET /team call
Phase 2: Implementation
- Choose pattern — Single task ops, bulk operations, webhook-driven, or recurrent scheduling
- Build CRUD wrappers — Implement authenticated HTTP client with error retry logic
- Add rate-limit pacing — Integrate delay logic or a simple token-bucket to stay under 100 req/min
- Handle errors — Map HTTP status codes to actionable errors (401 → token refresh, 404 → re-resolve IDs, 429 → backoff, 400 → inspect payload)
- Test with a single operation — Create one task, verify in UI, then delete
Phase 3: Validation
- Run integration test — Execute the full workflow (create → assign → set status → add time → verify via GET)
- Verify error paths — Bad token, wrong ID, rate limit (burst 101 requests), invalid status
- Check webhook delivery — Register a test webhook, trigger an event, confirm receipt on your endpoint
- Review rate limit compliance — Count requests over a 60-second sliding window; if approaching 100, increase wait intervals
- Document assumptions — Record which workspace, list IDs, and status names are assumed, and what to change if they vary