Kommendes liste has always been the next seven days and nothing else. Seven days is the right walk to take in the morning, and it is still what the view opens on — but "when does this month get busy" is a different question, and the month grid answers it with shape rather than in order. So the list now takes a horizon: 7, 14 or 30 days, chosen with a switcher beside the view buttons. Only the list gets one. A week grid is a week and a month grid is a month; there is no fortnight for them to show, so the switcher is hidden in those modes. The choice lives in localStorage next to the mode, under its own key. It is the same kind of choice as list-or-grid — how you want to look, not a fact about the work — and it should survive a reload without following you to a phone where thirty day headings is a very long scroll. Once the strip is longer than a week the day headings carry the week number. Thirty days repeat every weekday four times, and "mandag 6. okt" alone does not say which mandag. Server side the endpoint already took `?days=` and capped it at 31, so the window needed nothing — but its row limit was a constant 1000 written for a week. A month asked for with a week's ceiling would truncate the last days silently, and a day cut off the end of the answer looks exactly like a day with nothing due, so the limit now follows the window. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SA1TDmgQZw3AufJqkbwezx
6.9 KiB
API
REST under /api/v1, JSON in and out. Everything the web interface does goes
through it, so anything you can do there you can script.
Authenticating
Two ways, accepted everywhere.
A session cookie, which the web interface uses. httpOnly, so nothing but the
browser handles it.
A personal API token, which is what scripts use:
curl -H "Authorization: Bearer vrd_..." https://todo.example.dk/api/v1/tasks
Make one under Settings → API tokens. It carries your permissions exactly — there is no separate service identity.
The machine-readable spec
openapi.yaml describes the same API as an OpenAPI 3.1 document —
for generating a client, for importing into Bruno or Insomnia, or for reading in a
tool that renders it.
It is written by hand, and a test walks the router on every build and fails if an endpoint is routed but not described, or described but no longer routed. So it is as current as this page is, which is to say: as current as CI is green.
Errors
Every failure has the same shape:
{
"code": "validation_failed",
"error": "one or more fields are not valid",
"fields": { "due_date": "must be a date like 2026-03-15" }
}
code is stable and meant to be matched on. error is English prose for a log —
what a person reads is decided by the client, which knows what language they are in.
| Code | Status | Means |
|---|---|---|
bad_request |
400 | The body could not be read. |
unauthorized |
401 | Not signed in, or the token is wrong. |
totp_required |
401 | The login still needs its verification code. |
forbidden |
403 | Cross-site request, or admin needed. |
not_found |
404 | Does not exist, or you cannot see it. |
conflict |
409 | Already exists, or the state does not allow it. |
gmail_not_configured |
409 | No Google OAuth client is registered on this server. |
ai_not_configured |
409 | No AI provider has been set up. |
totp_not_enabled |
409 | Two-factor is off, and the request needs it on. |
inbox_protected |
409 | The Inbox cannot be deleted. |
validation_failed |
422 | Check fields. |
rate_limited |
429 | Too many attempts. |
internal_error |
500 | Our fault. |
!!! note "404 rather than 403" Anything you may not see answers 404. A 403 would confirm the thing exists, which is what somebody trying ids is hoping to learn.
The endpoints
Tasks
| Method | Path | |
|---|---|---|
GET |
/tasks |
List. Filter with project_id, label_id, priority, due_before, due_from, no_date, assignee_id=me, q, completed=include|only. |
POST |
/tasks |
Create. |
POST |
/tasks/quick-add |
Create from one line of natural language. |
GET |
/tasks/quick-add/preview?text= |
Parse without saving. |
GET |
/tasks/{id} |
One task. |
PATCH |
/tasks/{id} |
Change it. Only the fields you send. |
DELETE |
/tasks/{id} |
To the trash, recoverable for 30 days. |
POST |
/tasks/{id}/complete |
Tick off. A repeating task advances instead. |
POST |
/tasks/{id}/reopen |
Un-tick. |
POST |
/tasks/{id}/move |
Reorder: {"after_id": …, "before_id": …}. |
curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
-H "Authorization: Bearer vrd_..." \
-H "Content-Type: application/json" \
-d '{"text": "file the VAT return tomorrow at 10am p1 #Accounts @tax"}'
Projects and sections
| Method | Path | |
|---|---|---|
GET POST |
/projects |
List, create. |
GET PATCH DELETE |
/projects/{id} |
Read, change, trash. Owner only for the last two. |
GET POST |
/projects/{id}/sections |
|
PATCH DELETE |
/sections/{id} |
|
GET |
/projects/{id}/members |
|
POST |
/projects/{id}/invites |
Share it. {"email": …, "role": "editor"|"viewer"} |
DELETE |
/projects/{id}/members/{userID} |
|
GET |
/projects/{id}/activity |
|
GET |
/trash/projects |
What you have deleted and can still bring back, with how long is left. |
POST |
/trash/projects/{id}/restore |
Brings it back with the tasks that went with it. |
Views, filters and labels
| Method | Path | |
|---|---|---|
GET |
/today |
Overdue and due today, in your timezone. |
GET |
/upcoming?days=7 |
One entry per day, empty days included. Up to 31; over that is capped. |
GET |
/search?q= |
Across everything you can see. |
GET POST |
/filters |
|
GET |
/filters/{id}/tasks |
Run one. |
GET |
/filters/preview?query= |
Run an expression without saving it. |
GET POST |
/labels |
Comments and attachments
| Method | Path | |
|---|---|---|
GET POST |
/tasks/{id}/comments |
|
PATCH DELETE |
/comments/{id} |
Author only for editing. |
POST |
/tasks/{id}/attachments |
multipart/form-data, field file, 25 MB. |
GET DELETE |
/attachments/{id} |
Import and export
| Method | Path | |
|---|---|---|
POST |
/import/todoist |
A Todoist CSV, multipart/form-data. |
POST |
/import/csv |
Mapped rows as JSON. |
GET |
/export/account |
Everything, as JSON. |
GET |
/export/projects/{id}.csv |
Todoist-compatible. |
GET |
/export/projects/{id}.ics |
A calendar file. |
API tokens
| Method | Path | |
|---|---|---|
GET POST |
/tokens |
List, or mint one. name is required; expires_in_days is optional, and 0 means it never expires. |
DELETE |
/tokens/{id} |
Revoked immediately. |
The plaintext token comes back from POST and never again — only its hash is
stored. The listing shows a prefix, which is enough to tell two of your own
tokens apart.
!!! warning "These three need a signed-in session"
A bearer token is refused here with 403, even though it is accepted
everywhere else. Otherwise a leaked token would be permanent: whoever had it
could issue a second one, and revoking the first would change nothing.
Everything else
| Method | Path | |
|---|---|---|
GET |
/ws |
WebSocket. Live changes to projects you can see. |
GET |
/notifications |
|
GET POST |
/feed, /feed/rotate |
The calendar feed URL. |
GET POST |
/mail-address, /mail-address/rotate |
The mail-to-task address. |
POST |
/mcp |
The MCP endpoint. |
GET |
/healthz |
Unauthenticated. Reads the database rather than merely answering. |
Live updates
const socket = new WebSocket('wss://todo.example.dk/api/v1/ws');
socket.onmessage = (event) => {
const { type, project_id, payload } = JSON.parse(event.data);
// task.created, task.updated, task.completed, task.deleted,
// task.moved, comment.created, notification, reminder
};
One-way by design: nothing a client sends over the socket changes anything. Writes go through the REST API, where the permission checks are.
Rate limits
Only where guessing matters: ten login attempts per address per fifteen minutes, five password-reset requests per hour. The rest of the API is not limited — it is your server.