Everything is under /api/* on your deployment, JSON in and out, timestamps in epoch milliseconds, ids as short prefixed strings (sp_ space, ls_ list, tk_ task, sb_ subtask, cm_ comment, at_ attachment, us_ user, ar_ automation rule, ak_ API key).
All examples assume:
export FLOW=https://flow.example.com
export TOKEN=flow_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
alias flowcurl='curl -sS -H "Authorization: Bearer $TOKEN"'Auth. Every route requires either a flow_ bearer token or a Cloudflare Access JWT, except GET /api/health (public) and POST /api/inbound/:listId (authenticated per-list by its own inb_ token). "Admin" below means the owner or admin role. Reads and writes are additionally filtered by per-space visibility.
Errors. Always {"error": "<one readable sentence>"}. Validation failures are 422 with every Zod issue flattened into the one line. Domain errors keep the DO's own message (Unknown status "Blocked" for list ls_x. Valid statuses (in order): ...) — 404 when it names a missing entity, 422 otherwise. 401 no/bad credential, 403 authenticated but not permitted, 413 attachment over 100 MB, 416 unsatisfiable Range, 500 logged and never leaked.
| Method + path | Auth | What |
|---|---|---|
GET /api/health |
none | Liveness: {"ok":true,"service":"flow","ts":...} |
GET /api/me |
any | The caller: {user, via, apiKey} — apiKey present on the bearer path |
GET /api/snapshot |
any | The whole board: spaces, lists, tasks (slimmed), subtasks, users, automation rules, and the current seq |
GET /api/users |
any | Members, for assignee pickers |
flowcurl $FLOW/api/me
flowcurl $FLOW/api/snapshotGET /api/snapshot is for one-shot consumers (scripts, backups). Live clients connect to /ws and apply deltas — never poll this to refresh a board.
Writes and member routes require admin.
| Method + path | Auth | What |
|---|---|---|
GET /api/spaces |
any | All visible spaces |
GET /api/spaces/:spaceId |
any | One space + its lists |
POST /api/spaces |
admin | Create: {name, color?, visibility?} — born "private" (creator auto-added as sole member) unless you pass "workspace" |
PATCH /api/spaces/:spaceId |
admin | {name?, color?, archived?, position?, visibility?} |
DELETE /api/spaces/:spaceId |
admin | Delete (refused while it still has lists) |
GET /api/spaces/:spaceId/members |
any | {spaceId, userIds} |
PUT /api/spaces/:spaceId/members |
admin | Replace the membership wholesale: {userIds} |
flowcurl -X POST $FLOW/api/spaces -H 'Content-Type: application/json' \
-d '{"name":"Marketing","color":"#7c5cff"}'
# Make it private, then say who's in it (PUT replaces the whole set)
flowcurl -X PATCH $FLOW/api/spaces/sp_abc123 \
-H 'Content-Type: application/json' -d '{"visibility":"private"}'
flowcurl -X PUT $FLOW/api/spaces/sp_abc123/members \
-H 'Content-Type: application/json' -d '{"userIds":["us_one","us_two"]}'Visibility is "private" (default — owners/admins + the member list, with the creator auto-added as the sole member) or "workspace" (everyone; pass it explicitly on create to opt out of the private default). Flipping it, or editing membership, pushes {"type":"resync"} to the affected WebSocket clients.
Writes require admin. Statuses live on the list: at least 2, exactly one open first and one closed last, custom between; omitting statuses on create gives To Do → In Progress → Done.
| Method + path | Auth | What |
|---|---|---|
GET /api/lists?spaceId=... |
any | Lists (optionally by space) |
GET /api/lists/:listId |
any | List + statuses + its tasks. Admins also get the plaintext inboundToken; members get the boolean inboundEnabled |
POST /api/lists |
admin | {spaceId, name, statuses?} |
PATCH /api/lists/:listId |
admin | {name?, position?, archived?, spaceId?, statuses?, inboundToken?} |
DELETE /api/lists/:listId |
admin | Delete (refused while it still has tasks) |
flowcurl -X POST $FLOW/api/lists -H 'Content-Type: application/json' \
-d '{"spaceId":"sp_abc123","name":"Pipeline","statuses":[
{"name":"Triage","color":"#8b949e","type":"open"},
{"name":"Building","color":"#3b82f6","type":"custom"},
{"name":"Shipped","color":"#22c55e","type":"closed"}]}'Inbound intake is toggled through the same PATCH. "rotate" mints a fresh inb_ token and returns it once, with the exact URL to paste into the source system; null disables intake. Everywhere else — snapshots, deltas, list reads by non-admins — the token is stripped at the source.
flowcurl -X PATCH $FLOW/api/lists/ls_def456 \
-H 'Content-Type: application/json' -d '{"inboundToken":"rotate"}'
# {"list":{...},"inboundEnabled":true,"inboundToken":"inb_...",
# "inboundUrl":"https://flow.example.com/api/inbound/ls_def456",
# "warning":"This inbound token is shown only once."}| Method + path | Auth | What |
|---|---|---|
GET /api/tasks |
any | Same handler as search below |
GET /api/tasks/:taskId |
any | Full detail: task + subtasks + comments + attachments |
POST /api/tasks |
any | Create (optionally with inline subtasks) → 201 |
PATCH /api/tasks/:taskId |
any | Partial update; absent leaves alone, null clears |
POST /api/tasks/:taskId/move |
any | {listId?, status?, position?} — omit position and the server picks |
DELETE /api/tasks/:taskId |
any | Delete |
flowcurl -X POST $FLOW/api/tasks -H 'Content-Type: application/json' \
-d '{"listId":"ls_def456",
"title":"Checkout fails on Safari",
"description":"Spinner never resolves.\n\nRepro: **iOS 17**",
"status":"Triage",
"assigneeId":"us_abc",
"priority":"urgent",
"dueDate":1785000000000,
"tags":["bug","safari"],
"subtasks":[{"title":"Reproduce"},{"title":"Patch","assigneeId":"us_abc"}]}'
flowcurl -X PATCH $FLOW/api/tasks/tk_ghi789 -H 'Content-Type: application/json' \
-d '{"status":"Building","priority":"high"}'
flowcurl -X POST $FLOW/api/tasks/tk_ghi789/move -H 'Content-Type: application/json' \
-d '{"listId":"ls_other","status":"Triage","position":1.5}'status is always a status name, matched case-insensitively; omitted on create, the task lands in the list's open status. Priorities: urgent, high, normal, low. Descriptions and comments are markdown, plain strings over the wire.
Snooze. PATCH also takes snoozedUntil (epoch ms; null wakes now) and blockedNote (≤200 chars). A snoozed task keeps its list, status, and position — it's just hidden from the board and parked at the bottom of My Work until the hourly sweep passes its wake time or anyone comments on it. Waking never changes the status.
flowcurl -X PATCH $FLOW/api/tasks/tk_ghi789 -H 'Content-Type: application/json' \
-d '{"snoozedUntil":1785000000000,"blockedNote":"waiting on the revised quote"}'GET /api/tasks/search (query params) and POST /api/tasks/search (JSON body — easier when the filter list gets long) are the same search: FTS over title + description plus filters. Array filters accept repeated params or comma-separated values.
| Filter | Type |
|---|---|
query (alias q) |
FTS string |
listId, spaceId, assigneeId |
id |
status |
array of status names |
tags |
array |
includeClosed |
boolean, default false |
dueBefore, dueAfter, updatedAfter |
epoch ms |
limit |
1–200, default 50 |
cursor |
opaque; from the previous page |
flowcurl "$FLOW/api/tasks/search?query=safari&assigneeId=us_abc&limit=20"
flowcurl "$FLOW/api/tasks/search?listId=ls_def456&status=Triage&status=Building"
flowcurl "$FLOW/api/tasks/search?tags=bug,p1&includeClosed=true"
flowcurl -X POST $FLOW/api/tasks/search -H 'Content-Type: application/json' \
-d '{"query":"checkout","tags":["bug"],"limit":100}'
# {"tasks":[...],"cursor":"...","total":137}Returns concise TaskRow shapes, not full tasks; page with cursor until it's null.
Both forms report per item, so one bad entry never forces a blind retry of the whole batch. Up to 200 items.
| Method + path | Auth | What |
|---|---|---|
PATCH /api/tasks/bulk |
any | {updates: [UpdateTaskInput, ...]} |
POST /api/tasks/bulk |
any | {tasks: [CreateTaskInput, ...]} → 201 |
flowcurl -X PATCH $FLOW/api/tasks/bulk -H 'Content-Type: application/json' \
-d '{"updates":[{"taskId":"tk_1","status":"Done"},{"taskId":"tk_2","assigneeId":null}]}'
# {"results":[{"taskId":"tk_1","ok":true,"error":null},
# {"taskId":"tk_2","ok":false,"error":"no task tk_2"}]}Asana-style: done/not-done, optional assignee and due date, no status pipeline.
| Method + path | Auth | What |
|---|---|---|
POST /api/tasks/:taskId/subtasks |
any | One ({title, assigneeId?, dueDate?}) or a batch ({subtasks: [...]}, up to 100) → 201 |
PATCH /api/subtasks/:subtaskId |
any | {done?, title?, assigneeId?, dueDate?} |
DELETE /api/subtasks/:subtaskId |
any | Delete |
flowcurl -X POST $FLOW/api/tasks/tk_ghi789/subtasks -H 'Content-Type: application/json' \
-d '{"subtasks":[{"title":"Reproduce"},{"title":"Patch"},{"title":"Verify"}]}'
flowcurl -X PATCH $FLOW/api/subtasks/sb_jkl012 \
-H 'Content-Type: application/json' -d '{"done":true}'| Method + path | Auth | What |
|---|---|---|
GET /api/tasks/:taskId/comments |
any | The thread |
POST /api/tasks/:taskId/comments |
any | {body} (markdown, ≤20,000 chars) → 201 |
DELETE /api/comments/:commentId |
any | Delete |
Uploads are raw bytes streamed straight into R2 — nothing buffers in the Worker, so a 100 MB file never approaches the memory limit. Content-Length is required; 100 MB is a hard cap (413). Filename comes from ?filename= or an X-Filename header.
| Method + path | Auth | What |
|---|---|---|
POST /api/tasks/:taskId/attachments?filename=... |
any | Upload body bytes → metadata row |
GET /api/tasks/:taskId/attachments |
any | Metadata list |
GET /api/tasks/:taskId/attachments/:attachmentId |
any | Download (streamed; Range and If-None-Match/ETag supported) |
GET /api/attachments/:attachmentId |
any | Same download, by id alone |
DELETE /api/attachments/:attachmentId |
any | Delete the row, then the R2 object |
PATCH /api/attachments/:attachmentId/storage |
admin | {action: "finalize_drive", driveFileId, driveWebViewLink, driveDestination} or {action: "cleanup_r2", driveFileId, confirmR2Key} — moves an attachment's storage record between R2 and an external Google Drive file |
Attachment metadata carries storageProvider ("r2" or "drive") and driveFileId (the external Drive file id, when applicable). Download falls back to R2 unless storageProvider is "drive", in which case it redirects to driveWebViewLink.
flowcurl -X POST "$FLOW/api/tasks/tk_ghi789/attachments?filename=screenshot.png" \
-H 'Content-Type: image/png' --data-binary @screenshot.png
flowcurl -o screenshot.png $FLOW/api/attachments/at_pqr678
flowcurl -H 'Range: bytes=0-1023' $FLOW/api/attachments/at_pqr678 # 206 + Content-RangeSingle bytes= ranges (closed, open-ended, suffix) return 206 with an end past EOF clamped; a start at or past EOF is 416; multi-range requests get the whole object as a 200, which is a valid answer to any Range request.
Reads open to members; writes admin. Rules ship enabled: false. Full vocabulary in AUTOMATIONS.md.
| Method + path | Auth | What |
|---|---|---|
GET /api/automations |
any | All rules |
GET /api/automations/:ruleId |
any | One rule |
POST /api/automations |
admin | Create |
PATCH /api/automations/:ruleId |
admin | Upsert against the path id — send the whole rule, not a diff |
DELETE /api/automations/:ruleId |
admin | Delete |
GET /api/automation-runs?ruleId=&taskId=&limit=&before= |
any | Run log, newest first, keyset-paged |
GET /api/automations/:ruleId/runs |
any | One rule's history |
Self-only: both routes act on the caller's own prefs; there is no way to read or change another user's.
| Method + path | Auth | What |
|---|---|---|
GET /api/notifications/prefs |
any | {userId, email, prefs} — defaults returned before any write |
PUT /api/notifications/prefs |
any | Any subset of the booleans; returns the full merged set |
flowcurl -X PUT $FLOW/api/notifications/prefs -H 'Content-Type: application/json' \
-d '{"status_change_on_my_task":true,"comment_on_my_task":false}'Booleans: assigned_to_me (default on), comment_on_my_task (on), status_change_on_my_task (off), mention (on, reserved — mentions aren't parsed yet).
| Method + path | Auth | What |
|---|---|---|
GET /api/audit |
any | Newest first; filters entity, action (e.g. task.update), userId, apiKeyId, after, before; page by passing the returned cursor back as before |
flowcurl "$FLOW/api/audit?apiKeyId=ak_vwx234" # what did this agent do
flowcurl "$FLOW/api/audit?entity=tk_ghi789" # one task's full history
flowcurl "$FLOW/api/audit?userId=us_abc&action=task.update"Entries carry the full actor — {userId, via, apiKeyId, automationRuleId} — which is the point: a key impersonates a user, so the user id alone can't tell you whether a change came from the human or their agent.
Self-serve for your own identity; impersonating another user is admin-only.
| Method + path | Member | Admin |
|---|---|---|
POST /api/api-keys |
userId absent or own id |
any userId |
GET /api/api-keys |
own keys only | every key |
DELETE /api/api-keys/:apiKeyId |
own keys only | any key |
# The token is in this response and NOWHERE else, ever.
flowcurl -X POST $FLOW/api/api-keys -H 'Content-Type: application/json' \
-d '{"name":"my-agent"}'
# {"apiKey":{"id":"ak_...","name":"my-agent","userId":"us_..."},
# "token":"flow_...","warning":"This token is shown only once and cannot be recovered."}Admin-only; used by apps/importer. Import upserts by id (then clickupId), fires no automations, emits no deltas, and truncates over-long titles instead of rejecting them — see IMPORTING.md.
| Method + path | Auth | What |
|---|---|---|
GET /api/import/status |
admin | Pre-flight |
POST /api/import/batch |
admin | {users?, spaces?, lists?, tasks?, subtasks?, comments?} — fully-formed entities with ids, upserted |
POST /api/import/attachments |
admin | {taskId, filename, mimeType, size, sourceUrl, ...} — the Worker fetches sourceUrl (https, ClickUp hosts only) and streams it into R2 |
| Method + path | Auth | What |
|---|---|---|
POST /api/inbound/:listId |
the list's inb_ token (Bearer or ?token=) |
Create a task in that one list. externalId deduplicates: a repeat delivery returns the existing task with 200 {"created":false} |
curl -sS -X POST "$FLOW/api/inbound/ls_def456" \
-H "Authorization: Bearer inb_xxxxxxxx" -H 'Content-Type: application/json' \
-d '{"title":"Login broken","description":"500 on submit","externalId":"rpt-88"}'See AGENTS_AND_MCP.md for the payload mapping rules.
GET /ws authenticates (bearer, Access JWT, or the CF_Authorization cookie — the browser path) and hands the upgrade straight to the Durable Object, which owns the socket so it can hibernate and broadcast without a hop.
websocat "wss://flow.example.com/ws" -H "Authorization: Bearer $TOKEN"
> {"type":"hello","sinceSeq":null} # null => full snapshot
> {"type":"hello","sinceSeq":41823} # replay since seq; snapshot if the gap is too big
> {"type":"ping"} # <= {"type":"pong"}Server messages: snapshot, deltas, resync (reconnect fresh), pong. Protocol details in ARCHITECTURE.md.