Files
verdande/docs/api.md
T
Kristian WindandClaude Opus 5 68d82a1cb6 feat(api): personal API tokens, and let people change their own profile
docs/api.md has told people to make a token under Settings since it was
written. The store has held CreateAPIToken, ListAPITokens and
DeleteAPIToken since the first migration. There were no routes: nothing
between the two, and no way to make one.

GET, POST and DELETE under /tokens, all behind a new requireSession
middleware. A bearer token is refused there with 403 even though it is
accepted everywhere else — otherwise a leaked token is permanent, because
whoever has it issues a second one and revoking the first changes
nothing. The plaintext comes back from POST and never again; only the
hash is stored, so the interface has to say so at that moment rather than
offering a reveal that cannot work.

PATCH /auth/me alongside it. Name, timezone and language could not be
changed anywhere — not through the API and not through the interface —
and an account created from an invite inherits a default timezone. That
is not cosmetic: every date in the app is resolved in it, so somebody in
another country had every due date land an hour out with no way to say
so. The zone is validated against the running system's database rather
than a list kept here, which is the same database the dates are resolved
with. The email address stays read-only: it identifies the account and is
what invites were sent to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 10:56:33 +02:00

167 lines
6.0 KiB
Markdown

# 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.
## 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. |
| `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` | |
### 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. |
### 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](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.