TodoZen API

Everything the TodoZen app does, it does through this API. Base URL https://todozen.apercallc.com/api/v1. Authenticate with an API key from Settings.

Machine-readable copies: /llms.txt, /llms-full.txt, and the OpenAPI document.

TodoZen is a product of Aperca LLC. API support: support@apercallc.com.

Quickstart

The TodoZen API is a normal REST API over JSON. Everything the web app can do, it does through these same endpoints — there is no private API behind them.

Create an API key in Settings → API keys. The token is shown exactly once, at creation; nothing stores it and no later read can reproduce it. If you lose it, revoke the key and issue another.

Send the token as a bearer token on every request. That is the whole of authentication.

Your first request
TODOZEN_API_KEY="paste-key-from-settings"
curl https://todozen.apercallc.com/api/v1/tasks \
  -H "Authorization: Bearer ${TODOZEN_API_KEY}"
Creating a task
TODOZEN_API_KEY="paste-key-from-settings"
curl https://todozen.apercallc.com/api/v1/tasks \
  -H "Authorization: Bearer ${TODOZEN_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"title":"Buy milk","priority":"low"}'

Authentication and scopes

Two credentials reach this API. The web app uses a session cookie. Everything else uses an API key, sent as Authorization: Bearer <token>.

They are never mixed. Once a bearer token is present it is the only credential consulted — a cookie riding along on the same request is ignored. That means a key's permissions are exactly its scopes, never widened by whoever happens to be signed in to the browser.

The authority is represented as read and write scopes. Every key issued by this build currently receives both, which is why Settings says every key can read and write your TodoZen data. The scope list remains explicit in the key and every request still checks the required one; do not describe today's keys as read-only or write-only.

Two things a key can never do, no matter its scopes: mint another API key, and delete the account. Both require a real signed-in session, so a leaked key cannot issue its own replacement before you revoke it.

Revoking is immediate and permanent, from Settings → API keys. Revoked keys stay in the list on purpose — after a leak, the first question is when the key stopped working, and a row that vanished cannot answer it.

ScopeGrantsNeeded by
readReading anything in the workspace, including a full export.GET endpoints and GET /api/v1/export
writeCreating, editing, completing, deleting, and importing.POST, PATCH, PUT and DELETE endpoints

Calling from a browser

A browser extension, a userscript, or a page on another origin can call this API directly, as long as it authenticates with an API key. Cross-origin responses carry Access-Control-Allow-Origin: * and expose X-Request-Id along with the three X-RateLimit-* headers.

A cross-origin request without a bearer token is refused with 403 forbidden_origin. That is deliberate: a cookie is authority the browser attaches by itself, so accepting a cookie-authenticated request from another origin is exactly what CSRF is. A bearer token is not attached by anybody but you.

For the same reason Access-Control-Allow-Credentials is never sent. Do not use credentials: "include" — the request will fail its preflight. Send the key instead.

