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.
Two credentials reach this API. The web app uses a session cookie. Everything else uses an API key, sent as Authorization: Bearer <token>.
They are never mixed. Once a bearer token is present it is the only credential consulted — a cookie riding along on the same request is ignored. That means a key's permissions are exactly its scopes, never widened by whoever happens to be signed in to the browser.
The authority is represented as read and write scopes. Every key issued by this build currently receives both, which is why Settings says every key can read and write your TodoZen data. The scope list remains explicit in the key and every request still checks the required one; do not describe today's keys as read-only or write-only.
Two things a key can never do, no matter its scopes: mint another API key, and delete the account. Both require a real signed-in session, so a leaked key cannot issue its own replacement before you revoke it.
Revoking is immediate and permanent, from Settings → API keys. Revoked keys stay in the list on purpose — after a leak, the first question is when the key stopped working, and a row that vanished cannot answer it.
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
const response = await fetch("https://todozen.apercallc.com/api/v1/tasks", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
// Note: no `credentials: "include"`. The key is the credential.
body: JSON.stringify({ title: "Read this later" }),
});
Conventions
Pagination is cursor-based, never offset. List endpoints return { items, nextCursor }. Pass nextCursor back as ?cursor= to get the following page; a null cursor means you have reached the end. The default page is 50 items and the maximum is 200.
Ordering is `(createdAt, id)`, which is what makes a cursor stable while rows are being added. Search is the one exception: results are ranked by relevance, so ?q= uses its own cursor shape. A cursor from a search cannot resume a list, or the other way round — mixing them is rejected with validation_failed rather than silently resuming from the wrong place.
Times are ISO 8601 in UTC, always, on the way in and on the way out. Day-based concepts are resolved in the account's own timezone: today means the day it is where you are, not where the server is. Journal entries are addressed by a plain YYYY-MM-DD day, not a timestamp.
**The Today view means due by the end of today *or* already overdue.** Overdue tasks are not a separate list; they sort to the top of this one.
Errors share one envelope — { error: { code, message, requestId } }, plus details[] for validation failures. Switch on code: it is the stable contract. message is written for humans and may be reworded. requestId matches the X-Request-Id response header and is safe to quote in a bug report.
Rate limits are per identity, per profile: 300 reads a minute, 120 writes a minute, 5 exports an hour. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a 429 also carries Retry-After. Read them rather than guessing.
Paging through every task
let cursor = null;
const all = [];
do {
const url = new URL("https://todozen.apercallc.com/api/v1/tasks");
url.searchParams.set("limit", "200");
if (cursor) url.searchParams.set("cursor", cursor);
const page = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
}).then((response) => response.json());
all.push(...page.items);
cursor = page.nextCursor;
} while (cursor);
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, 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. GET /api/v1/tasks?goal=<id> lists the tasks behind that number — top-level tasks with their subtasks nested, combinable with view — and GET /api/v1/tasks/count takes the same filter.
Journal entries are addressed by day. One entry per day, written in the account's timezone. Writing to a day that already has an entry replaces it.
Habits record check-offs, not completion. A habit definition belongs to a workspace; each member's dates and streak are personal to that member.
Sharing is an explicit grant, not a move. A task, journal entry or habit stays in its workspace while a recipient gets read access through /api/v1/shared. The recipient is an email address; the create response is identical whether that address already has an account.
Notifications are durable facts with per-channel deliveries. In-app, email and web-push preferences decide which outbox rows are created; provider failure never rolls back the action that caused the notification.
Deleting a task is a soft delete. It disappears from every view but can be restored, until a sweep removes it for good.
Connecting an MCP client
The API is also served as MCP tools, at `https://todozen.apercallc.com/api/v1/mcp`. Point any MCP client at that URL, give it an API key as a bearer token, and it can read and write tasks, goals, habits and journal entries by name rather than by composing HTTP requests.
The tools are the API's own vocabulary: list_tasks, create_task, update_task, complete_task, reopen_task, delete_task, list_goals, create_goal, read_journal, write_journal, list_habits, create_habit, check_off_habit and search. Each one calls the same service a REST route calls, so the same rules apply — the workspace filter, the scope on your key, the rate limits, and the domain rules that refuse a due date in the past.
Tool inputs use the REST request schemas: a dueAt is an ISO 8601 timestamp with an offset, text fields are trimmed and length-capped exactly as the API caps them, and a search query is at most 200 characters and 16 terms. Input that fails those checks comes back as a tool error naming the field, not as a failed request. list_tasks, list_goals, list_habits and search return nextCursor; pass it back as cursor for the next page, and stop when it is null.
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.
Give your assistant the URL and your API key, and it has everything it needs. The full file is the better choice when the assistant cannot browse: paste it in once and it can compose requests without guessing at endpoints.
The OpenAPI 3.2 document is also served, at /api/docs, for tools that consume it directly — client generators, HTTP clients, agent frameworks that read OpenAPI natively. An assistant that speaks MCP does not need any of this: point it at the endpoint above instead.
A word of caution worth passing on: an API key is a credential. Every key issued by this build has both read and write access, so giving one to an assistant lets it read and change TodoZen data. Use a separate key with a short expiry when practical, and revoke it when the work is done.
What to tell your assistant
Use the TodoZen API. The reference is at https://todozen.apercallc.com/llms-full.txt.
Authenticate every request with: Authorization: Bearer <my key>
Tasks
The four views, one task with its subtasks, and the state transitions.
GET/api/v1/tasks
List tasks, filtered by view, 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); today and upcoming are ordered by dueAt (soonest first), completed by completedAt (most recent first).
q
query
string
no
Full-text search over title and notes. Takes over the whole listing — view is 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. At most 200 characters and 16 terms; longer is validation_failed.
goal
query
string
no
A goal id. Narrows the view (all when no view is named) to the top-level tasks counting toward that goal, with their subtasks nested. Matched inside the caller's workspace: another workspace's goal id returns no tasks. Ignored when q is set; a value that is not a UUID is validation_failed.
Errors: 400 Malformed cursor, view, goal, 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.
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/count
Count the tasks in a view (for a badge)
Requires read scope.
Parameter
In
Type
Required
Notes
view
query
today, upcoming, all, completed
yes
—
assigned
query
me
no
me narrows the count to tasks assigned to the caller.
goal
query
string
no
A goal id; narrows the count to that goal's tasks, as on GET /api/v1/tasks?goal=.
Response 200 — The count.
{
"count": 1
}
Errors: 400 Missing or invalid view, or invalid assigned or goal. · 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: 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.
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.
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.
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}/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.
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.
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.
Errors: 400 Malformed cursor. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.
DELETE/api/v1/tasks/trash
Empty the trash
Requires write scope.
Response 200 — How many rows were removed.
{
"purged": 1
}
Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.
POST/api/v1/tasks/{id}/purge
Delete one trashed task forever
Requires write scope.
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.
{
"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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.
POST/api/v1/workspaces
Create a shared workspace
Requires write scope.
Request body
{
"name": "string"
}
Response 201 — The created workspace, with the caller as owner.
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.
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.
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 Signed-in session only — an API key cannot call this.
Request body
{
"workspaceId": "string",
"email": "string"
}
Response 201 — The invitation was created and emailed.
Errors: 400 Body failed validation. · 401 No valid session. · 403 The caller is not the workspace's owner. · 404 No such workspace, or the caller is not a member. · 422 The email is invalid, or the workspace is personal and cannot receive members. · 429 Rate limited.
POST/api/v1/workspaces/invitations/accept
Accept an invitation
Requires Signed-in session only — an API key cannot call this.
Request body
{
"token": "string"
}
Response 200 — The workspace the caller is now a member of.
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
{
"name": "string"
}
Response 200 — The renamed workspace, with the caller's role.
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
{
"toUserId": "string"
}
Response 200 — The workspace, with the caller's new role.
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.
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 Signed-in session only — an API key cannot call this.
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.
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.
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.
Errors: 400 The cursor is not a valid cursor. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.
PATCH/api/v1/notifications
Mark notifications read
Requires write scope.
Request body
{
"id": "string"
}
Response 200 — How many rows changed, and the new unread count.
{
"updated": 1,
"unreadCount": 1
}
Errors: 400 Body failed validation. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.
POST/api/v1/push/subscriptions
Register this browser for push notifications
Requires Signed-in session only — an API key cannot call this.
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 Signed-in session only — an API key cannot call this.
Request body
{
"endpoint": "string"
}
Errors: 400 The body is not valid. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.
POST/api/v1/push/native
Register this phone for push notifications
Requires Signed-in session only — an API key cannot call this.
Request body
{
"platform": "android",
"token": "string"
}
Errors: 400 The token 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/native
Forget this phone
Requires Signed-in session only — an API key cannot call this.
Request body
{
"token": "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.
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.
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
At most 200 characters and 16 terms; longer is validation_failed. An empty q matches nothing.
limit
query
integer
no
Defaults to 50.
cursor
query
string
no
Opaque page token from a previous response's nextCursor. Resumes after the last result in relevance order.
Response 200 — Ranked results across tasks, goals and habits; personal workspaces also receive journal results.
Create or rotate the private calendar subscription URL
Requires write scope.
Response 201 — A one-time private URL for an iCalendar subscription. Rotating invalidates the previous URL.
{
"item": {
"enabled": true,
"url": "string"
}
}
Errors: 401 No valid session. · 429 Rate limited.
DELETE/api/v1/calendar-feed
Disable the private calendar subscription
Requires write scope.
Errors: 401 No valid session. · 429 Rate limited.
Geocoding
Free-text place suggestions for a task's location field, proxied to Photon (an open-source geocoder). Off for an account with locationSuggestionsEnabled turned off — see ADR 0058.
GET/api/v1/geocode/suggest
Suggest places for a free-text location query
Requires read scope.
Parameter
In
Type
Required
Notes
q
query
string
yes
The location text typed so far.
Response 200 — Up to 5 place suggestions, possibly empty.
{
"items": [
{
"label": "string"
}
]
}
Errors: 401 No valid session. · 429 Rate limited.
Export and import
The whole workspace out as one document, and additively back in.
POST/api/v1/import
Import a previously exported workspace document
Requires write scope.
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.
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. · 409 Idempotency key conflict. · 413 Import document exceeds 10 MiB. · 422 Missing or unsupported format version, or the document failed validation. · 429 Rate limited.
Errors: 400 Body failed validation. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited. · 503 No object store is configured here.
PUT/api/v1/account/avatar
Confirm an uploaded picture
Requires write scope.
Request body
{
"key": "string"
}
Response 200 — The picture's public URL.
{
"item": {
"image": "string"
}
}
Errors: 400 Body failed validation. · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 422 That key was not issued to this account, or what was uploaded under it is missing, too large, or not a PNG, JPEG or WebP picture. · 429 Rate limited. · 503 No object store is configured here, or it could not be reached to check the upload.
DELETE/api/v1/account/avatar
Remove the picture, and the object behind it
Requires write scope.
Errors: 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.
DELETE/api/v1/account
Hard-delete the caller's account and everything they own
Requires Signed-in session only — an API key cannot call this.
Request body
{
"confirm": "DELETE"
}
Errors: 400 confirm must be exactly "DELETE". · 401 No valid session. · 403 Cross-origin request without an API key (docs/adr/0021-bearer-keys-may-cross-origins.md), or an API key lacking the scope this route needs. · 429 Rate limited.
GET/api/v1/onboarding/sample-content
Check whether the workspace currently has sample content
Requires read scope.
Response 200 — Whether sample content is present.
{
"item": {
"present": false
}
}
Errors: 401 No valid session. · 403 Cross-origin request without an API key, or sample content is unavailable in a shared workspace. · 429 Rate limited.
POST/api/v1/onboarding/sample-content
Seed optional onboarding tasks with immutable provenance
Requires write scope.
Response 201 — Sample content created.
{
"item": {
"present": false
}
}
Errors: 401 No valid session. · 403 Cross-origin request without an API key, or sample content is unavailable in a shared workspace. · 429 Rate limited.
DELETE/api/v1/onboarding/sample-content
Remove onboarding tasks by immutable provenance
Requires write scope.
Errors: 401 No valid session. · 403 Cross-origin request without an API key, or sample content is unavailable in a shared workspace. · 429 Rate limited.
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 <token>; a cookie-authenticated request must come from the app's own origin, which is what stops CSRF (ADR 0021).
src/proxy.ts, all of /api/v1/*
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, import 10/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 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. A stored rule may carry an anchor suffix (on day 31 for a monthly or yearly rule, at 02:30) recording the day or time a short month or a DST change moved the due date off.
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
Journal
Code
Status
Meaning
Where
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}
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, 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
account_owns_shared_workspaces
409
Deleting the account would also delete a shared workspace other people still use. Transfer ownership of each one, or delete it deliberately, then try again. Nothing was deleted.
DELETE /api/v1/account
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
session_not_fresh
403
Deleting the account and creating an API key need a sign-in from the last 24 hours, checked against the database rather than the session cookie cache. Sign in again (password or Google), then retry.
DELETE /api/v1/account, POST /api/v1/api-keys
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
avatar_upload_invalid
422
The object under the confirmed key is missing, larger than 2 MiB, or not the PNG, JPEG or WebP picture its key and stored type claim (checked from its first bytes). The object is deleted; request a new upload URL and upload again.
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, deleting the account, managing a workspace, or granting someone else access with an invitation or a share. A leaked key cannot mint its own replacement (ADR 0019) or leave a successor behind (ADR 0046).
GET/POST /api/v1/api-keys, DELETE /api/v1/api-keys/{id}, DELETE /api/v1/account, POST /api/v1/workspaces/invitations, POST /api/v1/shared, /api/v1/push/*, the workspace management routes
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.