TodoMode API reference (v1)

The TodoMode API lets your own scripts, a command line, or an AI assistant read and change your TodoMode data: projects, tasks, notes, due dates, the agenda, search, and the trash. It is a plain JSON-over-HTTPS REST API that acts as you, through a personal API token.

This page is the complete reference. It is written for people building against the API (a CLI, an MCP server, an integration) who should not need to read the server code.

Quick start #

  1. In TodoMode, open Settings → API, give a token a name, choose its access (read only, or read and write) and an expiry, and press Create token. Copy the token: it is shown once.
  2. Call the API with it:
export TODOMODE_TOKEN="tm_…"            # the token you copied

curl -s https://app.todomode.com/api/v1/me \
  -H "Authorization: Bearer $TODOMODE_TOKEN"

# Add a task with the same shorthand the app's input box understands
curl -s https://app.todomode.com/api/v1/tasks \
  -H "Authorization: Bearer $TODOMODE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"project_id": "proj_6ac7b898efa547.08938441", "text": "Fix login due:fri at:9am #work !high"}'

# What is on my plate this week?
curl -s https://app.todomode.com/api/v1/agenda \
  -H "Authorization: Bearer $TODOMODE_TOKEN"

Base URL #

All endpoints live under one base URL:

https://app.todomode.com/api/v1

Every path in this reference is relative to it: GET /me means GET https://app.todomode.com/api/v1/me.

The same endpoints are also reachable as https://app.todomode.com/api/v1/?path=/me (the path in a query parameter, other parameters after it). Both spellings behave identically; use the first unless something in between rewrites your URLs.

Authentication #

Every request carries a personal API token in the Authorization header:

Authorization: Bearer tm_xZ9rflq0Tn3hV7Kc2a8YwB6dE1pM4sJu5gRiOoLxNbQ

Any problem with the token — missing, malformed, unknown, revoked, expired, or belonging to a suspended account — gets the same answer, so the response never tells a caller which it was:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="TodoMode API"

{"error": {"code": "unauthorized", "message": "Missing or invalid API token. Send \"Authorization: Bearer tm_…\"; create a token in Settings → API."}}

Requests and responses #

// one resource
{"data": {...}, "meta": {...}}        // meta only when there is something to add

// a list
{"data": [...], "next_cursor": "eyJvIjoxMDB9"}   // null on the last page

// an error
{"error": {"code": "validation_failed", "message": "…", "details": {"field": "due"}}}

Status codes and error codes #

Statuserror.codeWhen
200Success.
201Created (a project, a task, an import).
304Not modified (see Conditional requests).
400invalid_parameterA query parameter is malformed (limit, due, cursor, updated_since …). details.parameter names it.
400invalid_jsonThe body is not a JSON object.
401unauthorizedNo usable token. Always the same body.
402payment_requiredA write on an account whose trial or subscription has ended. details.upgrade_url says where to fix it. Reads keep working.
403insufficient_scopeA write with a read-only token.
403forbiddenYou can see the project but not change it (a read-only share), or only the owner may do this (renaming a project).
404not_foundNo such endpoint, or a project or task you cannot see — the API does not distinguish "does not exist" from "not yours".
405method_not_allowedThe path exists but not with this method. Allow lists the ones that work.
409notes_conflictThe notes changed since you read them (see Notes and the optimistic lock).
409conflictA move that cannot happen, or a client_request_id whose task has since been deleted or moved.
413payload_too_largeBody over 6 MB.
415unsupported_media_typeA body without Content-Type: application/json.
422validation_failedA body field is wrong. details.field names it.
422unparseable_syntaxThe task text has a shorthand token that could not be read (due:someday). details.errors lists them. See strict.
429rate_limitedToo many requests; see Rate limits.
500internal_errorOur fault. The message carries a reference to quote to support.
503unavailableTemporarily unavailable; retry after Retry-After.

Rate limits #

Each token may make 120 requests per minute, of which at most 60 may be writes (anything other than GET). Windows are fixed, aligned to the minute. Every authenticated response says where you stand:

X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1791473760            # Unix time the window resets
X-RateLimit-Write-Limit: 60              # on writes
X-RateLimit-Write-Remaining: 59

Over the limit the answer is 429 rate_limited with Retry-After in seconds. Failed authentication is limited separately, per IP address (30 a minute).

Pagination #

Lists of tasks return up to limit items (default 100, maximum 500) and a next_cursor. Pass it back as cursor with the same other parameters to get the next page; it is null on the last page. Treat cursors as opaque.