From an extension service worker
const response = await fetch("https://todozen.apercallc.com/api/v1/tasks", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${token}`,
    "Content-Type": "application/json",
  },
  // Note: no `credentials: "include"`. The key is the credential.
  body: JSON.stringify({ title: "Read this later" }),
});

Conventions

Pagination is cursor-based, never offset. List endpoints return { items, nextCursor }. Pass nextCursor back as ?cursor= to get the following page; a null cursor means you have reached the end. The default page is 50 items and the maximum is 200.

Ordering is `(createdAt, id)`, which is what makes a cursor stable while rows are being added. Search is the one exception: results are ranked by relevance, so ?q= uses its own cursor shape. A cursor from a search cannot resume a list, or the other way round — mixing them is rejected with validation_failed rather than silently resuming from the wrong place.

Times are ISO 8601 in UTC, always, on the way in and on the way out. Day-based concepts are resolved in the account's own timezone: today means the day it is where you are, not where the server is. Journal entries are addressed by a plain YYYY-MM-DD day, not a timestamp.

**The Today view means due by the end of today *or* already overdue.** Overdue tasks are not a separate list; they sort to the top of this one.

Errors share one envelope{ error: { code, message, requestId } }, plus details[] for validation failures. Switch on code: it is the stable contract. message is written for humans and may be reworded. requestId matches the X-Request-Id response header and is safe to quote in a bug report.

Rate limits are per identity, per profile: 300 reads a minute, 120 writes a minute, 5 exports an hour. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a 429 also carries Retry-After. Read them rather than guessing.

Paging through every task
let cursor = null;
const all = [];
do {
  const url = new URL("https://todozen.apercallc.com/api/v1/tasks");
  url.searchParams.set("limit", "200");
  if (cursor) url.searchParams.set("cursor", cursor);
  const page = await fetch(url, {
    headers: { Authorization: `Bearer ${token}` },
  }).then((response) => response.json());
  all.push(...page.items);
  cursor = page.nextCursor;
} while (cursor);
The error envelope
{
  "error": {
    "code": "validation_failed",
    "message": "title is required.",
    "requestId": "c8be31bf-87eb-4beb-b242-e14e437fb14b",
    "details": [{ "path": "title", "message": "title is required." }]
  }
}

The data model

A workspace is the tenancy boundary. Every account has a personal workspace; shared workspaces add explicit members. Send x-workspace-id to select one you belong to. A shared workspace carries tasks, goals and habits, but no journal.

Tasks are the centre of it. A task may have a due date, a priority, tags, and a parent.

Subtasks are exactly one level deep. A subtask cannot have subtasks of its own. The views list top-level tasks with their subtasks resolved underneath; the flat GET /api/v1/tasks listing and search both return subtasks as rows of their own, each carrying its parentId.

A goal is a heading, not a container. Tasks are filed under a goal, but the goal does not own them: deleting a goal leaves its tasks alone, which is the opposite of what deleting a parent task does to its subtasks. A goal's progress is counted from its live tasks on every read and never stored, so it cannot disagree with the rows.

Tags are one vocabulary, shared between tasks and journal entries. #health means the same thing on either.

Journal entries are addressed by day. One entry per day, written in the account's timezone. Writing to a day that already has an entry replaces it.

Habits record check-offs, not completion. A habit definition belongs to a workspace; each member's dates and streak are personal to that member.

Sharing is an explicit grant, not a move. A task, journal entry or habit stays in its workspace while a recipient gets read access through /api/v1/shared. The recipient is an email address; the create response is identical whether that address already has an account.

Notifications are durable facts with per-channel deliveries. In-app, email and web-push preferences decide which outbox rows are created; provider failure never rolls back the action that caused the notification.

Deleting a task is a soft delete. It disappears from every view but can be restored, until a sweep removes it for good.

Connecting an MCP client

The API is also served as MCP tools, at `https://todozen.apercallc.com/api/v1/mcp`. Point any MCP client at that URL, give it an API key as a bearer token, and it can read and write tasks, goals, habits and journal entries by name rather than by composing HTTP requests.

The tools are the API's own vocabulary: list_tasks, create_task, update_task, complete_task, reopen_task, delete_task, list_goals, create_goal, read_journal, write_journal, list_habits, create_habit, check_off_habit and search. Each one calls the same service a REST route calls, so the same rules apply — the workspace filter, the scope on your key, the rate limits, and the domain rules that refuse a due date in the past.

Two things deliberately have no tool: issuing an API key, and deleting the account. A key may never mint its replacement or destroy the workspace it reads, and the surest way to keep that true is to leave the operations out rather than to add a tool that refuses.

Deletes made through a tool are the same reversible soft deletes the app makes. Nothing on this endpoint removes anything permanently.

Claude Code
claude mcp add --transport http todozen https://todozen.apercallc.com/api/v1/mcp \
  --header "Authorization: Bearer <my key>"

Using this API with an AI assistant

Two machine-readable copies of this reference are served for AI assistants. `https://todozen.apercallc.com/llms.txt` is a short index — what the API is, how to authenticate, and where the rest lives. `https://todozen.apercallc.com/llms-full.txt` is this entire reference as one flat markdown file, every endpoint included.

Give your assistant the URL and your API key, and it has everything it needs. The full file is the better choice when the assistant cannot browse: paste it in once and it can compose requests without guessing at endpoints.

The OpenAPI 3.2 document is also served, at /api/docs, for tools that consume it directly — client generators, HTTP clients, agent frameworks that read OpenAPI natively. An assistant that speaks MCP does not need any of this: point it at the endpoint above instead.

A word of caution worth passing on: an API key is a credential. Every key issued by this build has both read and write access, so giving one to an assistant lets it read and change TodoZen data. Use a separate key with a short expiry when practical, and revoke it when the work is done.

What to tell your assistant
Use the TodoZen API. The reference is at https://todozen.apercallc.com/llms-full.txt.
Authenticate every request with: Authorization: Bearer <my key>

Tasks

The four views, one task with its subtasks, and the state transitions.

GET/api/v1/tasks

List tasks, filtered by view/tag, or full-text search

Requires read scope.

ParameterInTypeRequiredNotes
limitqueryintegernoDefaults to 50.
cursorquerystringnoOpaque page token from a previous response's nextCursor. Search results (q) and list results use different cursor shapes; a cursor from one is rejected by the other with validation_failed.
viewquerytoday, upcoming, all, completednoOne of today, upcoming, all, completed. Defaults to all. today returns open tasks due by the end of today in the caller's timezone, which includes anything already overdue; upcoming starts at tomorrow. all is returned in the user's manual order (see PUT /api/v1/tasks/{id}/position); the other three are ordered by createdAt.
tagquerystringno
qquerystringnoFull-text search over title and notes. Takes over the whole listing — view and tag are ignored when q is set, and results are ranked by relevance rather than recency. Paginated like every other list: follow nextCursor to reach the rest of the matches.
Response 200 — A page of tasks.
{
  "items": [
    {
      "id": "string",
      "title": "string",
      "notes": "string",
      "priority": "high",
      "dueAt": "2026-08-21T09:00:00.000Z",
      "completedAt": "2026-08-21T09:00:00.000Z",
      "tags": [
        "string"
      ],
      "createdAt": "2026-08-21T09:00:00.000Z",
      "updatedAt": "2026-08-21T09:00:00.000Z",
      "parentId": "string",
      "goalId": "string",
      "recurrence": "string",
      "location": "string",
      "assigneeId": "string",
      "subtasks": [
        {
          "id": "string",
          "title": "string",
          "notes": "string",
          "priority": "high",
          "dueAt": "2026-08-21T09:00:00.000Z",
          "completedAt": "2026-08-21T09:00:00.000Z",
          "tags": [
            "string"
          ],
          "createdAt": "2026-08-21T09:00:00.000Z",
          "updatedAt": "2026-08-21T09:00:00.000Z",
          "parentId": "string",
          "goalId": "string",
          "recurrence": "string",
          "location": "string",
          "assigneeId": "string"
        }
      ],
      "progress": {
        "completed": 1,
        "total": 1,
        "allComplete": false
      }
    }
  ],
  "nextCursor": "string"
}

Errors: 400 Malformed cursor, view, or query param. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.

POST/api/v1/tasks

Create a task

Requires write scope.

ParameterInTypeRequiredNotes
Idempotency-KeyheaderstringnoA unique key for this logical create. Successful results are retained for 24 hours and safely replayed.
Request body
{
  "title": "string",
  "notes": "string",
  "priority": "high",
  "dueAt": "2026-08-21T09:00:00.000Z",
  "tags": [
    "string"
  ],
  "parentId": "string",
  "recurrence": "string",
  "location": "string",
  "assigneeId": "string"
}
Response 201 — The created task.
{
  "item": {
    "id": "string",
    "title": "string",
    "notes": "string",
    "priority": "high",
    "dueAt": "2026-08-21T09:00:00.000Z",
    "completedAt": "2026-08-21T09:00:00.000Z",
    "tags": [
      "string"
    ],
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z",
    "parentId": "string",
    "goalId": "string",
    "recurrence": "string",
    "location": "string",
    "assigneeId": "string"
  }
}

Errors: 400 Validation failed. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 409 Idempotency key conflict. · 422 Due date is in the past, or priority is invalid. · 429 Rate limited.

GET/api/v1/tasks/{id}

Get a single task

Requires read scope.

ParameterInTypeRequiredNotes
idpathstringyes
Response 200 — The task, with its subtasks.
{
  "item": {
    "id": "string",
    "title": "string",
    "notes": "string",
    "priority": "high",
    "dueAt": "2026-08-21T09:00:00.000Z",
    "completedAt": "2026-08-21T09:00:00.000Z",
    "tags": [
      "string"
    ],
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z",
    "parentId": "string",
    "goalId": "string",
    "recurrence": "string",
    "location": "string",
    "assigneeId": "string",
    "subtasks": [
      {
        "id": "string",
        "title": "string",
        "notes": "string",
        "priority": "high",
        "dueAt": "2026-08-21T09:00:00.000Z",
        "completedAt": "2026-08-21T09:00:00.000Z",
        "tags": [
          "string"
        ],
        "createdAt": "2026-08-21T09:00:00.000Z",
        "updatedAt": "2026-08-21T09:00:00.000Z",
        "parentId": "string",
        "goalId": "string",
        "recurrence": "string",
        "location": "string",
        "assigneeId": "string"
      }
    ],
    "progress": {
      "completed": 1,
      "total": 1,
      "allComplete": false
    }
  }
}

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 Task doesn't exist, or belongs to another workspace. · 429 Rate limited.

PATCH/api/v1/tasks/{id}

Edit a task (partial update)

Requires write scope.

ParameterInTypeRequiredNotes
idpathstringyes
If-MatchheaderstringnoQuoted updatedAt value from the item being changed. A stale value returns 412 instead of overwriting a newer edit.
Request body
{
  "title": "string",
  "notes": "string",
  "priority": "high",
  "dueAt": "2026-08-21T09:00:00.000Z",
  "tags": [
    "string"
  ],
  "parentId": "string",
  "recurrence": "string",
  "location": "string",
  "assigneeId": "string"
}
Response 200 — The updated task.
{
  "item": {
    "id": "string",
    "title": "string",
    "notes": "string",
    "priority": "high",
    "dueAt": "2026-08-21T09:00:00.000Z",
    "completedAt": "2026-08-21T09:00:00.000Z",
    "tags": [
      "string"
    ],
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z",
    "parentId": "string",
    "goalId": "string",
    "recurrence": "string",
    "location": "string",
    "assigneeId": "string"
  }
}

Errors: 400 Validation failed. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 Task doesn't exist, or belongs to another workspace. · 412 The If-Match version is stale. · 422 Due date is in the past, or priority is invalid. · 429 Rate limited.

DELETE/api/v1/tasks/{id}

Soft-delete a task

Requires write scope.

ParameterInTypeRequiredNotes
idpathstringyes
If-MatchheaderstringnoQuoted updatedAt value from the item being changed. A stale value returns 412 instead of overwriting a newer edit.
Response 200 — The soft-deleted task.
{
  "item": {
    "id": "string",
    "title": "string",
    "notes": "string",
    "priority": "high",
    "dueAt": "2026-08-21T09:00:00.000Z",
    "completedAt": "2026-08-21T09:00:00.000Z",
    "tags": [
      "string"
    ],
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z",
    "parentId": "string",
    "goalId": "string",
    "recurrence": "string",
    "location": "string",
    "assigneeId": "string"
  }
}

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 Task doesn't exist, or belongs to another workspace. · 412 The If-Match version is stale. · 429 Rate limited.

POST/api/v1/tasks/{id}/complete

Complete a task

Requires write scope.

ParameterInTypeRequiredNotes
idpathstringyes
If-MatchheaderstringnoQuoted updatedAt value from the item being changed. A stale value returns 412 instead of overwriting a newer edit.
Response 200 — Completion result.
{
  "item": {
    "id": "string",
    "title": "string",
    "notes": "string",
    "priority": "high",
    "dueAt": "2026-08-21T09:00:00.000Z",
    "completedAt": "2026-08-21T09:00:00.000Z",
    "tags": [
      "string"
    ],
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z",
    "parentId": "string",
    "goalId": "string",
    "recurrence": "string",
    "location": "string",
    "assigneeId": "string"
  },
  "next": {
    "id": "string",
    "title": "string",
    "notes": "string",
    "priority": "high",
    "dueAt": "2026-08-21T09:00:00.000Z",
    "completedAt": "2026-08-21T09:00:00.000Z",
    "tags": [
      "string"
    ],
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z",
    "parentId": "string",
    "goalId": "string",
    "recurrence": "string",
    "location": "string",
    "assigneeId": "string"
  }
}

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 Task doesn't exist, or belongs to another workspace. · 409 Task exists but cannot be completed: it is already completed (task_already_completed) or soft-deleted (task_deleted). · 412 The If-Match version is stale. · 429 Rate limited.

POST/api/v1/tasks/{id}/auto-tag

Compatibility no-op for the unavailable auto-tag action

Requires write scope.

ParameterInTypeRequiredNotes
idpathstringyes
Response 200 — The unchanged task with tagged: false.
{
  "item": {
    "id": "string",
    "title": "string",
    "notes": "string",
    "priority": "high",
    "dueAt": "2026-08-21T09:00:00.000Z",
    "completedAt": "2026-08-21T09:00:00.000Z",
    "tags": [
      "string"
    ],
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z",
    "parentId": "string",
    "goalId": "string",
    "recurrence": "string",
    "location": "string",
    "assigneeId": "string"
  },
  "tagged": false
}

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 Task doesn't exist, or belongs to another workspace. · 429 Rate limited.

POST/api/v1/tasks/{id}/reopen

Reopen a completed task (undo complete)

Requires write scope.

ParameterInTypeRequiredNotes
idpathstringyes
If-MatchheaderstringnoQuoted updatedAt value from the item being changed. A stale value returns 412 instead of overwriting a newer edit.
Response 200 — The reopened task.
{
  "item": {
    "id": "string",
    "title": "string",
    "notes": "string",
    "priority": "high",
    "dueAt": "2026-08-21T09:00:00.000Z",
    "completedAt": "2026-08-21T09:00:00.000Z",
    "tags": [
      "string"
    ],
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z",
    "parentId": "string",
    "goalId": "string",
    "recurrence": "string",
    "location": "string",
    "assigneeId": "string"
  }
}

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 Task doesn't exist, or belongs to another workspace. · 409 Task exists but cannot be reopened: it was never completed (task_not_completed) or is soft-deleted (task_deleted). · 412 The If-Match version is stale. · 429 Rate limited.

PUT/api/v1/tasks/{id}/position

Move a task in the All view's manual order

Requires write scope.

ParameterInTypeRequiredNotes
idpathstringyes
Request body
{
  "afterId": "string"
}
Response 200 — The moved task.
{
  "item": {
    "id": "string",
    "title": "string",
    "notes": "string",
    "priority": "high",
    "dueAt": "2026-08-21T09:00:00.000Z",
    "completedAt": "2026-08-21T09:00:00.000Z",
    "tags": [
      "string"
    ],
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z",
    "parentId": "string",
    "goalId": "string",
    "recurrence": "string",
    "location": "string",
    "assigneeId": "string"
  }
}

Errors: 400 Malformed body. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 Task doesn't exist, or belongs to another workspace. · 422 The task is a subtask (task_not_reorderable), or afterId names the task being moved (task_move_self_reference). · 429 Rate limited.

GET/api/v1/tasks/trash

List soft-deleted tasks, newest deletion first

Requires read scope.

ParameterInTypeRequiredNotes
limitqueryintegernoDefaults to 50.
cursorquerystringno
Response 200 — A page of trashed tasks. purgesAt is the earliest instant the retention sweep may hard-delete the row, not a promise that it will happen then.
{
  "items": [
    {
      "id": "string",
      "title": "string",
      "notes": "string",
      "priority": "high",
      "dueAt": "2026-08-21T09:00:00.000Z",
      "completedAt": "2026-08-21T09:00:00.000Z",
      "tags": [
        "string"
      ],
      "createdAt": "2026-08-21T09:00:00.000Z",
      "updatedAt": "2026-08-21T09:00:00.000Z",
      "parentId": "string",
      "goalId": "string",
      "recurrence": "string",
      "location": "string",
      "assigneeId": "string",
      "deletedAt": "2026-08-21T09:00:00.000Z",
      "purgesAt": "2026-08-21T09:00:00.000Z",
      "daysUntilPurge": 1
    }
  ],
  "nextCursor": "string"
}

Errors: 400 Malformed cursor. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.

DELETE/api/v1/tasks/trash

Empty the trash

Requires write scope.

Response 200 — How many rows were removed.
{
  "purged": 1
}

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.

POST/api/v1/tasks/{id}/purge

Delete one trashed task forever

Requires write scope.

ParameterInTypeRequiredNotes
idpathstringyes
Response 200 — How many rows were removed — the task plus any of its subtasks that were in the trash with it.
{
  "purged": 1
}

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 Task doesn't exist, or belongs to another workspace. · 429 Rate limited.

POST/api/v1/tasks/{id}/restore

Restore a soft-deleted task (undo delete)

Requires write scope.

ParameterInTypeRequiredNotes
idpathstringyes
Response 200 — The restored task.
{
  "item": {
    "id": "string",
    "title": "string",
    "notes": "string",
    "priority": "high",
    "dueAt": "2026-08-21T09:00:00.000Z",
    "completedAt": "2026-08-21T09:00:00.000Z",
    "tags": [
      "string"
    ],
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z",
    "parentId": "string",
    "goalId": "string",
    "recurrence": "string",
    "location": "string",
    "assigneeId": "string"
  }
}

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 Task doesn't exist, or belongs to another workspace. · 429 Rate limited.

PUT/api/v1/tasks/{id}/goal

Attach the task to a goal, or detach it

Requires write scope.

ParameterInTypeRequiredNotes
idpathstringyes
Request body
{
  "goalId": "string"
}
Response 200 — The task's new goal.
{
  "item": {
    "taskId": "string",
    "goalId": "string"
  }
}

Errors: 400 Body failed validation. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 No such task, or no such goal. · 409 The task is soft-deleted. · 422 The task is a subtask — attach its parent instead. · 429 Rate limited.

Goals

Goals group tasks without owning them; progress is counted on every read.

GET/api/v1/goals

List the workspace's goals

Requires read scope.

ParameterInTypeRequiredNotes
limitqueryintegerno
cursorquerystringno
archivedquerystringnoPresent at any value: include archived goals.
Response 200 — A page of goals.
{
  "items": [
    {
      "id": "string",
      "title": "string",
      "notes": "string",
      "targetDate": "2026-08-21T09:00:00.000Z",
      "archivedAt": "2026-08-21T09:00:00.000Z",
      "createdAt": "2026-08-21T09:00:00.000Z",
      "updatedAt": "2026-08-21T09:00:00.000Z",
      "progress": {
        "completed": 1,
        "total": 1,
        "allComplete": false
      }
    }
  ],
  "nextCursor": "string"
}

Errors: 400 Malformed cursor. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.

POST/api/v1/goals

Create a goal

Requires write scope.

ParameterInTypeRequiredNotes
Idempotency-KeyheaderstringnoA unique key for this logical create. Successful results are retained for 24 hours and safely replayed.
Request body
{
  "title": "string",
  "notes": "string",
  "targetDate": "2026-08-21T09:00:00.000Z"
}
Response 201 — The created goal.
{
  "item": {
    "id": "string",
    "title": "string",
    "notes": "string",
    "targetDate": "2026-08-21T09:00:00.000Z",
    "archivedAt": "2026-08-21T09:00:00.000Z",
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z",
    "progress": {
      "completed": 1,
      "total": 1,
      "allComplete": false
    }
  }
}

Errors: 400 Body failed validation. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 409 Idempotency key conflict. · 429 Rate limited.

GET/api/v1/goals/{id}

Read one goal, with its derived progress

Requires read scope.

ParameterInTypeRequiredNotes
idpathstringyes
Response 200 — The goal.
{
  "item": {
    "id": "string",
    "title": "string",
    "notes": "string",
    "targetDate": "2026-08-21T09:00:00.000Z",
    "archivedAt": "2026-08-21T09:00:00.000Z",
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z",
    "progress": {
      "completed": 1,
      "total": 1,
      "allComplete": false
    }
  }
}

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 No such goal in this workspace. · 429 Rate limited.

PATCH/api/v1/goals/{id}

Update or archive a goal

Requires write scope.

ParameterInTypeRequiredNotes
idpathstringyes
If-MatchheaderstringnoQuoted updatedAt value from the item being changed. A stale value returns 412 instead of overwriting a newer edit.
Request body
{
  "title": "string",
  "notes": "string",
  "targetDate": "2026-08-21T09:00:00.000Z",
  "archived": false
}
Response 200 — The updated goal.
{
  "item": {
    "id": "string",
    "title": "string",
    "notes": "string",
    "targetDate": "2026-08-21T09:00:00.000Z",
    "archivedAt": "2026-08-21T09:00:00.000Z",
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z",
    "progress": {
      "completed": 1,
      "total": 1,
      "allComplete": false
    }
  }
}

Errors: 400 Body failed validation. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 No such goal in this workspace. · 412 The If-Match version is stale. · 429 Rate limited.

DELETE/api/v1/goals/{id}

Soft-delete a goal

Requires write scope.

ParameterInTypeRequiredNotes
idpathstringyes
If-MatchheaderstringnoQuoted updatedAt value from the item being changed. A stale value returns 412 instead of overwriting a newer edit.
Response 200 — The soft-deleted goal.
{
  "item": {
    "id": "string",
    "title": "string",
    "notes": "string",
    "targetDate": "2026-08-21T09:00:00.000Z",
    "archivedAt": "2026-08-21T09:00:00.000Z",
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z",
    "progress": {
      "completed": 1,
      "total": 1,
      "allComplete": false
    }
  }
}

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 No such goal in this workspace. · 412 The If-Match version is stale. · 429 Rate limited.

POST/api/v1/goals/{id}/restore

Restore a soft-deleted goal (undo delete)

Requires write scope.

ParameterInTypeRequiredNotes
idpathstringyes
Response 200 — The restored goal.
{
  "item": {
    "id": "string",
    "title": "string",
    "notes": "string",
    "targetDate": "2026-08-21T09:00:00.000Z",
    "archivedAt": "2026-08-21T09:00:00.000Z",
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z",
    "progress": {
      "completed": 1,
      "total": 1,
      "allComplete": false
    }
  }
}

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 No such goal in this workspace. · 429 Rate limited.

Journal

One entry per day, the daily prompt, and the streak.

GET/api/v1/journal/streak

How many days in a row the account has journalled

Requires read scope.

Response 200 — The current and longest run.
{
  "item": {
    "current": 1,
    "longest": 1,
    "today": "string"
  }
}

Errors: 401 No valid session. · 403 The active workspace is shared. Journals are available only in the personal workspace. · 429 Rate limited.

GET/api/v1/journal

Browse journal entries by date range (most recent first)

Requires read scope.

ParameterInTypeRequiredNotes
limitqueryintegernoDefaults to 50.
cursorquerystringno
fromquerystringnoYYYY-MM-DD, inclusive.
toquerystringnoYYYY-MM-DD, inclusive.
pinnedquerystringnoPresent at any value: only entries marked worth coming back to.
tagquerystringnoOnly entries carrying this tag. Matched case-insensitively.
Response 200 — A page of journal entries.
{
  "items": [
    {
      "id": "string",
      "date": "string",
      "body": "string",
      "mood": "rough",
      "tags": [
        "string"
      ],
      "pinnedAt": "2026-08-21T09:00:00.000Z",
      "createdAt": "2026-08-21T09:00:00.000Z",
      "updatedAt": "2026-08-21T09:00:00.000Z"
    }
  ],
  "nextCursor": "string"
}

Errors: 400 Malformed cursor, from, or to. · 401 No valid session. · 403 The active workspace is shared. Journals are available only in the personal workspace. · 429 Rate limited.

GET/api/v1/journal/today

Get today's journal entry (in the caller's timezone)

