2026-08-17 08:08:36 +02:00
|
|
|
# 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:
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
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.
|
|
|
|
|
|
2026-08-17 10:40:54 +02:00
|
|
|
## The machine-readable spec
|
|
|
|
|
|
|
|
|
|
[`openapi.yaml`](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.
|
|
|
|
|
|
2026-08-17 08:08:36 +02:00
|
|
|
## Errors
|
|
|
|
|
|
|
|
|
|
Every failure has the same shape:
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"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. |
|
2026-08-17 14:32:42 +02:00
|
|
|
| `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. |
|
2026-08-17 08:08:36 +02:00
|
|
|
| `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": …}`. |
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
|
|
|
|
|
-H "Authorization: Bearer vrd_..." \
|
|
|
|
|
-H "Content-Type: application/json" \
|
|
|
|
|
-d '{"text": "betal moms i morgen kl 10 p1 #Firma @regnskab"}'
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 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` | |
|
2026-08-17 14:59:51 +02:00
|
|
|
| `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. |
|
2026-08-17 08:08:36 +02:00
|
|
|
|
|
|
|
|
### 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. |
|
|
|
|
|
| `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. |
|
|
|
|
|
|
2026-08-17 10:39:23 +02:00
|
|
|
### 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.
|
|
|
|
|
|
2026-08-17 08:08:36 +02:00
|
|
|
### Everything else
|
|
|
|
|
|
|
|
|
|
| Method | Path | |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| `GET` | `/ws` | WebSocket. Live changes to projects you can see. |
|
|
|
|
|
| `GET` | `/notifications` | |
|
|
|
|
|
| `GET` `POST` | `/feed`, `/feed/rotate` | The [calendar feed](caldav.md) URL. |
|
|
|
|
|
| `GET` `POST` | `/mail-address`, `/mail-address/rotate` | The [mail-to-task](mail.md) address. |
|
|
|
|
|
| `POST` | `/mcp` | The [MCP](mcp.md) endpoint. |
|
|
|
|
|
| `GET` | `/healthz` | Unauthenticated. Reads the database rather than merely answering. |
|
|
|
|
|
|
|
|
|
|
## Live updates
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
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.
|