Unofficial TickTick API client for Node.js / TypeScript.
Disclaimer — This library reverse-engineers TickTick's private web API. It is not affiliated with or endorsed by TickTick. The API surface may change without notice. Use at your own risk.
The table below maps every major TickTick capability to its support status in this library. Each method has been verified against the real API via Playwright-based traffic capture (last verified: 2026-04-07).
| Category | Feature | Status | Method |
|---|---|---|---|
| Tasks | List all tasks | ✅ | tasks.list() |
| Create task | ✅ | tasks.create(draft) |
|
| Update task | ✅ | tasks.update(params) |
|
| Complete task | ✅ | tasks.complete(projectId, taskId) |
|
| Delete task | ✅ | tasks.delete(projectId, taskId) |
|
| Batch create | ✅ | tasks.createMany(drafts) |
|
| Batch update | ✅ | tasks.updateMany(params) |
|
| Batch delete | ✅ | tasks.deleteMany(items) |
|
| Move to project | tasks.move(item) — copy+delete, ID changes |
||
| Move many | tasks.moveMany(items) — same limitation |
||
| Create subtask | ✅ | tasks.createSubtask(parentId, projectId, draft) |
|
| Pin / Unpin | ✅ | tasks.pin() / tasks.unpin() |
|
| List completed | ✅ | tasks.listCompleted(options) |
|
| Iterate completed | ✅ | tasks.iterateCompleted(options) |
|
| List trash | 🚫 | tasks.listTrash() — API ignores status filter |
|
| Restore from trash | tasks.restore() — works if you know the task ID |
||
| Recurring tasks | ✅ | via repeatFlag / repeatEndDate in create/update |
|
| Reminders — read | ✅ | task.reminder / task.reminders on readback; parseReminderTrigger() helper |
|
| Reminders — write (time-based, single) | ✅ | tasks.create({reminder}) / tasks.setReminders() / tasks.update({reminder}) — routes through V2 batch sync endpoint |
|
| Reminders — write (time-based, multi) | ✅ | tasks.create({reminders}) / tasks.setReminders() / tasks.update({reminders}) — Premium-gated server-side |
|
| Reminders — write (geofence/location) | ❌ | Mobile-only on TickTick — needs iOS HAR session, see #5 | |
| Attachments | ❌ | Not implemented | |
| Comments | ❌ | Not implemented | |
| Sort order | ✅ | via sortOrder in create/update |
|
| Projects | List projects | ✅ | projects.list() |
| Create project | ✅ | projects.create(draft) |
|
| Update project | ✅ | projects.update(params) |
|
| Delete project | ✅ | projects.delete(id) |
|
| Batch delete | ✅ | projects.deleteMany(ids) |
|
| List columns (Kanban) | ✅ | projects.listColumns(projectId) |
|
| Sharing / Collaboration | ❌ | Not implemented | |
| Tags | List tags | ✅ | tags.list() |
| Create tag | ✅ | tags.create(draft) |
|
| Batch create | ✅ | tags.createMany(drafts) |
|
| Update tag | ✅ | tags.update(draft) |
|
| Delete tag | ✅ | tags.delete(name) |
|
| Batch delete | ✅ | tags.deleteMany(names) |
|
| Rename tag | ✅ | tags.rename(name, label) |
|
| Merge tags | ✅ | tags.merge(source, target) |
|
| Habits | List habits | ✅ | habits.list() |
| Create habit | ✅ | habits.create(draft) |
|
| Update habit | ✅ | habits.update(params) |
|
| Delete habit | ✅ | habits.delete(id) |
|
| Batch delete | ✅ | habits.deleteMany(ids) |
|
| Check in | ✅ | habits.upsertCheckin(input) |
|
| Get check-ins | ✅ | habits.getCheckins(ids, start, end) |
|
| Weekly stats | ✅ | habits.getWeekStats() |
|
| Focus | Start session | ✅ | focus.start(options) |
| Pause session | ✅ | focus.pause() |
|
| Resume session | ✅ | focus.resume() |
|
| Finish session | ✅ | focus.finish() |
|
| Stop (drop) session | ✅ | focus.stop() |
|
| Get local state | ✅ | focus.getState() |
|
| Sync remote state | ✅ | focus.syncState() |
|
| Reset local state | ✅ | focus.resetState() |
|
| Timeline | ✅ | focus.getTimeline(start, end) |
|
| Overview | ✅ | focus.getOverview() |
|
| Timing data | ✅ | focus.getTiming(start, end) |
|
| Heatmap | 🚫 | focus.getHeatmap() — server returns 500 |
|
| Hour distribution | 🚫 | focus.getHourDistribution() — server returns 500 |
|
| Distribution | 🚫 | focus.getDistribution() — server returns 500 |
|
| Statistics | User ranking | ✅ | statistics.getRanking() |
| Completed tasks list | ✅ | statistics.listCompleted(from, to, limit) |
|
| Countdowns | List countdowns | ✅ | countdowns.list() |
| Create countdown | ✅ | countdowns.create(draft) |
|
| Update countdown | ✅ | countdowns.update(params) |
|
| Delete countdown | ✅ | countdowns.delete(id) |
|
| User | Get profile | ✅ | user.getProfile() |
| Get status (Pro, etc.) | ✅ | user.getStatus() |
|
| Auth | Login | ✅ | client.login() |
| Logout | ✅ | client.logout() |
|
| Check auth | ✅ | client.isAuthenticated() |
|
| Auto re-auth | ✅ | Automatic on 401/403 | |
| Session persistence | ✅ | File / Memory / Custom stores |
Legend: ✅ Fully working
npm install ticktick-clientRequires Node.js 22+. Zero runtime dependencies.
import { TickTickClient, FileSessionStore } from 'ticktick-client';
const client = new TickTickClient({
credentials: {
username: 'your@email.com',
password: 'your-password',
},
// Persist session to avoid logging in every time
sessionStore: new FileSessionStore('./.ticktick-session.json'),
});
// First request triggers auto-login
const tasks = await client.tasks.list();
console.log(`You have ${tasks.length} tasks`);const client = new TickTickClient({
credentials: { username: 'you@example.com', password: 'password' },
});
// Automatically logs in on first API call and re-authenticates on session expiry.| Store | Use case |
|---|---|
FileSessionStore(path) |
CLI tools, scripts — persists to disk |
MemorySessionStore() |
Short-lived processes, tests |
Custom TickTickSessionStore |
Implement load(), save(), delete() for any backend |
// File-based (recommended for scripts)
import { FileSessionStore } from 'ticktick-client';
const client = new TickTickClient({
credentials: { username: '...', password: '...' },
sessionStore: new FileSessionStore('./.ticktick-session.json'),
});
// Pre-loaded session (no credentials needed)
const client = new TickTickClient({
session: existingSessionObject,
});// List all active tasks
const tasks = await client.tasks.list();
// Create
const task = await client.tasks.create({
title: 'Buy groceries',
projectId: 'inbox123',
priority: 3, // 0=none, 1=low, 3=medium, 5=high
dueDate: '2026-12-31T00:00:00.000Z',
tags: ['shopping'],
});
// Update
await client.tasks.update({
id: task.id,
projectId: task.projectId,
title: 'Buy organic groceries',
priority: 5,
});
// Complete / Delete
await client.tasks.complete(task.projectId, task.id);
await client.tasks.delete(task.projectId, task.id);
// Batch operations
await client.tasks.createMany([
{ title: 'Task A', projectId },
{ title: 'Task B', projectId },
]);
await client.tasks.updateMany([
{ id: 'id1', projectId, priority: 5 },
{ id: 'id2', projectId, priority: 3 },
]);
await client.tasks.deleteMany([
{ taskId: 'id1', projectId },
{ taskId: 'id2', projectId },
]);Reminders use the V2 batch sync endpoint (POST /api/v2/batch/task) with full read-modify-write semantics — the partial-update endpoint silently drops reminder fields. The library handles the routing transparently; you just pass reminder or reminders and it Does The Right Thing.
import { formatReminderTrigger } from 'ticktick-client';
// Create with a single reminder (sugar — collapses to reminders: [trigger] internally)
const task = await client.tasks.create({
title: 'Doctor',
projectId,
dueDate: '2026-06-01T15:00:00.000+0000',
isAllDay: false,
reminder: formatReminderTrigger({ before: '15m' }), // 'TRIGGER:-PT15M'
});
// Create with multiple reminders (Premium feature server-side)
await client.tasks.create({
title: 'Project deadline',
projectId,
dueDate: '2026-06-01T15:00:00.000+0000',
isAllDay: false,
reminders: [
formatReminderTrigger({ before: { days: 1 } }), // 'TRIGGER:-P1D'
formatReminderTrigger({ before: { hours: 1 } }), // 'TRIGGER:-PT1H'
formatReminderTrigger({ at: 'due' }), // 'TRIGGER:PT0S'
],
});
// Replace reminders on an existing task (dedicated method)
await client.tasks.setReminders(projectId, task.id, ['TRIGGER:-PT30M']);
// Combined: rename + change reminders in one call
await client.tasks.update({
id: task.id,
projectId,
title: 'Doctor — moved earlier',
reminders: ['TRIGGER:-PT1H'],
});
// Clear all reminders
await client.tasks.setReminders(projectId, task.id, null);
// Round-trip: read back what's on the task
const refreshed = (await client.tasks.list()).find((t) => t.id === task.id);
for (const rem of refreshed?.reminders ?? []) {
console.log(rem.id, rem.trigger);
}Notes:
reminderswins on conflict — if bothreminder(sugar) andreminders(array) are set, the array is authoritative.- The
idfield on each reminder is server-stable across edits. PassTickTickReminderobjects on round-trips to preserve ids; pass plain strings to let the library generate them. - Location/geofence reminders are mobile-only on TickTick and not yet supported — see #5.
Important: The TickTick REST API does not support native task moves. This library uses a copy+delete strategy — the task ID will change. Use the returned
previousIdto update any references.
const result = await client.tasks.move({
taskId: 'old-id',
fromProjectId: 'project-a',
toProjectId: 'project-b',
});
console.log(result.previousId); // 'old-id'
console.log(result.task.id); // new server-assigned ID
console.log(result.task.projectId); // 'project-b'
// Batch move with ID mapping
const results = await client.tasks.moveMany([
{ taskId: 't1', fromProjectId: 'a', toProjectId: 'b' },
{ taskId: 't2', fromProjectId: 'a', toProjectId: 'b' },
]);
for (const r of results) {
console.log(`${r.previousId} -> ${r.task.id}`);
}// Subtask
await client.tasks.createSubtask(parentTask.id, projectId, {
title: 'Sub-item',
});
// Pin / Unpin
await client.tasks.pin(task.id, projectId);
await client.tasks.unpin(task.id, projectId);
// Recurring task
await client.tasks.create({
title: 'Weekly review',
projectId,
repeatFlag: 'RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=FR',
});// Single page
const completed = await client.tasks.listCompleted({ projectId, limit: 50 });
// Auto-paginated async iterator
for await (const page of client.tasks.iterateCompleted()) {
for (const task of page) {
console.log(task.title, task.completedTime);
}
}const projects = await client.projects.list();
const project = await client.projects.create({
name: 'Work',
color: '#ff6348',
kind: 'TASK', // 'TASK' | 'NOTE'
viewMode: 'kanban', // 'list' | 'kanban' | 'timeline'
});
await client.projects.update({ id: project.id, name: 'Work 2026' });
await client.projects.delete(project.id);
await client.projects.deleteMany([id1, id2]);
// Kanban columns
const columns = await client.projects.listColumns(project.id);For a project in viewMode: 'kanban', you can create and rename/reorder
columns (a.k.a. sections / kanban swimlanes).
// Project must be in kanban view for columns to surface in the UI
const project = await client.projects.create({
name: 'Sprint board',
viewMode: 'kanban',
});
// Create a column
const col = await client.projects.createColumn(project.id, {
name: 'To Do',
sortOrder: 0,
});
// Place a task in it
await client.tasks.create({
title: 'Write the docs',
projectId: project.id,
columnId: col.id,
});
// Rename and/or reorder
await client.projects.updateColumn({
id: col.id,
projectId: project.id, // REQUIRED — server silently no-ops without it
name: 'In Progress',
sortOrder: 1,
});
// Partial update: name only (sortOrder preserved)
await client.projects.updateColumn({
id: col.id,
projectId: project.id,
name: 'Doing',
});
// List columns on the project
const cols = await client.projects.listColumns(project.id);
// Delete a column (tasks in it keep a dangling columnId; UI shows them as uncategorized)
await client.projects.deleteColumn(project.id, col.id);
projectIdis required on everyupdateColumnpayload. TickTick'sPOST /api/v2/columnendpoint silently drops the update (returns 200 with emptyid2etag) if the update item omitsprojectId. The TypeScript type enforces this AND the implementation throws an actionable runtime error if you bypass the type (e.g. viaas any). The requirement was discovered empirically — seePlans/kanban-columns-probe.md.
Wire-shape gotcha on delete:
POST /api/v2/columnbody for delete uses the keycolumnId(notid) on the delete item:{delete: [{columnId, projectId}]}. Sending{delete: [id-string]}or{delete: [{id, projectId}]}returns server 500unknown_exception— the bug is the field name, not the endpoint. This was discovered after six API-only probe rounds via an Interceptor capture of the TickTick web UI's actual delete request.
TickTick supports one level of folder nesting — projects can live
inside a folder; folders cannot live inside other folders. The server
calls them projectGroup; the UI calls them "folder."
// Create a folder
const folder = await client.projectGroups.create({ name: 'Work' });
// List folders
const folders = await client.projectGroups.list();
// Create a project nested inside the folder
const project = await client.projects.create({
name: 'Q3 planning',
groupId: folder.id,
});
// Move an existing project into a folder
await client.projects.update({ id: project.id, groupId: folder.id });
// Unparent a project (move back to top level)
await client.projects.update({ id: project.id, groupId: null });
// Rename / reorder a folder
await client.projectGroups.update({ id: folder.id, name: 'Work 2026' });
await client.projectGroups.update({ id: folder.id, sortOrder: -1 });
// Delete a folder
await client.projectGroups.delete(folder.id);
await client.projectGroups.deleteMany([id1, id2]);Wire detail: the V2 batch/project endpoint does NOT accept JSON
nullto cleargroupId— it accepts the literal string"NONE". This library translates caller-sidenullto"NONE"on the wire so the universal partial-update contract is preserved at the public API boundary. You always writegroupId: nullto unparent.
Folder delete is non-cascading. Deleting a folder while child projects still reference it leaves those projects with a
groupIdpointing at the now-deleted folder. If you care about clean state, unparent the children first.
Fetch the activity feed / history of changes for a task or project.
This is TickTick's Premium "View previous changes" feature — the
library is the first OSS client to wrap these endpoints (wire shape
captured empirically; full trail in Plans/activity-probe.md).
// Events for a single task (newest first)
const taskEvents = await client.activity.listForTask(taskId);
//
// Each event has at minimum {id, action, when, deviceChannel, whoProfile};
// optionally {name, description, content, kind, taskIds} depending on action.
// action is a discriminator like 'T_CREATE' / 'T_TITLE' / 'T_CONTENT' /
// 'T_DONE' / 'T_CANCEL' / 'P_CREATE' / 'P_TITLE' / 'P_ADD_COLUMN' / etc.
// Events for an entire project
const projectEvents = await client.activity.listForProject(projectId);
// Pagination — pass the last event's id as `lastId` and the running
// count as `skip`. Server returns an empty array when exhausted.
const first = await client.activity.listForTask(taskId);
const next = await client.activity.listForTask(taskId, {
skip: first.length,
lastId: first[first.length - 1]?.id,
});
// Caller-side filter (no server-side filter exists)
const titleChanges = taskEvents.filter((e) => e.action === 'T_TITLE');Premium-only. Non-Premium accounts will receive a 4xx response from the underlying endpoint. The library does not catch or mask that — your caller should handle the API error.
V1 path. These endpoints live on
/api/v1/...rather than V2 — activity is one of the few surfaces the library exposes via the older path because that is what the TickTick web UI itself hits.
const tags = await client.tags.list();
await client.tags.create({ name: 'urgent', label: 'urgent', color: '#ff0000' });
await client.tags.createMany([
{ name: 'work', label: 'work' },
{ name: 'personal', label: 'personal' },
]);
await client.tags.update({ name: 'work', color: '#0000ff' });
await client.tags.rename('work', 'office');
await client.tags.merge('office', 'personal'); // merge office into personal
await client.tags.delete('personal');
await client.tags.deleteMany(['tag1', 'tag2']);const habits = await client.habits.list();
await client.habits.create({
name: 'Exercise',
repeatRule: 'FREQ=DAILY',
goal: 1,
step: 1,
unit: 'times',
type: 'boolean',
recordEnable: false,
color: '#FF6B6B',
});
await client.habits.update({ id: habit.id, name: 'Morning Exercise' });
// Check in
await client.habits.upsertCheckin({
habitId: habit.id,
date: new Date(),
goal: 1,
value: 1,
status: 'done', // 'done' | 'undone' | 'unlabeled'
});
// Query check-ins for a date range
const checkins = await client.habits.getCheckins(
[habit.id],
'2026-04-01',
'2026-04-07',
);
// Weekly completion stats
const weekStats = await client.habits.getWeekStats();
await client.habits.delete(habit.id);
await client.habits.deleteMany([id1, id2]);// Start a focus session
await client.focus.start({
duration: 25, // minutes
focusOnTitle: 'Deep work',
focusOnId: taskId, // optional: link to a task
});
// Session lifecycle
await client.focus.pause();
await client.focus.resume();
await client.focus.finish(); // complete the pomodoro
await client.focus.stop(); // abandon (drop) the session
// Local state management (no network calls)
const state = client.focus.getState();
// { status: 'running' | 'paused' | 'idle' | null, focusId, duration, pomoCount, ... }
client.focus.resetState();
// Sync state from server
const remote = await client.focus.syncState();
// Analytics
const overview = await client.focus.getOverview();
// { todayPomoCount, todayPomoDuration, totalPomoCount, totalPomoDuration }
const timeline = await client.focus.getTimeline('2026-04-01', '2026-04-07');
// [{ id, startTime, endTime, status, pauseDuration, type }]
const timing = await client.focus.getTiming('2026-04-01', '2026-04-07');const ranking = await client.statistics.getRanking();
// { ranking, taskCount, projectCount, dayCount, completedCount, score, level }
const completed = await client.statistics.listCompleted(
'2026-04-01 00:00:00',
'2026-04-07 23:59:59',
100, // limit
);const countdowns = await client.countdowns.list();
await client.countdowns.create({
name: 'Product Launch',
date: new Date('2026-12-31'),
type: 'countdown', // 'countdown' | 'anniversary' | 'birthday' | 'holiday'
color: '#ff6348',
});
await client.countdowns.update({ id: countdown.id, name: 'Big Launch Day' });
await client.countdowns.delete(countdown.id);const profile = await client.user.getProfile();
// { username, email, displayName, picture, locale, ... }
const status = await client.user.getStatus();
// { userId, username, pro, teamPro, proEndDate, inboxId, ... }Utility functions for converting between human-readable labels and TickTick's numeric codes:
import {
parseTaskPriority, formatTaskPriority,
parseTaskStatus, formatTaskStatus,
parseHabitStatus, formatHabitStatus,
parseCheckinStatus, formatCheckinStatus,
} from 'ticktick-client';
parseTaskPriority('medium'); // 3
formatTaskPriority(5); // 'high'
parseTaskStatus('completed'); // 2
formatTaskStatus(0); // 'open'
parseHabitStatus('archived'); // 1
formatHabitStatus(0); // 'normal'
parseCheckinStatus('done'); // 2
formatCheckinStatus(1); // 'undone'import { parseReminderTrigger, formatReminderTrigger } from 'ticktick-client';
formatReminderTrigger({ at: 'due' }); // 'TRIGGER:PT0S'
formatReminderTrigger({ before: { minutes: 15 } }); // 'TRIGGER:-PT15M'
formatReminderTrigger({ before: '15m' }); // 'TRIGGER:-PT15M' (shorthand)
formatReminderTrigger({ before: '1d 9h' }); // 'TRIGGER:-P1DT9H'
formatReminderTrigger({ before: { weeks: 2 } }); // 'TRIGGER:-P2W'
formatReminderTrigger({ after: { minutes: 30 } }); // 'TRIGGER:PT30M'
parseReminderTrigger('TRIGGER:PT0S'); // { at: 'due' }
parseReminderTrigger('TRIGGER:-P0DT9H0M0S'); // { before: { hours: 9 } } — zero fields dropped
parseReminderTrigger('TRIGGER:-P1DT9H'); // { before: { days: 1, hours: 9 } }
parseReminderTrigger('TRIGGER:PT30M'); // { after: { minutes: 30 } }
parseReminderTrigger('garbage'); // undefinedReminders set through the official TickTick client (web, mobile, desktop) round-trip cleanly through parseReminderTrigger — iterate them on task.reminders (each entry is { id, trigger }):
const tasks = await client.tasks.list();
for (const t of tasks) {
for (const rem of t.reminders ?? []) {
console.log(rem.trigger, '→', parseReminderTrigger(rem.trigger));
}
}Compose a trigger and attach it to a task via tasks.create, tasks.update, or tasks.setReminders — see Reminders under the Tasks API for the full write path.
These are confirmed TickTick server-side issues, verified via Playwright network capture on 2026-04-07.
Task Move Changes ID (#32)
The REST API has no endpoint for moving tasks between projects. move() and moveMany() use a copy+delete strategy. The task receives a new ID. Use result.previousId to track the mapping.
Tested approaches that failed:
POST /api/v3/batch/taskProject→ 404POST /api/v2/task/{id}with newprojectId→ 200 but no actual change
Trash Listing Broken (#33)
listTrash() calls GET /api/v2/project/{id}/tasks?status=-1, but the status filter is ignored server-side. Deleted tasks are not retrievable via any known REST endpoint. restore() works if you already know the task ID.
Focus Analytics Endpoints Return 500 (#31)
getHeatmap(), getHourDistribution(), and getDistribution() always return HTTP 500 regardless of parameters or account data. All other focus endpoints (timeline, overview, timing, session control) work correctly.
ticktick-client/
src/
client.ts # TickTickClient — auth, HTTP, session management
modules/
tasks.ts # TasksModule — CRUD, batch, move, subtasks, pin, trash
projects.ts # ProjectsModule — CRUD, columns
tags.ts # TagsModule — CRUD, rename, merge
habits.ts # HabitsModule — CRUD, check-ins, weekly stats
focus.ts # FocusModule — session control, analytics, state
statistics.ts # StatisticsModule — ranking, completed list
countdowns.ts # CountdownsModule — CRUD
user.ts # UserModule — profile, status
types.ts # All TypeScript type definitions
errors.ts # TickTickError, TickTickAuthError, TickTickApiError
semantic.ts # Human-readable label converters
session-store.ts # FileSessionStore, MemorySessionStore
internal/
ids.ts # ObjectId generator
cookies.ts # Cookie parsing/serialization
npm install # install dependencies
npm test # run unit tests (vitest)
npm run lint # type check (tsc --noEmit)
npm run build # build ESM + CJS + DTS (tsup)
# Integration test against real API (requires .ticktick-session.json)
npx tsx scripts/integration-test.ts
# Capture real API traffic via Playwright
npx tsx scripts/capture-all-issues.ts