Requires read scope.

Response 200 — Today's entry, or item: null if nothing has been saved yet today.
{
  "item": {
    "id": "string",
    "date": "string",
    "body": "string",
    "mood": "rough",
    "tags": [
      "string"
    ],
    "pinnedAt": "2026-08-21T09:00:00.000Z",
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z"
  },
  "date": "string",
  "prompt": "string"
}

Errors: 401 No valid session. · 403 The active workspace is shared. Journals are available only in the personal workspace. · 429 Rate limited.

PUT/api/v1/journal/today

Upsert today's journal entry

Requires write scope.

Request body
{
  "body": "string",
  "mood": "rough",
  "tags": [
    "string"
  ],
  "pinned": false
}
Response 200 — The saved entry.
{
  "item": {
    "id": "string",
    "date": "string",
    "body": "string",
    "mood": "rough",
    "tags": [
      "string"
    ],
    "pinnedAt": "2026-08-21T09:00:00.000Z",
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z"
  }
}

Errors: 400 Validation failed. · 401 No valid session. · 403 The active workspace is shared. Journals are available only in the personal workspace. · 422 Body too long, or mood is invalid. · 429 Rate limited.

GET/api/v1/journal/{date}

Get the entry for a specific date

Requires read scope.

ParameterInTypeRequiredNotes
datepathstringyesYYYY-MM-DD
Response 200 — The entry for that date, or item: null if none exists.
{
  "item": {
    "id": "string",
    "date": "string",
    "body": "string",
    "mood": "rough",
    "tags": [
      "string"
    ],
    "pinnedAt": "2026-08-21T09:00:00.000Z",
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z"
  }
}