curl -s "https://app.todomode.com/api/v1/tasks?completed=false&limit=200" -H "Authorization: Bearer $TODOMODE_TOKEN"
curl -s "https://app.todomode.com/api/v1/tasks?completed=false&limit=200&cursor=eyJvIjoyMDB9" -H "Authorization: Bearer $TODOMODE_TOKEN"

Search is limited by limit alone (default 50, maximum 200). Projects, the trash and the agenda are not paginated.

Conditional requests #

Successful GET responses carry a weak ETag. Send it back as If-None-Match and an unchanged result answers 304 Not Modified with no body — a cheap way to poll.

Idempotent creates #

Network retries should not create duplicates. Send a key of your choosing (1–100 characters from A-Z a-z 0-9 . _ : -) as client_request_id in the body, or as an Idempotency-Key header, when creating a task or importing an outline. Repeating a request with the same key answers with what the first one created: 200 instead of 201, and "meta": {"replayed": true} for a task ("created": false for an import). Keys are private to your account. If what the first request created has since been deleted or moved to another project, the repeat answers 409 conflict: send a new key to create it again.

Accounts whose trial or subscription has ended #

The API applies the same rule as the app. While your account can use TodoMode, write tokens can write. When a trial or subscription has ended, every read keeps working — so you can take your data out — and every write answers 402 payment_required, with details.reason (trial_expired, subscription_ended, payment_failed …) and details.upgrade_url. GET /me reports which applies in access.

Objects #

Project #

{
  "id": "proj_6ac7b898efa547.08938441",
  "title": "Work",
  "permission": "owner",
  "position": 0,
  "open_tasks": 12,
  "total_tasks": 30,
  "created_at": "2026-10-08T15:36:56Z",
  "updated_at": "2026-10-08T15:36:56Z",
  "url": "https://app.todomode.com/?action=project&project=proj_6ac7b898efa547.08938441"
}

Task #

{
  "id": "task_6ac7b898efe3d9.96506938",
  "project_id": "proj_6ac7b898efa547.08938441",
  "parent_id": null,
  "position": 0,
  "indent": 0,
  "text": "Fix login",
  "completed": false,
  "completed_at": null,
  "priority": "high",
  "due_date": "2026-10-09",
  "due_time": "09:00",
  "due_end_time": null,
  "tags": ["work"],
  "recurrence": null,
  "reminder": {"mode": "offset", "offset": -3600, "at": "2026-10-09T06:00:00Z"},
  "has_notes": false,
  "archived": false,
  "created_at": "2026-10-08T15:36:56Z",
  "updated_at": "2026-10-08T15:36:56Z",
  "url": "https://app.todomode.com/?action=project&project=proj_6ac7b898efa547.08938441&focus=task_6ac7b898efe3d9.96506938"
}

Endpoints #

GET / #

The API's name, version and where this reference lives. Needs a token like everything else.

GET /me #

Who the token belongs to, and what it may do.

curl -s https://app.todomode.com/api/v1/me -H "Authorization: Bearer $TODOMODE_TOKEN"
{
  "data": {
    "id": 1,
    "email": "ann@example.com",
    "name": "Ann",
    "timezone": "Europe/Berlin",
    "week_start": 1,
    "today": "2026-10-08",
    "plan": "active",
    "access": {"read": true, "write": true, "reason": "active", "until": null},
    "token": {"id": 1, "name": "Laptop CLI", "prefix": "tm_xZ9rfl", "scope": "write", "expires_at": null}
  }
}

GET /projects #

Your projects in your order, then the ones shared with you, by title. Not paginated.

curl -s https://app.todomode.com/api/v1/projects -H "Authorization: Bearer $TODOMODE_TOKEN"

GET /projects/{project_id} #

One project. 404 if you cannot see it.

POST /projects #

Create a project. Write scope.

curl -s https://app.todomode.com/api/v1/projects -H "Authorization: Bearer $TODOMODE_TOKEN" \
  -H "Content-Type: application/json" -d '{"title": "Side project"}'

Answers 201 with the project. title is 1–255 characters.

PATCH /projects/{project_id} #

Rename a project: {"title": "New name"}. Write scope; only the project's owner (403 for a shared project, as in the app).

Projects cannot be deleted through the API in v1: deleting a project is permanent and takes every task with it, with no trash to restore from. Do it in the app.

GET /projects/{project_id}/tasks and GET /tasks #

Tasks of one project in outline order, or (GET /tasks) of every project you can read — project by project, each in outline order. Trashed tasks never appear; archived ones only on request.

