Files
verdande/docs/api.md
T
Kristian WindandClaude Opus 5 78ec9b08a1 fix: say the true thing when Gmail, AI or mail is not set up
Two bugs with one shape: the interface confidently told you something
that was not so.

Connecting Gmail without a registered OAuth client said "Det er der
allerede." Thirteen different situations answered a plain `conflict`, and
the frontend renders that as "that already exists" — which for four of
them is not vague but wrong. An unconfigured Gmail is not a Gmail that is
already connected; a second factor that is off is not one that is on; the
Inbox is not a project that already exists somewhere else. Those four
have their own codes now, and their own sentences.

The mail-to-task address came out as todo+…@localhost. VERDANDE_SMTP_FROM
defaults to verdande@localhost and the domain was taken from it
unconditionally, so an instance with no mail server handed out an address
that looks real, can never receive anything, and gives no hint which of
the two it is. The default no longer wins over the public hostname, and
the response now carries whether a mail server exists at all — because
even with the right domain, nothing routes to it until one does.

errors.go has always claimed the code list is kept in one place "so the
frontend's mapping can be checked against it". Nothing checked it, and
the mapping was missing bad_request. Now a test reads both files and
fails on a code with no message.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 14:35:36 +02:00

6.7 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": "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 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.