Errors: 400 date is not YYYY-MM-DD. · 401 No valid session. · 403 The active workspace is shared. Journals are available only in the personal workspace. · 429 Rate limited.

PUT/api/v1/journal/{date}

Upsert an entry for today or an earlier date

Requires write scope.

ParameterInTypeRequiredNotes
datepathstringyesA real YYYY-MM-DD day, no later than today.
Request body
{
  "body": "string",
  "mood": "rough",
  "tags": [
    "string"
  ],
  "pinned": false
}
Response 200 — The saved entry.
{
  "item": {
    "id": "string",
    "date": "string",
    "body": "string",
    "mood": "rough",
    "tags": [
      "string"
    ],
    "pinnedAt": "2026-08-21T09:00:00.000Z",
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z"
  }
}

Errors: 400 Date or request body is invalid. · 401 No valid session. · 403 The active workspace is shared. Journals are available only in the personal workspace. · 422 Date is in the future, body is too long, or mood is invalid. · 429 Rate limited.

PATCH/api/v1/journal/{date}

Restore a soft-deleted entry (undo delete)

Requires write scope.

ParameterInTypeRequiredNotes
datepathstringyesYYYY-MM-DD

Errors: 400 date is not YYYY-MM-DD. · 401 No valid session. · 403 The active workspace is shared. Journals are available only in the personal workspace. · 404 Entry doesn't exist, is already live, or belongs to another workspace. · 429 Rate limited.

DELETE/api/v1/journal/{date}

Soft-delete the entry for a specific date

Requires write scope.

ParameterInTypeRequiredNotes
datepathstringyesYYYY-MM-DD

Errors: 400 date is not YYYY-MM-DD. · 401 No valid session. · 403 The active workspace is shared. Journals are available only in the personal workspace. · 404 Entry doesn't exist, is already deleted, or belongs to another workspace. · 429 Rate limited.

POST/api/v1/journal/{date}/auto-tag

Compatibility no-op for the unavailable auto-tag action

Requires write scope.

ParameterInTypeRequiredNotes
datepathstringyesYYYY-MM-DD
Response 200 — The unchanged entry with tagged: false.
{
  "item": {
    "id": "string",
    "date": "string",
    "body": "string",
    "mood": "rough",
    "tags": [
      "string"
    ],
    "pinnedAt": "2026-08-21T09:00:00.000Z",
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z"
  },
  "tagged": false
}

Errors: 400 date is not YYYY-MM-DD. · 401 No valid session. · 403 The active workspace is shared. Journals are available only in the personal workspace. · 404 No entry exists for that date. · 429 Rate limited.

Habits

A habit is not a recurring task: it is never finished, and streaks are counted for the calling user from the check-offs themselves.

GET/api/v1/habits

List the workspace's habits

Requires read scope.

ParameterInTypeRequiredNotes
limitqueryintegernoDefaults to 50.
cursorquerystringno
archivedquerystringnoPresent at any value: include archived habits.
Response 200 — A page of habits.
{
  "items": [
    {
      "id": "string",
      "name": "string",
      "notes": "string",
      "schedule": "string",
      "archivedAt": "2026-08-21T09:00:00.000Z",
      "createdAt": "2026-08-21T09:00:00.000Z",
      "updatedAt": "2026-08-21T09:00:00.000Z",
      "streak": {
        "current": 1,
        "longest": 1,
        "unit": "day"
      },
      "week": {
        "done": 1,
        "expected": 1
      },
      "doneToday": false,
      "recentDates": [
        "string"
      ]
    }
  ],
  "nextCursor": "string"
}

Errors: 400 Malformed cursor. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.

POST/api/v1/habits

Create a habit

Requires write scope.

ParameterInTypeRequiredNotes
Idempotency-KeyheaderstringnoA unique key for this logical create. Successful results are retained for 24 hours and safely replayed.
Request body
{
  "name": "string",
  "notes": "string",
  "schedule": "string"
}
Response 201 — The created habit.
{
  "item": {
    "id": "string",
    "name": "string",
    "notes": "string",
    "schedule": "string",
    "archivedAt": "2026-08-21T09:00:00.000Z",
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z",
    "streak": {
      "current": 1,
      "longest": 1,
      "unit": "day"
    },
    "week": {
      "done": 1,
      "expected": 1
    },
    "doneToday": false,
    "recentDates": [
      "string"
    ]
  }
}

Errors: 400 Body failed validation. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 409 Idempotency key conflict. · 422 `habit_invalid_schedule` — the words are well-formed but are not a schedule. · 429 Rate limited.

GET/api/v1/habits/{id}

Read one habit, with its streak and this week's progress

Requires read scope.

ParameterInTypeRequiredNotes
idpathstringyes
Response 200 — The habit.
{
  "item": {
    "id": "string",
    "name": "string",
    "notes": "string",
    "schedule": "string",
    "archivedAt": "2026-08-21T09:00:00.000Z",
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z",
    "streak": {
      "current": 1,
      "longest": 1,
      "unit": "day"
    },
    "week": {
      "done": 1,
      "expected": 1
    },
    "doneToday": false,
    "recentDates": [
      "string"
    ]
  }
}

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 No such habit in this workspace. · 429 Rate limited.

PATCH/api/v1/habits/{id}

Update or archive a habit

Requires write scope.

ParameterInTypeRequiredNotes
idpathstringyes
If-MatchheaderstringnoQuoted updatedAt value from the item being changed. A stale value returns 412 instead of overwriting a newer edit.
Request body
{
  "name": "string",
  "notes": "string",
  "schedule": "string",
  "archived": false
}
Response 200 — The updated habit.
{
  "item": {
    "id": "string",
    "name": "string",
    "notes": "string",
    "schedule": "string",
    "archivedAt": "2026-08-21T09:00:00.000Z",
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z",
    "streak": {
      "current": 1,
      "longest": 1,
      "unit": "day"
    },
    "week": {
      "done": 1,
      "expected": 1
    },
    "doneToday": false,
    "recentDates": [
      "string"
    ]
  }
}

Errors: 400 Body failed validation. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 No such habit in this workspace. · 412 The If-Match version is stale. · 422 The schedule is not one this app reads. · 429 Rate limited.

DELETE/api/v1/habits/{id}

Soft-delete a habit

Requires write scope.

ParameterInTypeRequiredNotes
idpathstringyes
If-MatchheaderstringnoQuoted updatedAt value from the item being changed. A stale value returns 412 instead of overwriting a newer edit.

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 No such habit in this workspace. · 412 The If-Match version is stale. · 429 Rate limited.

POST/api/v1/habits/{id}/restore

Restore a soft-deleted habit (undo delete)

Requires write scope.

ParameterInTypeRequiredNotes
idpathstringyes
Response 200 — The restored habit.
{
  "item": {
    "id": "string",
    "name": "string",
    "notes": "string",
    "schedule": "string",
    "archivedAt": "2026-08-21T09:00:00.000Z",
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z",
    "streak": {
      "current": 1,
      "longest": 1,
      "unit": "day"
    },
    "week": {
      "done": 1,
      "expected": 1
    },
    "doneToday": false,
    "recentDates": [
      "string"
    ]
  }
}

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 No such habit in this workspace. · 429 Rate limited.

PUT/api/v1/habits/{id}/entries

Check a day off, or un-check it

Requires write scope.

ParameterInTypeRequiredNotes
idpathstringyes
Request body
{
  "date": "string",
  "done": false,
  "note": "string"
}
Response 200 — The habit, with its updated streak.
{
  "item": {
    "id": "string",
    "name": "string",
    "notes": "string",
    "schedule": "string",
    "archivedAt": "2026-08-21T09:00:00.000Z",
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z",
    "streak": {
      "current": 1,
      "longest": 1,
      "unit": "day"
    },
    "week": {
      "done": 1,
      "expected": 1
    },
    "doneToday": false,
    "recentDates": [
      "string"
    ]
  }
}

Errors: 400 Body failed validation. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 No such habit in this workspace. · 422 That day has not happened yet. · 429 Rate limited.

Workspaces

Membership is collaboration: a shared workspace is the container two people work in together, and a personal workspace is the one a journal lives in. Invitations are a signed, expiring token sent to an email, redeemed while signed in.

GET/api/v1/workspaces

List the caller's workspaces

Requires read scope.

Response 200 — The caller's workspaces.
{
  "items": [
    {
      "id": "string",
      "name": "string",
      "kind": "personal",
      "role": "owner"
    }
  ]
}

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.

POST/api/v1/workspaces

Create a shared workspace

Requires write scope.

Request body
{
  "name": "string"
}
Response 201 — The created workspace, with the caller as owner.
{
  "item": {
    "id": "string",
    "name": "string",
    "kind": "personal",
    "role": "owner"
  }
}

Errors: 400 Body failed validation. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 422 The workspace needs a non-empty name. · 429 Rate limited.

GET/api/v1/workspaces/members

List the members of the active workspace

Requires read scope.

Response 200 — The members of the active workspace.
{
  "items": [
    {
      "userId": "string",
      "name": "string",
      "role": "owner"
    }
  ]
}

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.

GET/api/v1/workspaces/{id}/members

List the members of a named workspace

Requires read scope.

ParameterInTypeRequiredNotes
idpathstringyes
Response 200 — The members of the workspace.
{
  "items": [
    {
      "userId": "string",
      "name": "string",
      "role": "owner"
    }
  ]
}

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 No such workspace, or the caller is not a member. · 429 Rate limited.

POST/api/v1/workspaces/invitations

Invite someone to a shared workspace by email

Requires write scope.