ParameterMeaning
project_id(GET /tasks only) one project, like the other form.
completedtrue or false; omit (or all) for both.
dueA due-date horizon: overdue, today, tomorrow, thisweek, nextweek, later, yesterday, lastweek, any (has a date), none (has none). overdue means open and before today. Weeks follow the user's week_start.
tagTasks with this tag (without #, case-insensitive).
priorityhigh, medium or low.
parent_idDirect children of this task; root for top-level tasks.
updated_sinceISO 8601 moment. Only tasks changed after it, oldest change first, plus deleted tombstones. See Keeping a copy in sync.
include_archivedtrue to include archived tasks.
includenotes to include each task's notes and notes_hash.
limit, cursorPagination.
# Open tasks due this week, across every project
curl -s "https://app.todomode.com/api/v1/tasks?completed=false&due=thisweek" -H "Authorization: Bearer $TODOMODE_TOKEN"

# One project's outline, with notes
curl -s "https://app.todomode.com/api/v1/projects/proj_6ac7b898efa547.08938441/tasks?include=notes" -H "Authorization: Bearer $TODOMODE_TOKEN"

GET /tasks/{task_id} #

One task, with its notes, notes_hash, and reminders — every additional reminder: {"id", "mode", "offset", "at", "source"}, where at is in your timezone with its offset.

The tasks whose title or note links to this one, in projects you can read. Trashed tasks are not listed; a task you cannot read is a 404, as everywhere.

{
  "data": [
    {
      "task_id": "task_6ac7b8a1c2d3e4.10293847",
      "title": "Prep for Fix login",
      "project_id": "proj_6ac7b898efa547.08938441",
      "project_title": "Work",
      "in": ["title"],
      "completed": false,
      "archived": false,
      "url": "https://app.todomode.com/?action=project&project=proj_6ac7b898efa547.08938441&highlight=task_6ac7b8a1c2d3e4.10293847"
    }
  ]
}

in is where the link sits: title, note, or both. title is plain text: links inside it show the linked task's title, or (private link). Fetch the task for its raw text.

POST /projects/{project_id}/tasks and POST /tasks #

Add a task. Write scope, and edit access to the project. POST /tasks takes project_id in the body; otherwise the two are identical.

FieldMeaning
textRequired. The title, read with the inline shorthand (below): "Fix login due:fri at:9am #work !high". Up to 2000 bytes as sent, and 1000 once the shorthand is taken out.
notesMarkdown notes.
dueA due date in words or ISO: "2026-10-20", "fri", "next week", "in 3 days", "tomorrow 9am" (a time sets due_time), or null. Up to 100 bytes.
due_time, due_end_time"09:00", "9am", "2:30pm", or null. A time without a date means today.
priorityhigh, medium, low or null.
tagsArray of tags, ["work", "q4"] (letters, digits, -, _; up to 50 characters each).
recurrence"weekly", "every 2 weeks", "every monday", "every! 3 days", or an object {"type", "anchor", "until", "count"}. The first occurrence is due when given, otherwise the next date the rule falls on.
remindersArray, each like an r: token: "-1h" (an hour before the due time), "due", "-2d", "tomorrow 9am", "2026-10-20 14:30". The first is the task's own reminder, the rest additional ones (up to 10). Each up to 100 bytes.
parent_idMake it the last child of this task.
after_task_idPut it right after this task and everything under it, as its sibling (same parent, same indent). With parent_id, the anchor must be one of that parent's children.
client_request_idIdempotency key (see Idempotent creates).
strictDefault true: a shorthand token that cannot be read (due:someday) is a 422 unparseable_syntax and nothing is created. false does what the app does — drops the token, creates the task — and lists what was dropped in meta.warnings.

Fields you send win over the same thing written in text: {"text": "Plan !low", "priority": "high"} is high. Without parent_id or after_task_id the task goes to the end of the project at the top level. Placement is always decided by the server against the live outline, so concurrent writers cannot interleave subtrees.

curl -s https://app.todomode.com/api/v1/projects/proj_6ac7b898efa547.08938441/tasks \
  -H "Authorization: Bearer $TODOMODE_TOKEN" -H "Content-Type: application/json" \
  -d '{"text": "Water plants @ every! 3 days", "client_request_id": "cli-7f3a"}'
{
  "data": {
    "id": "task_6ac7b898f03cf6.56834407",
    "text": "Water plants",
    "due_date": "2026-10-11",
    "recurrence": {"type": "every_3_days", "anchor": "completion", "until": null, "count": null},
    "…": "…"
  }
}

Answers 201 with the full task (as GET /tasks/{id}).

PATCH /tasks/{task_id} #

Change a task. Write scope; only the fields you send change. Takes every field of a create except placement, plus:

FieldMeaning
completedtrue / false — same as the complete/uncomplete endpoints.
expected_notes_hashWith notes: the notes_hash you read. See below.

text is read with the shorthand, but a token that is absent clears nothing: renaming a task keeps its date, tags, priority and recurrence. Clear things explicitly: "tags": [], "priority": null, "due": null (the time goes with the date), "recurrence": null, "notes": null. Reminders sent here are added; they do not replace existing ones. Re-sending the recurrence a task already has does not move its current occurrence.

curl -s -X PATCH https://app.todomode.com/api/v1/tasks/task_6ac7b898efe3d9.96506938 \
  -H "Authorization: Bearer $TODOMODE_TOKEN" -H "Content-Type: application/json" \
  -d '{"due": "next mon", "tags": ["work", "auth"]}'

Answers 200 with the task. Completing a recurring task this way also returns meta.next_occurrence.

Notes and the optimistic lock #

Notes are often long and edited in more than one place. To avoid overwriting someone else's edit, read the task, keep its notes_hash, and send it back as expected_notes_hash with the new notes. If the stored notes changed in the meantime, nothing is written and the answer is:

{"error": {"code": "notes_conflict", "message": "The notes were changed elsewhere since you read them.", "details": {"notes_hash": "9f86d0…"}}}

Fetch the task again, merge, and retry with the new hash. Without expected_notes_hash the write simply wins. Either way the previous version is kept in the task's notes history in the app.

POST /tasks/{task_id}/complete and POST /tasks/{task_id}/uncomplete #

Tick a task off, or reopen it. Write scope. Doing either twice is harmless. Completing a recurring task creates its next occurrence exactly as in the app — a new task holding the rule — and returns it as meta.next_occurrence; the completed one keeps no recurrence.

curl -s -X POST https://app.todomode.com/api/v1/tasks/task_6ac7b898efe3d9.96506938/complete \
  -H "Authorization: Bearer $TODOMODE_TOKEN"

POST /tasks/{task_id}/move #

Move a task and everything under it to another project (the app's :move). Write scope, and edit access to both projects. The tasks keep their ids, notes, tags, recurrence, reminders and files.

FieldMeaning
project_idRequired. The destination.
after_task_idLand after this task's subtree in the destination, as its sibling.
parent_idLand as this task's first child.
at_starttrue: the top of the destination. Default: the end, at the top level.

Answers with the moved task and meta.moved_ids / meta.count. Moving within one project (reordering, indenting) is not in v1.

DELETE /tasks/{task_id} #

Move a task to the trash, with everything under it. Write scope. Add ?subtree=false to trash only this task and keep its children (they move up to where it was). Trashed tasks stay restorable for 30 days, then are deleted for good.

{"data": {"batch_id": "trash_6b8bf3963cea35fb1d6d", "task_ids": ["task_6ac7b898efe3d9.96506938"], "count": 1}}

GET /trash #

What is in the trash of projects you can edit, newest first: batch_id, project_id, project_title, deleted_at, count, root_text (the first deleted task's title), days_left.

POST /trash/{batch_id}/restore #

Put a deleted batch back — in its old place if that still makes sense, otherwise at the end of the project. Write scope. Answers project_id, restored (how many tasks), in_place, task_ids. 404 once it has been restored or purged.

GET /search?q= #

Search like the app's search box, across every project you can read: words (all must match, in titles or notes; word* for a prefix), "exact phrases", -excluded words, a OR b, and operators — #tag (or tag:x), +name mentions, priority:high, due:today (any horizon name), done:thisweek (today, yesterday, thisweek, lastweek), completed:true|false, recurring:true|false. Archived and trashed tasks are not searched.

ParameterMeaning
qRequired, up to 500 characters.
limitUp to 200 (default 50).
curl -s -G https://app.todomode.com/api/v1/search --data-urlencode 'q=login #work' -H "Authorization: Bearer $TODOMODE_TOKEN"

data is the matching tasks, best first, each with project_title and notes_snippet (an excerpt of the notes when the match was there). meta has projects (projects whose titles match: id, title), total_tasks, and parsed (how the query was read).

GET /agenda #

"What is on my plate": open tasks with a due date across every project you can read, sorted by day and time (timed tasks first within a day), grouped into the app's agenda sections — overdue, today, tomorrow, thisweek (the rest of this week), nextweek, later.

ParameterMeaning
withinThe last section to include: today (overdue and today), tomorrow, thisweek (default), nextweek, all.
{
  "data": {
    "today": "2026-10-08",
    "timezone": "Europe/Berlin",
    "within": "thisweek",
    "sections": [
      {"key": "overdue", "title": "Overdue", "start": null, "end": "2026-10-07", "tasks": []},
      {"key": "today", "title": "Today", "start": "2026-10-08", "end": "2026-10-08", "tasks": []},
      {"key": "tomorrow", "title": "Tomorrow", "start": "2026-10-09", "end": "2026-10-09",
       "tasks": [{"id": "task_6ac7b898efe3d9.96506938", "text": "Fix login", "due_time": "09:00", "project_title": "Work", "…": "…"}]},
      {"key": "thisweek", "title": "This week", "start": "2026-10-05", "end": "2026-10-11", "tasks": []}
    ]
  }
}

Every section in range is listed, empty or not. A section's start/end are the dates it covers; a task is in exactly one section.

POST /projects/{project_id}/import #

Paste an outline into a project in one request — handy for piping a file from a command line. Write scope.

FieldMeaning
textRequired. Indented text or a markdown list (-, *, 1., [ ]/[x]; nesting by indentation), OPML, or a Todoist CSV. Up to 5 MB and 10,000 items. #tags, !priority and @daily in lines work as typed.
formatauto (default), text, opml, todoist_csv.
parent_id, after_task_idWhere the block goes, as for a create; default the end of the project.
client_request_idIdempotency key.
printf -- "- Groceries\n  - milk\n  - bread\n- Call the bank !high\n" | \
  jq -Rs '{text: .}' | \
  curl -s https://app.todomode.com/api/v1/projects/proj_6ac7b898efa547.08938441/import \
    -H "Authorization: Bearer $TODOMODE_TOKEN" -H "Content-Type: application/json" -d @-

Answers 201 with {"count": 4, "first_id": "task_…", "created": true}.

The inline shorthand #

Task text sent to the API is read exactly as the app's input box reads it. Tokens are taken out of the title and set fields:

TokenSetsExamples
!high !medium !low !1 !2 !3priority (the first one; a second stays in the title)!high, !2
#tagtags (after a space or at the start; # inside a URL is left alone)#work #q4
@daily @weekly @monthly @yearlyrecurrence@weekly
@ every … / @ every! …recurrence (every! counts from completion)@ every 2 weeks, @ every monday, friday, @ every 2nd wed, @ every 15th, @ every weekday until dec 31, @ every! 3 days
due:due date, optionally with a timedue:today, due:tomorrow 9am, due:fri, due:next week, due:in 3 days, due:2w, due:march 24, due:2026-12-25
at:time of day (dates the task today if it has no date)at:9am, at:14:30, at:9-10am, at:allday, at:none
r: / remind:reminders; every token countsr:-1h, r:due, r:-2d, r:tomorrow 9am, r:in 30 minutes

Date words, relative to the user's today in their timezone:

A token's value runs until the next token, #, !, @, or the words and/with: in due:next friday #work, the date is "next friday".

A task's title or notes can link to another task. The stored form is the target's id, then an optional label:

Call the bank about [[task_6ac7b898efe3d9.96506938|Fix login]]
Background in [[task_6ac7b898efe3d9.96506938]]

To follow a link, GET /tasks/{id} the id. GET /tasks/{id}/backlinks lists what links to a task. Writes return the task as stored, so text and notes show the resolved form.

Keeping a copy in sync #

To mirror tasks (a CLI cache, an MCP server's index, a calendar bridge):

  1. Full load. GET /tasks?include_archived=true (all pages). Remember the time you started the request (in UTC, from your clock or the response's Date header) as the checkpoint.
  2. Changes. Every so often, GET /tasks?updated_since=<checkpoint>&include_archived=true (all pages). data holds every task created or changed since, oldest change first; upsert them by id. deleted lists the tasks moved to the trash since: {"id", "project_id", "deleted_at"} — drop them. Then move the checkpoint to when that request started. Overlap a second or two; upserts are idempotent.
  3. A task restored from the trash comes back as a change. A task moved to another project comes back as a change with its new project_id. A task archived by automatic archiving comes back as a change with archived: true; one brought back from the archive in the app may not, so also resync in full once a day or so.
  4. Resync in full if your checkpoint is more than 25 days old: the trash is purged after 30 days and a purged task leaves no tombstone. Also resync if a project disappears from GET /projects (deleted in the app, or no longer shared with you) — its tasks are gone too.

Use If-None-Match on frequent polls of small lists; respect the rate limits (a minute between change polls is plenty).

The command line and AI assistants #

You may not need to build anything:

If you are building your own:

Versioning #

Security notes #

Not in v1 #

Deleting projects, reordering or indenting within a project, attachments and images, sharing and public links, deleting individual reminders, notes history, timers, webhooks. The app does all of these; tell us which you need.