Et projekt kunne kun deles ved at stave en e-mailadresse. Det er den forkerte
vej at spørge om en kollega, man kender navnet på og sidder i samme rum som —
og beslutningen om, hvorvidt en instans' konti må vises for hinanden, var
allerede taget: `UsersForSharing` har gjort det for noter hele tiden, med
begrundelsen skrevet ved siden af. Der er ingen åben oprettelse, så konti er en
lukket, inviteret mængde, og det er en adressebog frem for en udlevering.
Projekterne bruger nu den samme.
`GET /members` svarer med tre lister frem for én, så panelet åbner færdigt i ét
kald — samme form som notens delingspanel. `POST /invites` tager `user_id` ved
siden af `email`, og de to veje mødes ét sted: en adresse, der viser sig at høre
til en konto, ER den konto, og skal ikke gennem en oprettelsesside, den ikke kan
komme igennem.
De ventende invitationer kom med, fordi den nye liste skabte behovet for dem:
den, der er inviteret og ikke kommet, har ingen konto og står derfor hverken
blandt medlemmerne eller blandt dem, man kan vælge. Før betød det, at man ikke
kunne se sin egen invitation; nu ville det betyde, at man leder efter en person,
der mangler i listen, og inviterer igen. Det andet link virker lige så godt som
det første. De kan trækkes tilbage, for ellers er den ventende liste kun til at
se på.
Tre ting, der kostede en kørsel hver:
`OpenAPI is valid` fangede den nye DELETE-rute, før jeg selv gjorde.
To felter hed "Rolle" på den samme side. Det er ikke kun en prøve, der brokker
sig — en skærmlæser siger "Rolle" to gange uden at kunne skelne. De har nu hver
sin tilstand og hvert sit navn, og den gamle prøves `getByLabel('Rolle')` er
blevet `exact`, fordi den matchede som understreng.
Og e2e-prøven lånte først sin anden person fra prøven ovenfor. Det så jeg kun,
fordi jeg kørte den alene: basen tømmes for hver kørsel, så en prøve, der kun
består, når hele filen kører i rækkefølge, består ikke af sine egne grunde. Den
laver nu selv sin — og viser dermed skiftet: den samme person er først en
ventende adresse og bagefter et navn, der kan vælges.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
184 lines
7.3 KiB
Markdown
184 lines
7.3 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.
|
|
|
|
## 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.
|
|
|
|
## 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. |
|
|
| `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": …}`. |
|
|
|
|
```bash
|
|
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` | Who is on it, who can be added (`candidates`), and who has been invited and not arrived (`pending`). |
|
|
| `POST` | `/projects/{id}/invites` | Share it. `{"user_id": …}` for somebody who already has an account here, `{"email": …}` for anybody else — plus `"role": "editor"\|"viewer"`. An address that turns out to belong to an account is that account. |
|
|
| `DELETE` | `/projects/{id}/invites/{inviteID}` | Withdraw an invitation before it is used. |
|
|
| `PATCH` `DELETE` | `/projects/{id}/members/{userID}` | Change somebody's role, or take them off. |
|
|
| `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](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.
|