Request body
{
  "workspaceId": "string",
  "email": "string"
}
Response 201 — The invitation was created and emailed.
{
  "item": {
    "url": "string",
    "email": "string"
  }
}

Errors: 400 Body failed validation. · 401 No valid session. · 403 The caller is not the workspace's owner. · 404 No such workspace, or the caller is not a member. · 422 The email is invalid, or the workspace is personal and cannot receive members. · 429 Rate limited.

POST/api/v1/workspaces/invitations/accept

Accept an invitation

Requires Signed-in session only — an API key cannot call this.

Request body
{
  "token": "string"
}
Response 200 — The workspace the caller is now a member of.
{
  "item": {
    "workspaceId": "string",
    "workspaceName": "string"
  }
}

Errors: 400 Body failed validation. · 401 No valid session. · 403 The signed-in email does not match the invitation. · 422 The token is invalid, expired, or the workspace is no longer shared. · 429 Rate limited.

PATCH/api/v1/workspaces/{id}

Rename a workspace

Requires Signed-in session only — an API key cannot call this.

ParameterInTypeRequiredNotes
idpathstringyes
Request body
{
  "name": "string"
}
Response 200 — The renamed workspace, with the caller's role.
{
  "item": {
    "id": "string",
    "name": "string",
    "kind": "personal",
    "role": "owner"
  }
}

Errors: 400 Body failed validation. · 401 No valid session. · 403 The caller is not the workspace's owner, or presented an API key instead of a session — the management routes are signed-in only. · 404 No such workspace, or the caller is not a member. · 429 Rate limited.

DELETE/api/v1/workspaces/{id}

Delete a workspace

Requires Signed-in session only — an API key cannot call this.

ParameterInTypeRequiredNotes
idpathstringyes

Errors: 401 No valid session. · 403 The caller is not the workspace's owner, or presented an API key instead of a session — the management routes are signed-in only. · 404 No such workspace, or the caller is not a member. · 422 A personal workspace cannot be mutated in this way. · 429 Rate limited.

DELETE/api/v1/workspaces/{id}/members/{userId}

Remove a member from a shared workspace

Requires Signed-in session only — an API key cannot call this.

ParameterInTypeRequiredNotes
idpathstringyes
userIdpathstringyesThe member's user id.

Errors: 401 No valid session. · 403 The caller is not the workspace's owner, or presented an API key instead of a session — the management routes are signed-in only. · 404 No such workspace, or the caller is not a member. · 409 The last owner cannot leave or be removed (transfer ownership first), and a transfer to someone who is already the owner is a no-op the API refuses. · 422 A personal workspace cannot be mutated in this way. · 429 Rate limited.

POST/api/v1/workspaces/{id}/transfer

Transfer ownership to another member

Requires Signed-in session only — an API key cannot call this.

ParameterInTypeRequiredNotes
idpathstringyes
Request body
{
  "toUserId": "string"
}
Response 200 — The workspace, with the caller's new role.
{
  "item": {
    "id": "string",
    "name": "string",
    "kind": "personal",
    "role": "owner"
  }
}

Errors: 400 Body failed validation. · 401 No valid session. · 403 The caller is not the workspace's owner, or presented an API key instead of a session — the management routes are signed-in only. · 404 No such workspace, the caller is not a member, or the named target is not a member — the three are indistinguishable on purpose. · 409 The last owner cannot leave or be removed (transfer ownership first), and a transfer to someone who is already the owner is a no-op the API refuses. · 422 A personal workspace cannot be mutated in this way. · 429 Rate limited.

POST/api/v1/workspaces/{id}/leave

Leave a shared workspace

Requires Signed-in session only — an API key cannot call this.

ParameterInTypeRequiredNotes
idpathstringyes

Errors: 401 No valid session. · 403 Presented an API key instead of a session — the management routes are signed-in only. · 404 No such workspace, or the caller is not a member. · 409 The last owner cannot leave or be removed (transfer ownership first), and a transfer to someone who is already the owner is a no-op the API refuses. · 422 A personal workspace cannot be mutated in this way. · 429 Rate limited.

Sharing

Explicit, revocable record grants addressed to a mailbox. The create response never reveals whether that address already has an account; a waiting grant is claimed when its recipient signs in.

GET/api/v1/shared

List records shared with the caller

Requires read scope.

ParameterInTypeRequiredNotes
limitqueryintegernoPage size, default 50, max 200.
cursorquerystringnoOpaque cursor from a previous page's nextCursor.
Response 200 — Grants shared with the caller, oldest first.
{
  "items": [
    {
      "id": "string",
      "resourceType": "task",
      "resourceId": "string",
      "grantedBy": {
        "userId": "string",
        "name": "string"
      },
      "canComment": false,
      "expiresAt": "2026-08-21T09:00:00.000Z",
      "createdAt": "2026-08-21T09:00:00.000Z",
      "resourceTitle": "string"
    }
  ],
  "nextCursor": "string"
}

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.

POST/api/v1/shared

Share a record with an email address

Requires write scope.

Request body
{
  "resourceType": "task",
  "resourceId": "string",
  "email": "string",
  "canComment": false,
  "expiresAt": "2026-08-21T09:00:00.000Z"
}
Response 201 — The created or updated grant.
{
  "item": {
    "id": "string",
    "resourceType": "task",
    "resourceId": "string",
    "grantedBy": {
      "userId": "string",
      "name": "string"
    },
    "canComment": false,
    "expiresAt": "2026-08-21T09:00:00.000Z",
    "createdAt": "2026-08-21T09:00:00.000Z",
    "resourceTitle": "string"
  }
}

Errors: 400 Body failed validation. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 The record does not exist, is deleted, or is outside the caller's active workspace. Recipient existence is never reported. · 422 The resourceType is not in the closed set, or the grant names the creator as its recipient. · 429 Rate limited.

GET/api/v1/shared/{id}

Resolve a grant to its record

Requires read scope.

ParameterInTypeRequiredNotes
idpathstringyes
Response 200 — The grant and the resolved record.
{
  "grant": {
    "id": "string",
    "resourceType": "task",
    "resourceId": "string",
    "grantedBy": {
      "userId": "string",
      "name": "string"
    },
    "canComment": false,
    "expiresAt": "2026-08-21T09:00:00.000Z",
    "createdAt": "2026-08-21T09:00:00.000Z",
    "resourceTitle": "string"
  },
  "item": {
    "kind": "task",
    "item": {
      "id": "string",
      "title": "string",
      "notes": "string",
      "priority": "high",
      "dueAt": "2026-08-21T09:00:00.000Z",
      "completedAt": "2026-08-21T09:00:00.000Z",
      "tags": [
        "string"
      ],
      "createdAt": "2026-08-21T09:00:00.000Z",
      "updatedAt": "2026-08-21T09:00:00.000Z",
      "parentId": "string",
      "goalId": "string",
      "recurrence": "string",
      "location": "string",
      "assigneeId": "string"
    }
  }
}

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 The grant does not exist, is revoked, has expired, or is not yours to read; or the underlying record was deleted. · 429 Rate limited.

DELETE/api/v1/shared/{id}

Revoke a grant

Requires write scope.

ParameterInTypeRequiredNotes
idpathstringyes
Response 200 — The revoked grant.
{
  "item": {
    "id": "string",
    "resourceType": "task",
    "resourceId": "string",
    "grantedBy": {
      "userId": "string",
      "name": "string"
    },
    "canComment": false,
    "expiresAt": "2026-08-21T09:00:00.000Z",
    "createdAt": "2026-08-21T09:00:00.000Z",
    "resourceTitle": "string"
  }
}

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 No such grant, or not yours to revoke. · 429 Rate limited.

Notifications

What happened, for whom, and whether it has been read. A notification is written inside the transaction that caused it; delivering it is a separate outbox row per channel — in-app, email or web push — drained later, so a provider being down cannot fail an assignment.

GET/api/v1/notifications

List the caller's notifications

Requires read scope.

ParameterInTypeRequiredNotes
unreadquerytrueno
limitqueryintegerno
cursorquerystringno
Response 200 — A page of notifications, with the unread count.
{
  "items": [
    {
      "id": "string",
      "kind": "task_due",
      "workspaceId": "string",
      "title": "string",
      "body": "string",
      "href": "string",
      "readAt": "string",
      "createdAt": "string"
    }
  ],
  "nextCursor": "string",
  "unreadCount": 1
}

Errors: 400 The cursor is not a valid cursor. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.

PATCH/api/v1/notifications

Mark notifications read

Requires write scope.

Request body
{
  "id": "string"
}
Response 200 — How many rows changed, and the new unread count.
{
  "updated": 1,
  "unreadCount": 1
}

Errors: 400 Body failed validation. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.

POST/api/v1/push/subscriptions

Register this browser for push notifications

Requires write scope.

Request body
{
  "endpoint": "string",
  "keys": {
    "p256dh": "string",
    "auth": "string"
  },
  "userAgent": "string"
}

Errors: 400 The subscription body is not valid. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited. · 503 Push is not configured on this server.

DELETE/api/v1/push/subscriptions

Forget this browser

Requires write scope.

Request body
{
  "endpoint": "string"
}

Errors: 400 The body is not valid. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.

Insights

The dashboard's numbers, counted from the rows on every read — there is no rollup table to fall out of step.

GET/api/v1/insights

Streaks, counts and trends over a window of days

Requires read scope.

