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-Afterheader. - 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/tokenwithclient_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.
# Get authenticated user's workspaces
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"]
# teams[0]["id"] is your workspace_id
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()
# Set custom fields after creation
if custom_fields:
for field in custom_fields:
set_custom_field(task["id"], field["id"], field["value"])
return task
def set_custom_field(task_id: str, field_id: str, value):
"""Set a single custom field value on a task."""
resp = requests.post(
f"https://api.clickup.com/api/v2/task/{task_id}/field/{field_id}",
headers=HEADERS,
json={"value": value}
)
resp.raise_for_status()
def update_task_status(task_id: str, status: str):
"""Transition a task to a new status.
Status must match exactly (case-sensitive) the status name configured
in the ClickUp workspace.
"""
resp = requests.put(
f"https://api.clickup.com/api/v2/task/{task_id}",
headers=HEADERS,
json={"status": status}
)
resp.raise_for_status()
# Usage
task = create_task(
list_id=LIST_ID,
name="Implement auth middleware",
description="- Add JWT verification\n- Add rate limiting\n- Write tests",
assignees=[123456],
priority=2,
due_date=int(pd.Timestamp("2026-08-15").timestamp() * 1000),
custom_fields=[{"id": "abc123", "value": "backend"}]
)
print(f"Created task: {task['id']} — {task['url']}")
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"]
# Get all "in progress" 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();
}
// Create a task
async function createTask({ name, description, assignees, priority, dueDate }) {
return clickupFetch(`/list/${LIST_ID}/task`, {
method: 'POST',
body: JSON.stringify({
name,
description,
assignees,
priority,
due_date: dueDate ? new Date(dueDate).getTime() : undefined,
}),
});
}
// Bulk status update
async function bulkUpdateStatus(taskIds, status) {
const results = [];
for (const id of taskIds) {
const task = await clickupFetch(`/task/${id}`, {
method: 'PUT',
body: JSON.stringify({ status }),
});
results.push(task);
// Respect rate limits: 100 req/min = 600ms between requests minimum
await new Promise(r => setTimeout(r, 700));
}
return results;
}
// Usage
(async () => {
const task = await createTask({
name: 'Deploy staging environment',
description: 'Configure CI/CD pipeline for staging',
assignees: [123456],
priority: 1,
});
console.log(`Created: ${task.id} — ${task.url}`);
})();
Bash (curl) — Quick API Operations
API_TOKEN="pk_xxxxxxxx"
WORKSPACE_ID="12345678"
LIST_ID="901234567890"
BASE="https://api.clickup.com/api/v2"
# Get all spaces in a workspace
curl -s -H "Authorization: $API_TOKEN" \
"$BASE/team/$WORKSPACE_ID/space" | jq '.spaces[] | {id, name}'
# Create a task
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}'
# Update task status
curl -s -X PUT \
-H "Authorization: $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status":"in review"}' \
"$BASE/task/9abc1234" | jq '.status.status'
# Add time entry (duration in milliseconds)
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'
# Delete a task
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()
# Register to receive all task changes
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)
# In your web handler (Fastify example):
# const signature = req.headers['x-signature'];
# const verified = verifySignature(JSON.stringify(req.body), secret, 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."""
# 1. Create folder for the sprint cycle (optional)
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()
# 2. Create lists within the folder (Backlog, Current Sprint, Done)
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())
# Rate limit: 100 req/min → at most 1 req per 600ms
# Be conservative: wait 700ms between writes
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": "updated"}
)
batch = resp.json()["tasks"]
if not batch:
break
for t in batch:
cf = t.get("custom_fields", [])
for f in cf:
if f["id"] == field_id and f.get("value") == value:
tasks.append(t)
page += 1
time.sleep(0.35) # read operations: 100 req/min → 600ms, reads half
return 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
- API token authenticates successfully (
GET /teamreturns workspaces) - Workspace ID resolves to a valid team
- Space / folder / list IDs exist in the target workspace
- Task creation creates a visible task in the target list (check via UI or GET)
- Status transitions match the exact (case-sensitive) status names configured
- Custom fields set correctly (verify via GET task or UI)
- Assignees are workspace members (user IDs exist in membership list)
- Rate limit does not trigger during bulk operations (<100 req/min sustained)
- Webhook registration receives test event within 30 seconds
- Webhook signature verification passes with the stored secret
- Time entries appear on the task's Time Tracking tab
- Dependency links appear on the task (check "waiting on" / "blocking")
- Error handling covers: 401 (bad token), 404 (wrong ID), 429 (rate limit), 400 (bad payload)
- Pagination works for lists with >100 tasks
- Permission checks: tasks created programmatically respect the hierarchy access levels
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 aGET /teamcall
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