# TodoZen API (v1) > The complete API reference, as one file. Base URL `https://todozen.apercallc.com/api/v1`. Generated from the OpenAPI document at https://todozen.apercallc.com/api/docs — if the two ever disagree, the OpenAPI document is the source and this is a bug. ## 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** ```bash TODOZEN_API_KEY="paste-key-from-settings" curl https://todozen.apercallc.com/api/v1/tasks \ -H "Authorization: Bearer ${TODOZEN_API_KEY}" ``` **Creating a task** ```bash 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 `. 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. | Scope | Grants | Needed by | | --- | --- | --- | | `read` | Reading anything in the workspace, including a full export. | `GET` endpoints and `GET /api/v1/export` | | `write` | Creating, 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** ```javascript 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** ```javascript 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** ```json { "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`](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** ```bash claude mcp add --transport http todozen https://todozen.apercallc.com/api/v1/mcp \ --header "Authorization: Bearer " ``` ## 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`](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`](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** ```bash Use the TodoZen API. The reference is at https://todozen.apercallc.com/llms-full.txt. Authenticate every request with: Authorization: Bearer ``` ## 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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `limit` | query | integer | no | Defaults to `50`. | | `cursor` | query | string | no | Opaque 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. | | `view` | query | `today`, `upcoming`, `all`, `completed` | no | One 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. | | `tag` | query | string | no | — | | `q` | query | string | no | Full-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. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `Idempotency-Key` | header | string | no | A unique key for this logical create. Successful results are retained for 24 hours and safely replayed. | Request body: ```json { "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. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | Response `200` — The task, with its subtasks. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | | `If-Match` | header | string | no | Quoted updatedAt value from the item being changed. A stale value returns 412 instead of overwriting a newer edit. | Request body: ```json { "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. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | | `If-Match` | header | string | no | Quoted updatedAt value from the item being changed. A stale value returns 412 instead of overwriting a newer edit. | Response `200` — The soft-deleted task. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | | `If-Match` | header | string | no | Quoted updatedAt value from the item being changed. A stale value returns 412 instead of overwriting a newer edit. | Response `200` — Completion result. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | Response `200` — The unchanged task with tagged: false. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | | `If-Match` | header | string | no | Quoted updatedAt value from the item being changed. A stale value returns 412 instead of overwriting a newer edit. | Response `200` — The reopened task. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | Request body: ```json { "afterId": "string" } ``` Response `200` — The moved task. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `limit` | query | integer | no | Defaults to `50`. | | `cursor` | query | string | no | — | 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. ```json { "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. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | Response `200` — How many rows were removed — the task plus any of its subtasks that were in the trash with it. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | Response `200` — The restored task. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | Request body: ```json { "goalId": "string" } ``` Response `200` — The task's new goal. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `limit` | query | integer | no | — | | `cursor` | query | string | no | — | | `archived` | query | string | no | Present at any value: include archived goals. | Response `200` — A page of goals. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `Idempotency-Key` | header | string | no | A unique key for this logical create. Successful results are retained for 24 hours and safely replayed. | Request body: ```json { "title": "string", "notes": "string", "targetDate": "2026-08-21T09:00:00.000Z" } ``` Response `201` — The created goal. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | Response `200` — The goal. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | | `If-Match` | header | string | no | Quoted updatedAt value from the item being changed. A stale value returns 412 instead of overwriting a newer edit. | Request body: ```json { "title": "string", "notes": "string", "targetDate": "2026-08-21T09:00:00.000Z", "archived": false } ``` Response `200` — The updated goal. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | | `If-Match` | header | string | no | Quoted updatedAt value from the item being changed. A stale value returns 412 instead of overwriting a newer edit. | Response `200` — The soft-deleted goal. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | Response `200` — The restored goal. ```json { "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. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `limit` | query | integer | no | Defaults to `50`. | | `cursor` | query | string | no | — | | `from` | query | string | no | YYYY-MM-DD, inclusive. | | `to` | query | string | no | YYYY-MM-DD, inclusive. | | `pinned` | query | string | no | Present at any value: only entries marked worth coming back to. | | `tag` | query | string | no | Only entries carrying this tag. Matched case-insensitively. | Response `200` — A page of journal entries. ```json { "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. ```json { "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: ```json { "body": "string", "mood": "rough", "tags": [ "string" ], "pinned": false } ``` Response `200` — The saved entry. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `date` | path | string | yes | YYYY-MM-DD | Response `200` — The entry for that date, or item: null if none exists. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `date` | path | string | yes | A real YYYY-MM-DD day, no later than today. | Request body: ```json { "body": "string", "mood": "rough", "tags": [ "string" ], "pinned": false } ``` Response `200` — The saved entry. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `date` | path | string | yes | YYYY-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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `date` | path | string | yes | YYYY-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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `date` | path | string | yes | YYYY-MM-DD | Response `200` — The unchanged entry with tagged: false. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `limit` | query | integer | no | Defaults to `50`. | | `cursor` | query | string | no | — | | `archived` | query | string | no | Present at any value: include archived habits. | Response `200` — A page of habits. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `Idempotency-Key` | header | string | no | A unique key for this logical create. Successful results are retained for 24 hours and safely replayed. | Request body: ```json { "name": "string", "notes": "string", "schedule": "string" } ``` Response `201` — The created habit. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | Response `200` — The habit. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | | `If-Match` | header | string | no | Quoted updatedAt value from the item being changed. A stale value returns 412 instead of overwriting a newer edit. | Request body: ```json { "name": "string", "notes": "string", "schedule": "string", "archived": false } ``` Response `200` — The updated habit. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | | `If-Match` | header | string | no | Quoted 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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | Response `200` — The restored habit. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | Request body: ```json { "date": "string", "done": false, "note": "string" } ``` Response `200` — The habit, with its updated streak. ```json { "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. ```json { "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: ```json { "name": "string" } ``` Response `201` — The created workspace, with the caller as owner. ```json { "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. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | Response `200` — The members of the workspace. ```json { "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: ```json { "workspaceId": "string", "email": "string" } ``` Response `201` — The invitation was created and emailed. ```json { "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: ```json { "token": "string" } ``` Response `200` — The workspace the caller is now a member of. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | Request body: ```json { "name": "string" } ``` Response `200` — The renamed workspace, with the caller's role. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | 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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | | `userId` | path | string | yes | The 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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | Request body: ```json { "toUserId": "string" } ``` Response `200` — The workspace, with the caller's new role. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | 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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `limit` | query | integer | no | Page size, default 50, max 200. | | `cursor` | query | string | no | Opaque cursor from a previous page's nextCursor. | Response `200` — Grants shared with the caller, oldest first. ```json { "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: ```json { "resourceType": "task", "resourceId": "string", "email": "string", "canComment": false, "expiresAt": "2026-08-21T09:00:00.000Z" } ``` Response `201` — The created or updated grant. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | Response `200` — The grant and the resolved record. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | Response `200` — The revoked grant. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `unread` | query | `true` | no | — | | `limit` | query | integer | no | — | | `cursor` | query | string | no | — | Response `200` — A page of notifications, with the unread count. ```json { "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: ```json { "id": "string" } ``` Response `200` — How many rows changed, and the new unread count. ```json { "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: ```json { "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: ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `days` | query | `7`, `30`, `90` | no | One of 7, 30, 90. Defaults to 30. | Response `200` — The window's numbers. ```json { "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. ```json { "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: ```json { "name": "string" } ``` Response `201` — The tag (created, or the existing one with that name). ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | 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. ## Search Across tasks, goals, habits and journal entries, ranked by relevance rather than recency. ### `GET /api/v1/search` Search tasks, goals, habits and personal journal entries, ranked Requires: `read` scope. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `q` | query | string | no | — | | `limit` | query | integer | no | Defaults to `50`. | Response `200` — Ranked results across tasks, goals and habits; personal workspaces also receive journal results. ```json { "items": [ { "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. · `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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `from` | query | string | yes | Start of the window, inclusive (YYYY-MM-DD). | | `to` | query | string | yes | End of the window, inclusive (YYYY-MM-DD). | Response `200` — Dated tasks and goals in the window. ```json { "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. ```json { "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. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `q` | query | string | yes | The location text typed so far. | Response `200` — Up to 5 place suggestions, possibly empty. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `mode` | query | `skip-existing`, `merge` | no | Defaults to `skip-existing`. | Request body: ```json {} ``` Response `200` — What landed. ```json { "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. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `archived` | query | string | no | Present at any value: include archived keys. | Response `200` — Every key this account has issued. ```json { "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: ```json { "name": "string", "expiresAt": "2026-08-21T09:00:00.000Z" } ``` Response `201` — The created key, plus its one-time token. ```json { "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. | Parameter | In | Type | Required | Notes | | --- | --- | --- | --- | --- | | `id` | path | string | yes | — | Response `200` — The revoked key. ```json { "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: ```json { "contentType": "image/webp", "contentLength": 1 } ``` Response `200` — Where to upload, and what to send with it. ```json { "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: ```json { "key": "string" } ``` Response `200` — The picture's public URL. ```json { "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: ```json { "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. ```json { "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. ```json { "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. ```json { "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: ```json { "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. ```json { "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 | Code | Status | Meaning | Where | | --- | --- | --- | --- | | `forbidden_origin` | 403 | A cross-origin request that did not authenticate with an API key. Cross-origin callers must send `Authorization: Bearer `; 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/*` | | `unauthorized` | 401 | No valid session and no usable API key. | Any route behind `requireSession`/`requireWorkspace` | | `rate_limited` | 429 | The 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_failed` | 400 | The 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_match` | 400 | The 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_failed` | 412 | The 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_key` | 400 | Idempotency-Key must contain between 1 and 200 visible ASCII characters. | POST /api/v1/tasks and POST /api/v1/goals | | `idempotency_key_reused` | 409 | The 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_progress` | 409 | Another 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_missing` | 500 | The 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_error` | 500 | An unexpected server failure. The response deliberately omits exception details; use `requestId` to locate the structured server log. | Any `/api/v1/*` route | ### Tasks | Code | Status | Meaning | Where | | --- | --- | --- | --- | | `task_not_found` | 404 | The 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_past` | 422 | `dueAt` 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_priority` | 422 | `priority` is not `high`, `medium`, `low`, or omitted. | `POST /api/v1/tasks`, `PATCH /api/v1/tasks/{id}` | | `task_already_completed` | 409 | The 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_completed` | 409 | Reopen was called on a task that was never completed. | `POST /api/v1/tasks/{id}/reopen` | | `task_deleted` | 409 | The 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_incomplete` | 409 | A parent task still has live incomplete subtasks. Complete every child before completing the parent. | `POST /api/v1/tasks/{id}/complete` | | `parent_task_completed` | 409 | The 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_exceeded` | 422 | Subtasks 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_reference` | 422 | `parentId` names the task being edited. Unreachable on create, where the task has no id yet. | `PATCH /api/v1/tasks/{id}` | | `task_invalid_recurrence` | 422 | The 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_date` | 422 | A 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_reorderable` | 422 | The 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_reference` | 422 | `afterId` 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_member` | 422 | The 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 | Code | Status | Meaning | Where | | --- | --- | --- | --- | | `goal_not_found` | 404 | The 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_subtask` | 422 | The 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 | Code | Status | Meaning | Where | | --- | --- | --- | --- | | `tag_not_found` | 404 | The tag does not exist or belongs to another workspace — the same indistinguishability rule as tasks. | `DELETE /api/v1/tags/{id}` | | `journal_not_found` | 404 | The 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_long` | 422 | The entry body exceeds the domain's upper bound. | `PUT /api/v1/journal/today` or `PUT /api/v1/journal/{date}` | | `journal_invalid_mood` | 422 | `mood` is not one of the five defined levels, or omitted. | `PUT /api/v1/journal/today` or `PUT /api/v1/journal/{date}` | | `journal_future_date` | 422 | A journal entry cannot be written for a day after today. | `PUT /api/v1/journal/{date}` | | `journal_not_in_shared_workspace` | 403 | A 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 | Code | Status | Meaning | Where | | --- | --- | --- | --- | | `habit_not_found` | 404 | No 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_schedule` | 422 | The 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_future` | 422 | A 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 | Code | Status | Meaning | Where | | --- | --- | --- | --- | | `workspace_not_found` | 404 | The 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_shared` | 422 | A 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_required` | 422 | A shared workspace needs a non-empty name. | `POST /api/v1/workspaces` | | `workspace_personal_immutable` | 422 | A 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_owner` | 409 | Cannot 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_member` | 404 | The 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_owner` | 403 | Only 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_workspace` | 403 | Optional 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_invalid` | 422 | An invitation needs a valid email address to send the link to. | `POST /api/v1/workspaces/invitations` | | `invitation_invalid` | 422 | The invitation token is malformed, tampered, or its signature does not verify. The link cannot be redeemed. | `POST /api/v1/workspaces/invitations/accept` | | `invitation_expired` | 422 | The 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_mismatch` | 403 | The 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 | Code | Status | Meaning | Where | | --- | --- | --- | --- | | `share_not_found` | 404 | The 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_found` | 404 | The 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_invalid` | 422 | `email` 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_type` | 422 | `resourceType` 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_self` | 422 | The 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 | Code | Status | Meaning | Where | | --- | --- | --- | --- | | `push_unavailable` | 503 | This 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_unavailable` | 503 | This 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_invalid` | 422 | The 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 | Code | Status | Meaning | Where | | --- | --- | --- | --- | | `api_key_invalid` | 401 | The 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_revoked` | 401 | The 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_expired` | 401 | The token is real, but its `expiresAt` has passed. | Any route, when `Authorization: Bearer` is presented | | `api_key_insufficient_scope` | 403 | The 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_permitted` | 403 | A 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_scopes` | 400 | A key was requested with an empty scope list — a key that could do nothing. | `POST /api/v1/api-keys` | | `api_key_unknown_scope` | 400 | A scope was requested that is not `read` or `write`. | `POST /api/v1/api-keys` | | `api_key_not_found` | 404 | No 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 | Code | Status | Meaning | Where | | --- | --- | --- | --- | | `import_too_large` | 413 | The request exceeds the import byte limit and is rejected before its full body is buffered or parsed. | `POST /api/v1/import` | | `import_version_missing` | 422 | The file has no `version` field. | `POST /api/v1/import` | | `import_version_unsupported` | 422 | The file's `version` is not one this build reads. Rejected outright rather than read best-effort. | `POST /api/v1/import` | | `import_malformed` | 422 | The document failed shape validation. Nothing was written — the whole import is one transaction. | `POST /api/v1/import` |