ParameterInTypeRequiredNotes
daysquery7, 30, 90noOne of 7, 30, 90. Defaults to 30.
Response 200 — The window's numbers.
{
  "item": {
    "range": {
      "from": "string",
      "to": "string",
      "days": 1
    },
    "tasks": {
      "completedPerDay": [
        {
          "date": "string",
          "count": 1
        }
      ],
      "completedTotal": 1,
      "completedChangePercent": 1,
      "createdTotal": 1,
      "openNow": 1,
      "overdueNow": 1,
      "busiestDay": {
        "date": "string",
        "count": 1
      },
      "perDay": 1
    },
    "journal": {
      "entriesPerDay": [
        {
          "date": "string",
          "count": 1
        }
      ],
      "entriesTotal": 1,
      "streak": {
        "current": 1,
        "longest": 1
      },
      "moods": [
        {
          "mood": "string",
          "count": 1
        }
      ]
    },
    "habits": [
      {
        "id": "string",
        "name": "string",
        "schedule": "string",
        "streak": {
          "current": 1,
          "longest": 1,
          "unit": "day"
        },
        "week": {
          "done": 1,
          "expected": 1
        },
        "completionPercent": 1
      }
    ]
  }
}

Errors: 400 `days` is not one of the offered windows. · 401 No valid session. · 403 The active workspace is shared. Journals are available only in the personal workspace. · 429 Rate limited.

Tags

One vocabulary, shared by tasks and journal entries.

GET/api/v1/tags

List the workspace's tags

Requires read scope.

Response 200 — Every tag in the workspace.
{
  "items": [
    {
      "id": "string",
      "name": "string",
      "createdAt": "2026-08-21T09:00:00.000Z"
    }
  ]
}

Errors: 401 No valid session. · 429 Rate limited.

POST/api/v1/tags

Create a tag (find-or-create by normalized name)

Requires write scope.

Request body
{
  "name": "string"
}
Response 201 — The tag (created, or the existing one with that name).
{
  "item": {
    "id": "string",
    "name": "string",
    "createdAt": "2026-08-21T09:00:00.000Z"
  }
}

Errors: 400 Validation failed. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.

DELETE/api/v1/tags/{id}

Delete a tag (removes it from every task that had it)

Requires write scope.

ParameterInTypeRequiredNotes
idpathstringyes

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 404 Tag doesn't exist, or belongs to another workspace. · 429 Rate limited.

Calendar

The Calendar view reads a window of dated tasks and goals, and the private tasks-and-goals subscription URL is created, rotated or disabled here.

GET/api/v1/calendar

List dated tasks and goals in a window for the Calendar view

Requires read scope.

ParameterInTypeRequiredNotes
fromquerystringyesStart of the window, inclusive (YYYY-MM-DD).
toquerystringyesEnd of the window, inclusive (YYYY-MM-DD).
Response 200 — Dated tasks and goals in the window.
{
  "tasks": [
    {
      "id": "string",
      "title": "string",
      "notes": "string",
      "priority": "high",
      "dueAt": "2026-08-21T09:00:00.000Z",
      "completedAt": "2026-08-21T09:00:00.000Z",
      "tags": [
        "string"
      ],
      "createdAt": "2026-08-21T09:00:00.000Z",
      "updatedAt": "2026-08-21T09:00:00.000Z",
      "parentId": "string",
      "goalId": "string",
      "recurrence": "string",
      "location": "string",
      "assigneeId": "string"
    }
  ],
  "goals": [
    {
      "id": "string",
      "title": "string",
      "notes": "string",
      "targetDate": "2026-08-21T09:00:00.000Z",
      "archivedAt": "2026-08-21T09:00:00.000Z",
      "createdAt": "2026-08-21T09:00:00.000Z",
      "updatedAt": "2026-08-21T09:00:00.000Z",
      "progress": {
        "completed": 1,
        "total": 1,
        "allComplete": false
      }
    }
  ]
}

Errors: 400 `from`/`to` missing, malformed, out of order, or wider than 92 days. · 401 No valid session. · 429 Rate limited.

GET/api/v1/calendar-feed

Get private calendar subscription status

Requires read scope.

Response 200 — Whether calendar sync is active.
{
  "item": {
    "enabled": false,
    "createdAt": "2026-08-21T09:00:00.000Z",
    "updatedAt": "2026-08-21T09:00:00.000Z"
  }
}

Errors: 401 No valid session. · 429 Rate limited.

POST/api/v1/calendar-feed

Create or rotate the private calendar subscription URL

Requires write scope.

Response 201 — A one-time private URL for an iCalendar subscription. Rotating invalidates the previous URL.
{
  "item": {
    "enabled": true,
    "url": "string"
  }
}

Errors: 401 No valid session. · 429 Rate limited.

DELETE/api/v1/calendar-feed

Disable the private calendar subscription

Requires write scope.

Errors: 401 No valid session. · 429 Rate limited.

Geocoding

Free-text place suggestions for a task's location field, proxied to Photon (an open-source geocoder). Off for an account with locationSuggestionsEnabled turned off — see ADR 0058.

GET/api/v1/geocode/suggest

Suggest places for a free-text location query

Requires read scope.

ParameterInTypeRequiredNotes
qquerystringyesThe location text typed so far.
Response 200 — Up to 5 place suggestions, possibly empty.
{
  "items": [
    {
      "label": "string"
    }
  ]
}

Errors: 401 No valid session. · 429 Rate limited.

Export and import

The whole workspace out as one document, and additively back in.

POST/api/v1/import

Import a previously exported workspace document

Requires write scope.

ParameterInTypeRequiredNotes
modequeryskip-existing, mergenoDefaults to skip-existing.
Request body
{}
Response 200 — What landed.
{
  "item": {
    "counts": {
      "tasks": 1,
      "goals": 1,
      "journalEntries": 1,
      "habits": 1,
      "tags": 1,
      "skipped": 1
    }
  }
}

Errors: 400 Body is not a JSON object. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 413 Import document exceeds 10 MiB. · 422 Missing or unsupported format version, or the document failed validation. · 429 Rate limited.

GET/api/v1/export

Download the caller's full workspace data as JSON

Requires read scope.

Response 200 — The export.
{
  "version": 1,
  "exportedAt": "2026-08-21T09:00:00.000Z",
  "preferences": {
    "theme": "string",
    "themeName": "string",
    "timezone": "string",
    "webWalkthroughVersion": 1,
    "soundEnabled": false,
    "locationSuggestionsEnabled": false,
    "autoTaggingEnabled": false,
    "taskView": "today",
    "taskSort": "default",
    "features": {
      "habits": false,
      "dashboard": false,
      "themes": false,
      "sharing": false,
      "notifications": false
    },
    "notifications": {
      "inApp": false,
      "email": false,
      "push": false,
      "quietHoursStart": 1,
      "quietHoursEnd": 1,
      "dailyEmailCap": 1,
      "habitReminderHour": 1
    }
  },
  "tags": [
    {
      "id": "string",
      "name": "string",
      "createdAt": "2026-08-21T09:00:00.000Z"
    }
  ],
  "tasks": [
    {
      "id": "string",
      "title": "string",
      "notes": "string",
      "priority": "high",
      "dueAt": "2026-08-21T09:00:00.000Z",
      "completedAt": "2026-08-21T09:00:00.000Z",
      "tags": [
        "string"
      ],
      "createdAt": "2026-08-21T09:00:00.000Z",
      "updatedAt": "2026-08-21T09:00:00.000Z",
      "parentId": "string",
      "goalId": "string",
      "recurrence": "string",
      "location": "string"
    }
  ],
  "goals": [
    {
      "id": "string",
      "title": "string",
      "notes": "string",
      "targetDate": "2026-08-21T09:00:00.000Z",
      "archivedAt": "2026-08-21T09:00:00.000Z",
      "createdAt": "2026-08-21T09:00:00.000Z",
      "updatedAt": "2026-08-21T09:00:00.000Z",
      "progress": {
        "completed": 1,
        "total": 1,
        "allComplete": false
      }
    }
  ],
  "journalEntries": [
    {
      "id": "string",
      "date": "string",
      "body": "string",
      "mood": "rough",
      "tags": [
        "string"
      ],
      "pinnedAt": "2026-08-21T09:00:00.000Z",
      "createdAt": "2026-08-21T09:00:00.000Z",
      "updatedAt": "2026-08-21T09:00:00.000Z"
    }
  ],
  "habits": [
    {
      "id": "string",
      "name": "string",
      "notes": "string",
      "schedule": "string",
      "archivedAt": "2026-08-21T09:00:00.000Z",
      "createdAt": "2026-08-21T09:00:00.000Z",
      "updatedAt": "2026-08-21T09:00:00.000Z",
      "entries": [
        {
          "date": "string",
          "note": "string"
        }
      ]
    }
  ]
}

Errors: 401 No valid session. · 429 Rate limited.

API keys

Issuing and revoking keys. These routes require a signed-in session — a key cannot mint another.

GET/api/v1/api-keys

List the caller's API keys

Requires Signed-in session only — an API key cannot call this.

ParameterInTypeRequiredNotes
archivedquerystringnoPresent at any value: include archived keys.
Response 200 — Every key this account has issued.
{
  "items": [
    {
      "id": "string",
      "name": "string",
      "prefix": "string",
      "scopes": [
        "read"
      ],
      "lastUsedAt": "2026-08-21T09:00:00.000Z",
      "expiresAt": "2026-08-21T09:00:00.000Z",
      "revokedAt": "2026-08-21T09:00:00.000Z",
      "archivedAt": "2026-08-21T09:00:00.000Z",
      "createdAt": "2026-08-21T09:00:00.000Z"
    }
  ]
}

Errors: 401 No valid session. · 403 Presented an API key instead of a session — this route requires a signed-in session. · 429 Rate limited.

POST/api/v1/api-keys

Issue a new API key

Requires Signed-in session only — an API key cannot call this.

Request body
{
  "name": "string",
  "expiresAt": "2026-08-21T09:00:00.000Z"
}
Response 201 — The created key, plus its one-time token.
{
  "item": {
    "id": "string",
    "name": "string",
    "prefix": "string",
    "scopes": [
      "read"
    ],
    "lastUsedAt": "2026-08-21T09:00:00.000Z",
    "expiresAt": "2026-08-21T09:00:00.000Z",
    "revokedAt": "2026-08-21T09:00:00.000Z",
    "archivedAt": "2026-08-21T09:00:00.000Z",
    "createdAt": "2026-08-21T09:00:00.000Z"
  },
  "token": "string"
}

