Skip to content

MHoroszowski/ticktick-client

 
 

Repository files navigation

ticktick-client

npm version CI License: MIT

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.


Feature Coverage

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   ⚠️ Works with known limitations   🚫 API broken server-side   ❌ Not implemented


Installation

npm install ticktick-client

Requires Node.js 22+. Zero runtime dependencies.


Quick Start

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`);

Authentication

Credentials (auto-login)

const client = new TickTickClient({
  credentials: { username: 'you@example.com', password: 'password' },
});
// Automatically logs in on first API call and re-authenticates on session expiry.

Session Stores

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,
});

API Reference

Tasks

// 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

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:

  • reminders wins on conflict — if both reminder (sugar) and reminders (array) are set, the array is authoritative.
  • The id field on each reminder is server-stable across edits. Pass TickTickReminder objects 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.

Moving Tasks Between Projects

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 previousId to 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}`);
}

Subtasks, Pinning, Recurring

// 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',
});

Completed Tasks (Paginated)

// 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);
  }
}

Projects

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);

Kanban Columns

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);

projectId is required on every updateColumn payload. TickTick's POST /api/v2/column endpoint silently drops the update (returns 200 with empty id2etag) if the update item omits projectId. The TypeScript type enforces this AND the implementation throws an actionable runtime error if you bypass the type (e.g. via as any). The requirement was discovered empirically — see Plans/kanban-columns-probe.md.

Wire-shape gotcha on delete: POST /api/v2/column body for delete uses the key columnId (not id) on the delete item: {delete: [{columnId, projectId}]}. Sending {delete: [id-string]} or {delete: [{id, projectId}]} returns server 500 unknown_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.

Folders (Project Groups)

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 null to clear groupId — it accepts the literal string "NONE". This library translates caller-side null to "NONE" on the wire so the universal partial-update contract is preserved at the public API boundary. You always write groupId: null to unparent.

Folder delete is non-cascading. Deleting a folder while child projects still reference it leaves those projects with a groupId pointing at the now-deleted folder. If you care about clean state, unparent the children first.

Activity Feed (Premium)

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.

Tags

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']);

Habits

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]);

Focus (Pomodoro)

// 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');

Statistics

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
);

Countdowns

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);

User

const profile = await client.user.getProfile();
// { username, email, displayName, picture, locale, ... }

const status = await client.user.getStatus();
// { userId, username, pro, teamPro, proEndDate, inboxId, ... }

Semantic Helpers

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'

Reminder TRIGGER (RFC 5545 §3.8.6.3)

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');                // undefined

Reminders 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.


Known Limitations

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 → 404
  • POST /api/v2/task/{id} with new projectId → 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.


Architecture

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

Development

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

License

MIT

About

Unofficial TickTick API client for Node.js/TypeScript — zero dependencies, full TypeScript types, 50+ verified endpoints

Resources

License

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages

  • TypeScript 100.0%