Errors: 400 Body failed validation. · 401 No valid session. · 403 Presented an API key instead of a session. · 429 Rate limited.

DELETE/api/v1/api-keys/{id}

Revoke an API key

Requires Signed-in session only — an API key cannot call this.

ParameterInTypeRequiredNotes
idpathstringyes
Response 200 — The revoked key.
{
  "item": {
    "id": "string",
    "name": "string",
    "prefix": "string",
    "scopes": [
      "read"
    ],
    "lastUsedAt": "2026-08-21T09:00:00.000Z",
    "expiresAt": "2026-08-21T09:00:00.000Z",
    "revokedAt": "2026-08-21T09:00:00.000Z",
    "archivedAt": "2026-08-21T09:00:00.000Z",
    "createdAt": "2026-08-21T09:00:00.000Z"
  }
}

Errors: 401 No valid session. · 403 Presented an API key instead of a session. · 404 No such key for this account. · 429 Rate limited.

Account

Preferences, sample content, and deleting the account.

POST/api/v1/account/avatar/upload-url

Ask for a URL that authorizes one picture upload

Requires write scope.

Request body
{
  "contentType": "image/webp",
  "contentLength": 1
}
Response 200 — Where to upload, and what to send with it.
{
  "item": {
    "uploadUrl": "string",
    "key": "string",
    "publicUrl": "string",
    "requiredHeaders": {},
    "expiresInSeconds": 1
  }
}

Errors: 400 Body failed validation. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited. · 503 No object store is configured here.

PUT/api/v1/account/avatar

Confirm an uploaded picture

Requires write scope.

Request body
{
  "key": "string"
}
Response 200 — The picture's public URL.
{
  "item": {
    "image": "string"
  }
}

Errors: 400 Body failed validation. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 422 That key was not issued to this account. · 429 Rate limited. · 503 No object store is configured here.

DELETE/api/v1/account/avatar

Remove the picture, and the object behind it

Requires write scope.

Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.

DELETE/api/v1/account

Hard-delete the caller's account and everything they own

Requires Signed-in session only — an API key cannot call this.

Request body
{
  "confirm": "DELETE"
}

Errors: 400 confirm must be exactly "DELETE". · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.

GET/api/v1/onboarding/sample-content

Check whether the workspace currently has sample content

Requires read scope.

Response 200 — Whether sample content is present.
{
  "item": {
    "present": false
  }
}

Errors: 401 No valid session. · 403 Cross-origin request without an API key, or sample content is unavailable in a shared workspace. · 429 Rate limited.

POST/api/v1/onboarding/sample-content

Seed optional onboarding tasks with immutable provenance

Requires write scope.

Response 201 — Sample content created.
{
  "item": {
    "present": false
  }
}

Errors: 401 No valid session. · 403 Cross-origin request without an API key, or sample content is unavailable in a shared workspace. · 429 Rate limited.

DELETE/api/v1/onboarding/sample-content

Remove onboarding tasks by immutable provenance

Requires write scope.

Errors: 401 No valid session. · 403 Cross-origin request without an API key, or sample content is unavailable in a shared workspace. · 429 Rate limited.

GET/api/v1/preferences

Get the caller's preferences

Requires read scope.

Response 200 — The caller's preferences.
{
  "item": {
    "theme": "string",
    "themeName": "string",
    "timezone": "string",
    "webWalkthroughVersion": 1,
    "soundEnabled": false,
    "locationSuggestionsEnabled": false,
    "autoTaggingEnabled": false,
    "taskView": "today",
    "taskSort": "default",
    "features": {
      "habits": false,
      "dashboard": false,
      "themes": false,
      "sharing": false,
      "notifications": false
    },
    "notifications": {
      "inApp": false,
      "email": false,
      "push": false,
      "quietHoursStart": 1,
      "quietHoursEnd": 1,
      "dailyEmailCap": 1,
      "habitReminderHour": 1
    }
  }
}

Errors: 401 No valid session. · 429 Rate limited.

PATCH/api/v1/preferences

Update the caller's preferences

Requires write scope.

Request body
{
  "theme": "light",
  "themeName": "default",
  "timezone": "string",
  "webWalkthroughVersion": 1,
  "soundEnabled": false,
  "locationSuggestionsEnabled": false,
  "autoTaggingEnabled": false,
  "taskView": "today",
  "taskSort": "default",
  "notifications": {
    "inApp": false,
    "email": false,
    "push": false,
    "quietHoursStart": 1,
    "quietHoursEnd": 1,
    "dailyEmailCap": 1,
    "habitReminderHour": 1
  },
  "features": {
    "habits": false,
    "dashboard": false,
    "themes": false,
    "sharing": false,
    "notifications": false
  }
}
Response 200 — The updated preferences.
{
  "item": {
    "theme": "string",
    "themeName": "string",
    "timezone": "string",
    "webWalkthroughVersion": 1,
    "soundEnabled": false,
    "locationSuggestionsEnabled": false,
    "autoTaggingEnabled": false,
    "taskView": "today",
    "taskSort": "default",
    "features": {
      "habits": false,
      "dashboard": false,
      "themes": false,
      "sharing": false,
      "notifications": false
    },
    "notifications": {
      "inApp": false,
      "email": false,
      "push": false,
      "quietHoursStart": 1,
      "quietHoursEnd": 1,
      "dailyEmailCap": 1,
      "habitReminderHour": 1
    }
  }
}

Errors: 400 Validation failed. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.

Errors

Every error response uses one envelope: { error: { code, message, requestId } }, plus details[] on a validation failure. Switch on code — it is the stable contract, while message is written for humans and may be reworded.

Transport and auth

CodeStatusMeaningWhere
forbidden_origin403A cross-origin request that did not authenticate with an API key. Cross-origin callers must send Authorization: Bearer <token>; a cookie-authenticated request must come from the app's own origin, which is what stops CSRF (ADR 0021).src/proxy.ts, all of /api/v1/*
unauthorized401No valid session and no usable API key.Any route behind requireSession/requireWorkspace
rate_limited429The per-identity rate limit for this route's profile was exceeded (read 300/min, write 120/min, export 5/hour, invite 10/min on the mailbox-addressed writes). The response also carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.Any route
validation_failed400The request body or a query parameter failed validation, including a malformed cursor, view, or date. details[] names every failing field.Any route parsing a body or query parameter
invalid_if_match400The If-Match header is not a quoted ISO updatedAt timestamp. Use the updatedAt value from the item being changed.Task, goal, and habit update and delete routes, plus task transitions
precondition_failed412The item changed after the caller read it. Fetch the current item and deliberately reapply the change instead of overwriting it.Task, goal, and habit update and delete routes, plus task transitions
invalid_idempotency_key400Idempotency-Key must contain between 1 and 200 visible ASCII characters.POST /api/v1/tasks and POST /api/v1/goals
idempotency_key_reused409The key already identifies a different route or request body. Generate a new key for a new logical operation.POST /api/v1/tasks and POST /api/v1/goals
idempotency_in_progress409Another request with the same key and body is still executing. Retry after the first request completes.POST /api/v1/tasks and POST /api/v1/goals
workspace_missing500The authenticated user has no workspace and one could not be created. Effectively unreachable, since provisioning self-heals (ADR 0015) — it means the create itself failed, so it is a data-integrity bug rather than a client error.src/modules/auth/session.ts
internal_error500An unexpected server failure. The response deliberately omits exception details; use requestId to locate the structured server log.Any /api/v1/* route

Tasks

CodeStatusMeaningWhere
task_not_found404The task does not exist, or belongs to a different workspace. The two are indistinguishable on purpose, so a cross-workspace probe cannot enumerate another workspace's task ids. This is also the answer for an unreachable parentId.GET/PATCH/DELETE /api/v1/tasks/{id} and the complete/reopen/restore/auto-tag sub-routes
task_due_date_in_past422dueAt is before now. A create-only rule: editing a task to a past due date is legitimate, and is how a task becomes overdue.POST /api/v1/tasks
task_invalid_priority422priority is not high, medium, low, or omitted.POST /api/v1/tasks, PATCH /api/v1/tasks/{id}
task_already_completed409The task already has a completedAt. A conflict rather than a missing row: the task exists and the caller can see it, only the transition is refused. Retrying will not help; re-reading the task explains why.POST /api/v1/tasks/{id}/complete
task_not_completed409Reopen was called on a task that was never completed.POST /api/v1/tasks/{id}/reopen
task_deleted409The task is soft-deleted — either the one being completed or reopened, or the one named as a parentId or as a goal's task. Restore it first.POST /api/v1/tasks, PATCH /api/v1/tasks/{id}, the complete/reopen sub-routes, PUT /api/v1/tasks/{id}/goal
subtasks_incomplete409A parent task still has live incomplete subtasks. Complete every child before completing the parent.POST /api/v1/tasks/{id}/complete
parent_task_completed409The parent is completed. Reopen it before adding an incomplete child or reopening one of its subtasks.POST /api/v1/tasks, PATCH /api/v1/tasks/{id}, POST /api/v1/tasks/{id}/reopen
subtask_depth_exceeded422Subtasks are exactly one level deep (ADR 0017). Either the named parent is itself a subtask, or the task being moved already has subtasks of its own.POST /api/v1/tasks, PATCH /api/v1/tasks/{id}
subtask_self_reference422parentId names the task being edited. Unreachable on create, where the task has no id yet.PATCH /api/v1/tasks/{id}
task_invalid_recurrence422The repeat rule was not understood. The vocabulary is every day, every N days/weeks/months/years, a named weekday (every monday), or every weekday — plus the shorthands daily, weekly, monthly, yearly and weekdays.POST /api/v1/tasks, PATCH /api/v1/tasks/{id}
task_recurrence_requires_due_date422A repeating task needs a due date to repeat from. Sent either when a rule is set on a task with no due date, or when the due date is cleared from a task that already has one.POST /api/v1/tasks, PATCH /api/v1/tasks/{id}
task_not_reorderable422The task is a subtask. Manual order is one flat sequence over top-level tasks (ADR 0029); a subtask is ordered under its parent, in the order it was added.PUT /api/v1/tasks/{id}/position
task_move_self_reference422afterId names the task being moved. Placing a task after itself has no meaning; send null to move it to the top.PUT /api/v1/tasks/{id}/position
task_assignee_not_member422The assignee is not a member of the task's workspace. The domain rule is checked under the workspace assignment lock and backed by a composite database foreign key, so concurrent member removal cannot leave an invalid stored assignee.POST /api/v1/tasks, PATCH /api/v1/tasks/{id}

Goals

CodeStatusMeaningWhere
goal_not_found404The goal does not exist, is soft-deleted, or belongs to another workspace — the same indistinguishability rule as tasks.GET/PATCH/DELETE /api/v1/goals/{id}, POST /api/v1/goals/{id}/restore, PUT /api/v1/tasks/{id}/goal
goal_task_is_subtask422The task named is a subtask. A goal counts the units the user planned at, so the parent is what gets attached (ADR 0018).PUT /api/v1/tasks/{id}/goal

Tags and journal

CodeStatusMeaningWhere
tag_not_found404The tag does not exist or belongs to another workspace — the same indistinguishability rule as tasks.DELETE /api/v1/tags/{id}
journal_not_found404The journal entry does not exist, is already in the requested deleted state, or belongs to another workspace.DELETE/PATCH /api/v1/journal/{date} and its auto-tag sub-route
journal_body_too_long422The entry body exceeds the domain's upper bound.PUT /api/v1/journal/today or PUT /api/v1/journal/{date}
journal_invalid_mood422mood is not one of the five defined levels, or omitted.PUT /api/v1/journal/today or PUT /api/v1/journal/{date}
journal_future_date422A journal entry cannot be written for a day after today.PUT /api/v1/journal/{date}
journal_not_in_shared_workspace403A shared workspace has no journal. Switch to the personal workspace to read or write journal content.GET /api/v1/journal, GET /api/v1/journal/today, GET/PUT /api/v1/journal/{date}, GET /api/v1/journal/streak, journal auto-tag, Insights, and journal MCP tools when the active workspace is shared

Habits

CodeStatusMeaningWhere
habit_not_found404No habit with that id in the caller's workspace. Indistinguishable from a habit belonging to someone else, deliberately./api/v1/habits/{id}, /api/v1/habits/{id}/restore, and /api/v1/habits/{id}/entries
habit_invalid_schedule422The schedule is not one the vocabulary covers. Accepted forms are every day, a list of named weekdays (every monday and thursday), and a weekly target (once a week or 3 times a week).POST /api/v1/habits, PATCH /api/v1/habits/{id}
habit_date_in_future422A day cannot be checked off before it has happened, in the account's own timezone. Backfilling a past day is allowed.PUT /api/v1/habits/{id}/entries

Workspaces

CodeStatusMeaningWhere
workspace_not_found404The workspace does not exist, was deleted, or the caller is not a member — the same indistinguishability rule as tasks, so one user cannot probe another's workspace ids.POST /api/v1/workspaces/invitations, POST /api/v1/workspaces/invitations/accept, GET /api/v1/workspaces/{id}/members, PATCH /api/v1/workspaces/{id}, DELETE /api/v1/workspaces/{id}, DELETE /api/v1/workspaces/{id}/members/{userId}, POST /api/v1/workspaces/{id}/transfer, POST /api/v1/workspaces/{id}/leave
workspace_not_shared422A personal workspace can never receive a member — that is what keeps a journal private by construction. Inviting into one, or accepting an invite to one a owner later converted back, is refused.POST /api/v1/workspaces/invitations, POST /api/v1/workspaces/invitations/accept
workspace_name_required422A shared workspace needs a non-empty name.POST /api/v1/workspaces
workspace_personal_immutable422A personal workspace cannot be deleted, transferred, or left — it is the permanent fallback. Rename is the only edit a personal workspace accepts.DELETE /api/v1/workspaces/{id}, POST /api/v1/workspaces/{id}/transfer, POST /api/v1/workspaces/{id}/leave, DELETE /api/v1/workspaces/{id}/members/{userId}
workspace_last_owner409Cannot remove or leave the last owner of a shared workspace — it would be orphaned. Transfer ownership to another member first.DELETE /api/v1/workspaces/{id}/members/{userId}, POST /api/v1/workspaces/{id}/leave
workspace_not_member404The named user is not a member of this workspace. The same indistinguishability rule as tasks: a foreign user id is the same answer as a missing one.DELETE /api/v1/workspaces/{id}/members/{userId}, POST /api/v1/workspaces/{id}/transfer
not_workspace_owner403Only the owner of a workspace can invite members, rename it, remove a member, transfer it, or delete it. A member may use the workspace but cannot change who else is in it.POST /api/v1/workspaces/invitations, PATCH /api/v1/workspaces/{id}, DELETE /api/v1/workspaces/{id}, DELETE /api/v1/workspaces/{id}/members/{userId}, POST /api/v1/workspaces/{id}/transfer
sample_content_not_in_shared_workspace403Optional onboarding samples are limited to a personal workspace so teaching tasks are never injected into a collaborative workspace.GET/POST/DELETE /api/v1/onboarding/sample-content
invitation_email_invalid422An invitation needs a valid email address to send the link to.POST /api/v1/workspaces/invitations
invitation_invalid422The invitation token is malformed, tampered, or its signature does not verify. The link cannot be redeemed.POST /api/v1/workspaces/invitations/accept
invitation_expired422The invitation link has passed its expiry. A new invitation can be sent; nothing else about the workspace is affected.POST /api/v1/workspaces/invitations/accept
invitation_email_mismatch403The signed-in user's email does not match the address the invitation was sent to. An invitation is person-to-person; a leaked link is not a way in for whoever holds it.POST /api/v1/workspaces/invitations/accept

Sharing

CodeStatusMeaningWhere
share_not_found404The grant does not exist, is revoked, has expired, or is not yours to read — the same indistinguishability rule as a task. A grant is read through a dedicated path that resolves it before the record, so an inactive or foreign grant is the same answer as a missing one.GET /api/v1/shared/{id}, DELETE /api/v1/shared/{id}
share_resource_not_found404The record being shared does not exist, is soft-deleted, or is not in the active workspace. A grant is written only after the row is confirmed reachable, so this means the body named a row the caller cannot see.POST /api/v1/shared
share_email_invalid422email is not an address. Note what is *not* here: there is no code for "that address has no TodoZen account", and there never will be. A grant is addressed to a mailbox, and whether a mailbox has an account behind it is exactly what this endpoint must not tell its caller (ADR 0042) — an address with no account gets a grant that waits for one.POST /api/v1/shared
share_invalid_resource_type422resourceType is not one of task, journal_entry, or habit. The closed set lives in src/domain/sharing/grant.ts.POST /api/v1/shared
share_grant_to_self422The grant names the creator's own address as its recipient. A member already sees everything in their workspace, so a grant to yourself is a no-op the API refuses rather than cluttering the list. This is the one refusal that names an account and leaks nothing: it can only ever be the caller's own address, which they already know.POST /api/v1/shared

Account

CodeStatusMeaningWhere
push_unavailable503This deployment has no VAPID keys, so it can store a subscription but could never deliver to it. Push configuration is all-or-nothing (ADR 0043) — set VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY and VAPID_SUBJECT together, or none of them. The app boots and every other channel works either way.POST /api/v1/push/subscriptions
storage_unavailable503This deployment has no object store configured, so pictures cannot be uploaded. Avatars are optional: everything else works, and the app falls back to an initial-letter avatar.POST /api/v1/account/avatar/upload-url, PUT /api/v1/account/avatar
avatar_key_invalid422The confirmed key is not one this account was issued. Keys are derived server-side from the caller's own id, so a key that merely looks plausible is refused rather than trusted.PUT /api/v1/account/avatar

API keys

CodeStatusMeaningWhere
api_key_invalid401The bearer token is malformed or matches no key. The same answer for both — a caller holding a wrong token learns only that it is wrong.Any route, when Authorization: Bearer is presented
api_key_revoked401The token is real but the key was revoked. Reported distinctly from api_key_invalid, because the caller demonstrably held a real key and naming the reason saves them debugging a token that worked yesterday.Any route, when Authorization: Bearer is presented
api_key_expired401The token is real, but its expiresAt has passed.Any route, when Authorization: Bearer is presented
api_key_insufficient_scope403The key authenticated but lacks the scope the route requires (read or write). 403 rather than 401, because re-authenticating would not help — a key with the right scope is needed.Any route, when Authorization: Bearer is presented
api_key_not_permitted403A key was presented to a route that requires a real signed-in session — minting a key, or deleting the account. A leaked key cannot mint its own replacement (ADR 0019).GET/POST /api/v1/api-keys, DELETE /api/v1/api-keys/{id}, DELETE /api/v1/account
api_key_no_scopes400A key was requested with an empty scope list — a key that could do nothing.POST /api/v1/api-keys
api_key_unknown_scope400A scope was requested that is not read or write.POST /api/v1/api-keys
api_key_not_found404No such key for this account — the same indistinguishability rule as tasks, so one account cannot probe another's key ids.DELETE /api/v1/api-keys/{id}

Import

CodeStatusMeaningWhere
import_too_large413The request exceeds the import byte limit and is rejected before its full body is buffered or parsed.POST /api/v1/import
import_version_missing422The file has no version field.POST /api/v1/import
import_version_unsupported422The file's version is not one this build reads. Rejected outright rather than read best-effort.POST /api/v1/import
import_malformed422The document failed shape validation. Nothing was written — the whole import is one transaction.POST /api/v1/import