Files
verdande/docs/openapi.yaml
T

2894 lines
96 KiB
YAML
Raw Normal View History

openapi: 3.1.0
# The machine-readable half of docs/api.md.
#
# Hand-written, and kept honest by a test: internal/httpapi/openapi_test.go walks
# the router and fails if a route is missing here or described here but not
# routed. Generating it from annotations was the alternative, and it would have
# meant every handler carrying a second copy of itself in a comment.
info:
title: verdande
version: "0.1.0"
summary: Self-hosted tasks and projects, shared with other people.
description: |
REST under `/api/v1`, JSON in and out. Everything the web interface does goes
through this API, so anything you can do there you can script.
Authenticate with a session cookie or with a personal API token. Both are
accepted on every endpoint except `/tokens`, which needs a session — a token
that can mint another token is a leak that cannot be revoked.
Anything you may not see answers `404`, never `403`. A `403` would confirm
the thing exists, which is what somebody trying ids is hoping to learn.
license:
name: MIT
identifier: MIT
servers:
- url: https://todo.example.dk
description: Your own instance.
security:
- session: []
- bearer: []
tags:
- name: auth
description: Signing in, the second factor, and the account itself.
- name: tasks
description: Tasks, sub-tasks, comments, attachments and reminders.
- name: projects
description: Projects, sections, members and activity.
- name: views
description: Today, upcoming, search, labels and saved filters.
- name: integrations
description: Calendar feed, mail-to-task, Gmail, AI and Web Push.
- name: data
description: Import, export and project templates.
paths:
# --- auth -----------------------------------------------------------------
/api/v1/auth/setup:
get:
tags: [auth]
summary: Whether this instance still needs its first account.
security: []
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
needs_setup: { type: boolean }
post:
tags: [auth]
summary: Create the first account, which becomes the administrator.
description: Refused once any account exists. Rate limited.
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email, name, password]
properties:
email: { type: string, format: email }
name: { type: string }
password: { type: string, minLength: 10 }
timezone: { type: string, example: Europe/Copenhagen }
locale: { type: string, enum: [da, en] }
responses:
"201":
description: Created, and signed in.
content:
application/json:
schema:
type: object
properties:
user: { $ref: "#/components/schemas/User" }
"409": { $ref: "#/components/responses/Conflict" }
"422": { $ref: "#/components/responses/Validation" }
"429": { $ref: "#/components/responses/RateLimited" }
/api/v1/auth/login:
post:
tags: [auth]
summary: Sign in.
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email, password]
properties:
email: { type: string, format: email }
password: { type: string }
responses:
"200":
description: |
Signed in, or waiting for a code. When `totp_required` is true the
session exists but reaches nothing until `/auth/login/totp`.
content:
application/json:
schema:
type: object
properties:
user: { $ref: "#/components/schemas/User" }
totp_required: { type: boolean }
"401": { $ref: "#/components/responses/Unauthorized" }
"429": { $ref: "#/components/responses/RateLimited" }
/api/v1/auth/login/totp:
post:
tags: [auth]
summary: Complete a login with the code from an authenticator.
description: Accepts a recovery code in place of a generated one.
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [code]
properties:
code: { type: string }
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
user: { $ref: "#/components/schemas/User" }
"401": { $ref: "#/components/responses/Unauthorized" }
/api/v1/auth/signup:
post:
tags: [auth]
summary: Create an account from an invite.
description: |
There is no open registration. The address comes from the invite rather
than from the request, so an invite cannot be redirected to somebody else.
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [token, name, password]
properties:
token: { type: string }
name: { type: string }
password: { type: string, minLength: 10 }
timezone: { type: string }
locale: { type: string, enum: [da, en] }
responses:
"201":
description: Created, and signed in.
content:
application/json:
schema:
type: object
properties:
user: { $ref: "#/components/schemas/User" }
"400": { $ref: "#/components/responses/BadRequest" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/auth/password/forgot:
post:
tags: [auth]
summary: Ask for a reset link.
description: |
Always answers 204, whether or not the address has an account. Answering
differently would turn this into a way to test which addresses exist.
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
email: { type: string, format: email }
responses:
"204": { description: Accepted. }
"429": { $ref: "#/components/responses/RateLimited" }
/api/v1/auth/password/reset:
post:
tags: [auth]
summary: Set a new password with a reset token.
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [token, password]
properties:
token: { type: string }
password: { type: string, minLength: 10 }
responses:
"204": { description: Changed. Every other session is ended. }
"400": { $ref: "#/components/responses/BadRequest" }
"422": { $ref: "#/components/responses/Validation" }
"429": { $ref: "#/components/responses/RateLimited" }
/api/v1/auth/me:
get:
tags: [auth]
summary: Who the caller is.
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/User" }
"401": { $ref: "#/components/responses/Unauthorized" }
patch:
tags: [auth]
summary: Change your own name, timezone or language.
description: |
The email address is not among them: it identifies the account and is what
invites were sent to. The timezone is not cosmetic — every date is
resolved in it, so "tomorrow at 9" means a different instant afterwards.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name: { type: string, maxLength: 80 }
timezone: { type: string, example: Europe/Copenhagen }
locale: { type: string, enum: [da, en] }
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/User" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/auth/logout:
post:
tags: [auth]
summary: End this session.
responses:
"204": { description: Signed out. }
/api/v1/auth/password/change:
post:
tags: [auth]
summary: Change your password.
description: Ends every other session, which is most of the reason to do it.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [current_password, new_password]
properties:
current_password: { type: string }
new_password: { type: string, minLength: 10 }
responses:
"204": { description: Changed. }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/auth/totp/setup:
post:
tags: [auth]
summary: Begin two-factor enrolment.
description: |
Stores the secret but does not switch anything on. `/auth/totp/confirm`
does that, once a code has proved the authenticator really has it.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
secret: { type: string }
uri: { type: string, description: otpauth:// URI for a QR code. }
"409": { $ref: "#/components/responses/Conflict" }
/api/v1/auth/totp/confirm:
post:
tags: [auth]
summary: Finish enrolment and switch two-factor on.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [code]
properties:
code: { type: string }
responses:
"200":
description: |
On. The recovery codes are returned once and never again — only their
hashes are kept.
content:
application/json:
schema: { $ref: "#/components/schemas/RecoveryCodes" }
"400": { $ref: "#/components/responses/BadRequest" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/auth/totp/disable:
post:
tags: [auth]
summary: Switch two-factor off.
description: |
Needs the password again: switching off a second factor is exactly what
somebody with a borrowed session would do first.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [password]
properties:
password: { type: string }
responses:
"204": { description: Off. }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/auth/sessions:
get:
tags: [auth]
summary: Where you are signed in.
description: |
Most recently used first. `id` is the stored hash of the cookie and never
the cookie itself — it cannot be pasted into a browser, and it is what the
delete below takes.
Session only: an API token cannot list the devices of the person it was
stolen from.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
sessions:
type: array
items: { $ref: "#/components/schemas/Session" }
"403": { $ref: "#/components/responses/Forbidden" }
/api/v1/auth/sessions/{sessionID}:
parameters:
- { name: sessionID, in: path, required: true, schema: { type: string } }
delete:
tags: [auth]
summary: Sign one device out.
description: |
Ending the current session is allowed and is simply logging out —
somebody clearing every device wants this one gone too.
responses:
"204": { description: Ended. }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/auth/recovery-codes:
get:
tags: [auth]
summary: How many recovery codes are left.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
remaining: { type: integer }
post:
tags: [auth]
summary: Issue a fresh set and retire the old one.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [password]
properties:
password: { type: string }
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/RecoveryCodes" }
"409": { $ref: "#/components/responses/Conflict" }
"422": { $ref: "#/components/responses/Validation" }
# --- tasks ----------------------------------------------------------------
/api/v1/tasks:
get:
tags: [tasks]
summary: List tasks.
parameters:
- { name: project_id, in: query, schema: { type: string } }
- { name: section_id, in: query, schema: { type: string } }
- { name: parent_id, in: query, schema: { type: string }, description: Sub-tasks of one task. }
- { name: label_id, in: query, schema: { type: string } }
- { name: priority, in: query, schema: { type: integer, minimum: 1, maximum: 4 } }
- { name: assignee_id, in: query, schema: { type: string }, description: "`me` resolves to the caller." }
- { name: due_before, in: query, schema: { type: string, format: date } }
- { name: due_from, in: query, schema: { type: string, format: date } }
- { name: no_date, in: query, schema: { type: boolean } }
- { name: q, in: query, schema: { type: string }, description: "Full-text search, diacritic-insensitive for Danish." }
- name: completed
in: query
schema: { type: string, enum: [include, only] }
description: Omitted, only open tasks are returned.
- { name: limit, in: query, schema: { type: integer } }
responses:
"200": { $ref: "#/components/responses/TaskList" }
post:
tags: [tasks]
summary: Create a task.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/TaskInput" }
responses:
"201": { $ref: "#/components/responses/Task" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/tasks/{taskID}:
parameters:
- { name: taskID, in: path, required: true, schema: { type: string } }
get:
tags: [tasks]
summary: One task.
responses:
"200": { $ref: "#/components/responses/Task" }
"404": { $ref: "#/components/responses/NotFound" }
patch:
tags: [tasks]
summary: Change a task.
description: Only the fields present are changed.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/TaskInput" }
responses:
"200": { $ref: "#/components/responses/Task" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
delete:
tags: [tasks]
summary: Delete a task.
description: Soft-deleted, and purged from the trash after 30 days.
responses:
"204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/tasks/{taskID}/complete:
parameters:
- { name: taskID, in: path, required: true, schema: { type: string } }
post:
tags: [tasks]
summary: Tick a task off.
description: |
A repeating task is not closed: it moves forward in the same row, keeping
its id, so sub-tasks and comments stay attached. `recurred` and `next_due`
say when that happened.
responses:
"200":
description: OK
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/Task"
- type: object
properties:
recurred: { type: boolean }
next_due: { type: string, format: date }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/tasks/{taskID}/reopen:
parameters:
- { name: taskID, in: path, required: true, schema: { type: string } }
post:
tags: [tasks]
summary: Un-tick a task.
responses:
"200": { $ref: "#/components/responses/Task" }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/tasks/{taskID}/move:
parameters:
- { name: taskID, in: path, required: true, schema: { type: string } }
post:
tags: [tasks]
summary: Move a task, and place it between two others.
description: |
`after_id` and `before_id` are the tasks either side of the gap it lands
in. Give both to place between them, one to place at an end, neither to
place first.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
project_id: { type: string, description: "Omitted, the task stays in its project." }
section_id: { type: string, description: Empty string means no section. }
after_id: { type: string }
before_id: { type: string }
responses:
"200": { $ref: "#/components/responses/Task" }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/tasks/quick-add:
post:
tags: [tasks]
summary: Create a task from one line of natural language.
description: |
Danish and English. `betal moms i morgen kl 10 p1 #Firma @vigtigt` becomes
a task due tomorrow at 10:00, priority 1, in the project Firma, labelled
vigtigt. An unknown `#project` is not an error — the task goes to the
Inbox rather than a thought being thrown away over a typo.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [text]
properties:
text: { type: string }
project_id:
type: string
description: Where it goes when the text names no project.
responses:
2026-08-17 16:22:36 +02:00
"201":
description: Created
content:
application/json:
schema: { $ref: "#/components/schemas/QuickAddResult" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/tasks/quick-add/preview:
get:
tags: [tasks]
summary: Show what a line would become, without creating anything.
parameters:
- { name: text, in: query, required: true, schema: { type: string } }
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/ParsedTask" }
/api/v1/tasks/{taskID}/comments:
parameters:
- { name: taskID, in: path, required: true, schema: { type: string } }
get:
tags: [tasks]
summary: Comments on a task, and the files attached to the task itself.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
comments:
type: array
items: { $ref: "#/components/schemas/Comment" }
attachments:
type: array
items: { $ref: "#/components/schemas/Attachment" }
"404": { $ref: "#/components/responses/NotFound" }
post:
tags: [tasks]
summary: Write a comment.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [body]
properties:
body: { type: string, maxLength: 10000 }
responses:
"201":
description: Created
content:
application/json:
schema: { $ref: "#/components/schemas/Comment" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/comments/{commentID}:
parameters:
- { name: commentID, in: path, required: true, schema: { type: string } }
patch:
tags: [tasks]
summary: Edit a comment. Its author only.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [body]
properties:
body: { type: string, maxLength: 10000 }
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/Comment" }
"404": { $ref: "#/components/responses/NotFound" }
delete:
tags: [tasks]
summary: Delete a comment. Its author only.
responses:
"204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/tasks/{taskID}/attachments:
parameters:
- { name: taskID, in: path, required: true, schema: { type: string } }
post:
tags: [tasks]
summary: Attach a file, up to 25 MB.
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: { type: string, format: binary }
comment_id:
type: string
description: Hang it on a comment instead of on the task.
responses:
"201":
description: Created
content:
application/json:
schema: { $ref: "#/components/schemas/Attachment" }
"404": { $ref: "#/components/responses/NotFound" }
"413": { $ref: "#/components/responses/TooLarge" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/attachments/{attachmentID}:
parameters:
- { name: attachmentID, in: path, required: true, schema: { type: string } }
get:
tags: [tasks]
summary: Download a file.
description: |
Always `application/octet-stream` and always an attachment, whatever the
file is. An uploaded SVG rendered inline would run its own script on this
origin with the session cookie attached.
responses:
"200":
description: The bytes.
content:
application/octet-stream:
schema: { type: string, format: binary }
"404": { $ref: "#/components/responses/NotFound" }
delete:
tags: [tasks]
summary: Delete a file.
responses:
"204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/tasks/{taskID}/reminders:
parameters:
- { name: taskID, in: path, required: true, schema: { type: string } }
get:
tags: [tasks]
summary: Reminders on a task.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
reminders:
type: array
items: { $ref: "#/components/schemas/Reminder" }
"404": { $ref: "#/components/responses/NotFound" }
post:
tags: [tasks]
summary: Add a reminder.
description: |
Exactly one of `remind_at` and `offset_min`. The offset is relative to the
task's due time and negative for "before", which is the only kind anybody
wants.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
remind_at: { type: string, format: date-time }
offset_min: { type: integer }
responses:
"201":
description: Created
content:
application/json:
schema: { $ref: "#/components/schemas/Reminder" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/reminders/{reminderID}:
parameters:
- { name: reminderID, in: path, required: true, schema: { type: string } }
delete:
tags: [tasks]
summary: Delete a reminder.
responses:
"204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" }
# --- projects -------------------------------------------------------------
/api/v1/projects:
get:
tags: [projects]
summary: Projects you can see.
parameters:
- { name: archived, in: query, schema: { type: boolean } }
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
projects:
type: array
items: { $ref: "#/components/schemas/Project" }
post:
tags: [projects]
summary: Create a project.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ProjectInput" }
responses:
"201": { $ref: "#/components/responses/Project" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/projects/reorder:
post:
tags: [projects]
summary: Set the order projects appear in.
description: |
The whole list at once rather than one move at a time. A person has a
handful of projects, so sending the order you want is simpler than
midpoints and cannot land half-applied.
Projects you do not own are skipped: `sort_order` is a column on the
project rather than a preference per viewer, so a shared one stays where
its owner put it.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ids]
properties:
ids:
type: array
maxItems: 500
items: { type: string }
description: Project ids, top to bottom.
responses:
"204": { description: Reordered. }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/projects/{projectID}:
parameters:
- { name: projectID, in: path, required: true, schema: { type: string } }
get:
tags: [projects]
summary: One project.
responses:
"200": { $ref: "#/components/responses/Project" }
"404": { $ref: "#/components/responses/NotFound" }
patch:
tags: [projects]
summary: Change a project. Its owner only.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ProjectInput" }
responses:
"200": { $ref: "#/components/responses/Project" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
delete:
tags: [projects]
summary: Delete a project. Its owner only.
description: Soft-deleted, and restorable from the trash for 30 days.
responses:
"204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/errors:
get:
tags: [auth]
summary: What has gone wrong lately. Administrators only.
description: |
Every 500 the API has answered, newest first, with what the handler was
attempting and the error it hit.
These are written to the database rather than only to the log because the
log does not survive. On a Rune the log belongs to the container, and the
container is replaced on every restart — so a panel watcher reports "HTTP
5xx, twice, at 11:49" long after the line explaining it has gone, which
tells an operator that something broke and gives them no way to find out
what.
Kept for thirty days by the nightly sweep: long enough to answer "what
happened last week", short enough that a fault loop cannot fill the volume
the tasks live on.
parameters:
- { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
errors:
type: array
items:
type: object
properties:
id: { type: string }
at: { type: string, format: date-time }
method: { type: string }
path: { type: string }
status: { type: integer }
what:
type: string
description: What the handler was attempting, in its own words.
example: list projects
message: { type: string }
user_name:
type: string
description: |
Who was asking, when there was somebody. A fault only
one account hits is a different problem from one
everybody hits.
request_id:
type: string
description: Ties the row back to the log line, for whoever still has it.
"403": { $ref: "#/components/responses/Forbidden" }
/api/v1/users:
get:
tags: [auth]
summary: Everybody on the instance, and the invites not yet accepted.
description: |
Administrators only. Each user carries what deleting them would destroy,
so the confirmation can name real numbers without a second request in the
middle of a decision.
Session only: an API token cannot enumerate the instance's membership.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
users:
type: array
items: { $ref: "#/components/schemas/AdminUser" }
invites:
type: array
items: { $ref: "#/components/schemas/PendingInvite" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [auth]
summary: Invite somebody to the instance.
description: |
There is no open registration and no account created with a password
somebody else chose: this issues an invite with no project attached, and
the link ends with a password only its owner has seen. The same link and
the same `/auth/signup` as a project invite — an invite whose project is
empty simply grants no membership when it is accepted.
No name is asked for. The signup form asks the person themselves, and a
name an administrator guesses is a name somebody has to correct.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
email: { type: string, format: email }
responses:
"201":
description: Created
content:
application/json:
schema:
type: object
properties:
link:
type: string
description: |
Returned so the interface can offer to copy it. On an
instance with no mail server that is the only way the invite
reaches anybody.
emailed: { type: boolean }
"403": { $ref: "#/components/responses/Forbidden" }
"409":
description: That address already has an account.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/users/{userID}:
parameters:
- { name: userID, in: path, required: true, schema: { type: string } }
patch:
tags: [auth]
summary: Make somebody an administrator, or stop them being one.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
is_admin: { type: boolean }
responses:
"204": { description: Changed. }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"409":
description: |
The last administrator cannot be demoted — `code` is `last_admin`.
An instance with no administrator has no way back: there is no
console, and setup refuses to run once an account exists.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
delete:
tags: [auth]
summary: Remove an account and everything the foreign keys take with it.
description: |
The only hard delete in this API. Everything else is soft-deleted with a
trash behind it; this is not recoverable.
What goes is wider than it looks: their projects, every task in those
projects, **and every task they wrote anywhere** — `tasks.created_by`
cascades, so retiring a colleague's account removes their contributions
from shared projects too. Tasks merely assigned to them are unassigned
and survive.
Refused for your own account, and for the last administrator.
responses:
"204": { description: Deleted. }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"409":
description: Your own account, or the last administrator.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
/api/v1/invites/{inviteID}:
parameters:
- { name: inviteID, in: path, required: true, schema: { type: string } }
delete:
tags: [auth]
summary: Withdraw an invite that has not been accepted.
description: |
Only the token's hash is stored, so a link sent to the wrong address
cannot be looked up and resent — withdrawing it is the only way to stop
it working before it expires.
responses:
"204": { description: Withdrawn. }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/project-groups:
get:
tags: [projects]
summary: Your foldable sidebar groups.
description: |
A group is a heading over some of your projects. It is yours rather than
the project's: it has no members and no roles, and a project shared with
you can be filed under one of your groups without the owner seeing it.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
groups:
type: array
items: { $ref: "#/components/schemas/ProjectGroup" }
post:
tags: [projects]
summary: Make a group.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ProjectGroupInput" }
responses:
"201": { $ref: "#/components/responses/ProjectGroup" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/project-groups/reorder:
post:
tags: [projects]
summary: Set the order groups appear in.
description: The whole list at once, for the same reasons as `/projects/reorder`.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ids]
properties:
ids:
type: array
maxItems: 500
items: { type: string }
description: Group ids, top to bottom.
responses:
"204": { description: Reordered. }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/project-groups/{groupID}:
parameters:
- { name: groupID, in: path, required: true, schema: { type: string } }
patch:
tags: [projects]
summary: Rename a group, or fold it.
description: |
`collapsed` is stored on the account rather than in the browser. The
sidebar's width is a property of the screen you are at; a folded group is
a statement about the group, and is true wherever you sign in.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ProjectGroupInput" }
responses:
"200": { $ref: "#/components/responses/ProjectGroup" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
delete:
tags: [projects]
summary: Delete a group.
description: |
The heading goes; the projects under it come back out as ungrouped. A
group holds no work of its own, so this is not soft-deleted — there is
nothing in it to recover.
responses:
"204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/trash/projects:
get:
tags: [projects]
summary: Projects you have deleted and can still bring back.
description: |
Only your own. Restoring has been possible since the trash existed, but
the endpoint takes an id — and the id is what you no longer have once the
project has gone from the interface.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
projects:
type: array
items:
allOf:
- $ref: "#/components/schemas/Project"
- type: object
properties:
deleted_at: { type: string, format: date-time }
purge_after:
type: string
format: date-time
description: |
When it stops being recoverable. Sent rather than
left for the client to work out, because the
retention window is the server's and a second
copy of it will one day disagree.
task_count:
type: integer
description: Tasks that went with it, and would come back with it.
/api/v1/trash/projects/{projectID}/restore:
parameters:
- { name: projectID, in: path, required: true, schema: { type: string } }
post:
tags: [projects]
summary: Restore a deleted project.
responses:
"200": { $ref: "#/components/responses/Project" }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/projects/{projectID}/sections:
parameters:
- { name: projectID, in: path, required: true, schema: { type: string } }
get:
tags: [projects]
summary: Sections in a project.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
sections:
type: array
items: { $ref: "#/components/schemas/Section" }
"404": { $ref: "#/components/responses/NotFound" }
post:
tags: [projects]
summary: Add a section. Editors and owners.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string }
responses:
"201":
description: Created
content:
application/json:
schema: { $ref: "#/components/schemas/Section" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/sections/{sectionID}:
parameters:
- { name: sectionID, in: path, required: true, schema: { type: string } }
patch:
tags: [projects]
summary: Rename or reorder a section.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name: { type: string }
sort_order: { type: number }
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/Section" }
"404": { $ref: "#/components/responses/NotFound" }
delete:
tags: [projects]
summary: Delete a section.
description: Its tasks are not deleted; they lose their section.
responses:
"204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/projects/{projectID}/members:
parameters:
- { name: projectID, in: path, required: true, schema: { type: string } }
get:
tags: [projects]
summary: Who the project is shared with.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
members:
type: array
items: { $ref: "#/components/schemas/Member" }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/projects/{projectID}/members/{userID}:
parameters:
- { name: projectID, in: path, required: true, schema: { type: string } }
- { name: userID, in: path, required: true, schema: { type: string } }
patch:
tags: [projects]
summary: Change what an existing member may do. The project's owner only.
description: |
Editor or viewer. An UPDATE against a membership that already exists —
somebody who is not a member is not silently made one, because that is an
invite and belongs to the invite flow.
Not the owner: ownership is transferred, not granted, and two owners is a
state with no way to settle a disagreement about who may remove whom. The
owner also has no membership row at all — ownership lives on the project —
so this answers 409 with the real reason rather than "not a member".
Correcting a role used to mean removing the person and inviting them
again, which also unassigns every task they were responsible for.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [role]
properties:
role: { type: string, enum: [editor, viewer] }
responses:
"204": { description: Changed. }
"404": { $ref: "#/components/responses/NotFound" }
"409":
description: The owner's standing cannot be changed.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
"422": { $ref: "#/components/responses/Validation" }
delete:
tags: [projects]
summary: Remove somebody from a project. Its owner only.
description: |
Their tasks are unassigned rather than left pointing at somebody who can
no longer see the project — which is why changing a role is a PATCH and
not a remove-and-reinvite.
responses:
"204": { description: Removed. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/projects/{projectID}/invites:
parameters:
- { name: projectID, in: path, required: true, schema: { type: string } }
post:
tags: [projects]
summary: Invite somebody. Its owner only.
description: |
With no mail server configured the response carries the link instead of
emailing it, so the invite is not silently lost.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email, role]
properties:
email: { type: string, format: email }
role: { type: string, enum: [editor, viewer] }
responses:
"201":
description: Created
content:
application/json:
schema:
type: object
properties:
link: { type: string }
emailed: { type: boolean }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/projects/{projectID}/activity:
parameters:
- { name: projectID, in: path, required: true, schema: { type: string } }
get:
tags: [projects]
summary: What has happened in a project.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
activity:
type: array
items: { $ref: "#/components/schemas/Activity" }
"404": { $ref: "#/components/responses/NotFound" }
# --- views ----------------------------------------------------------------
/api/v1/today:
get:
tags: [views]
summary: What is overdue, and what is due today.
description: Resolved in the caller's timezone, not the server's.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
overdue:
type: array
items: { $ref: "#/components/schemas/Task" }
today:
type: array
items: { $ref: "#/components/schemas/Task" }
/api/v1/upcoming:
get:
tags: [views]
summary: The next few days, empty ones included.
parameters:
- { name: days, in: query, schema: { type: integer, default: 7 } }
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
days:
type: array
items:
type: object
properties:
date: { type: string, format: date }
tasks:
type: array
items: { $ref: "#/components/schemas/Task" }
/api/v1/people:
get:
tags: [views]
summary: Everybody you share a project with, and yourself.
description: |
What an interface needs to put a name and a face on an `assignee_id` — in
a task row, in a filter, anywhere a person appears beside work. One
request per session rather than a lookup per row, and per project members
would mean knowing which project every task belongs to first.
Deliberately not the instance's user list: that is
`/users`, administrators only. A task list has no business enumerating
everybody with an account here.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
people:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
avatar_color: { type: string }
/api/v1/delegated:
get:
tags: [views]
summary: What you are waiting on other people for.
description: |
Everything assigned to somebody who is not you, in the projects you can
see, grouped by the person it is assigned to.
Grouped rather than flat because the question is about people, and a
client grouping it itself would need every assignee's name — which it has
no way to ask for. The names come back with the tasks, so one request is
one screen.
parameters:
- { name: limit, in: query, schema: { type: integer, default: 200, maximum: 500 } }
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
people:
type: array
items:
type: object
properties:
user_id: { type: string }
name: { type: string }
avatar_color: { type: string }
tasks:
type: array
items: { $ref: "#/components/schemas/Task" }
/api/v1/search:
get:
tags: [views]
summary: Full-text search over tasks.
description: |
Diacritic-folded, so "gron" finds "grøn". Danish treats ø and æ as letters
of their own rather than as accented forms, which is why the index carries
a folded copy of the text beside the original.
parameters:
- { name: q, in: query, required: true, schema: { type: string } }
responses:
"200": { $ref: "#/components/responses/TaskList" }
/api/v1/labels:
get:
tags: [views]
summary: Your labels, with how many tasks carry each.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
labels:
type: array
items: { $ref: "#/components/schemas/Label" }
post:
tags: [views]
summary: Create a label.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string }
responses:
"201":
description: Created
content:
application/json:
schema: { $ref: "#/components/schemas/Label" }
"409": { $ref: "#/components/responses/Conflict" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/labels/{labelID}:
parameters:
- { name: labelID, in: path, required: true, schema: { type: string } }
patch:
tags: [views]
summary: Rename or recolour a label.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name: { type: string }
color: { type: string }
sort_order: { type: number }
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/Label" }
"404": { $ref: "#/components/responses/NotFound" }
delete:
tags: [views]
summary: Delete a label.
responses:
"204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/filters:
get:
tags: [views]
summary: Saved filters.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
filters:
type: array
items: { $ref: "#/components/schemas/Filter" }
post:
tags: [views]
summary: Save a filter.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/FilterInput" }
responses:
"201":
description: Created
content:
application/json:
schema: { $ref: "#/components/schemas/Filter" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/filters/{filterID}:
parameters:
- { name: filterID, in: path, required: true, schema: { type: string } }
patch:
tags: [views]
summary: Change a saved filter.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/FilterInput" }
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/Filter" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
delete:
tags: [views]
summary: Delete a saved filter.
responses:
"204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/filters/{filterID}/tasks:
parameters:
- { name: filterID, in: path, required: true, schema: { type: string } }
get:
tags: [views]
summary: Run a saved filter.
responses:
"200": { $ref: "#/components/responses/TaskList" }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/filters/preview:
get:
tags: [views]
summary: Run a filter expression without saving it.
description: |
The expression is compiled to a bound SQL fragment, never assembled from
the text — see the filter language in the documentation.
parameters:
- { name: query, in: query, required: true, schema: { type: string, example: "i dag & p1 | @arbejde" } }
responses:
"200": { $ref: "#/components/responses/TaskList" }
"422": { $ref: "#/components/responses/Validation" }
# --- integrations ---------------------------------------------------------
/api/v1/tokens:
get:
tags: [integrations]
summary: Your API tokens.
description: Needs a session. A bearer token is refused with 403.
security:
- session: []
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
tokens:
type: array
items: { $ref: "#/components/schemas/ApiToken" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [integrations]
summary: Mint an API token.
description: |
The plaintext comes back here and never again — only its hash is stored.
Needs a session: a token that can mint another token is a leak that
revoking the first one does not close.
security:
- session: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string, maxLength: 80 }
expires_in_days:
type: integer
minimum: 0
maximum: 3650
default: 0
description: 0 means it never expires.
responses:
"201":
description: Created
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/ApiToken"
- type: object
properties:
token: { type: string, example: vrd_… }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/tokens/{tokenID}:
parameters:
- { name: tokenID, in: path, required: true, schema: { type: string } }
delete:
tags: [integrations]
summary: Revoke an API token, immediately.
security:
- session: []
responses:
"204": { description: Revoked. }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/feed:
get:
tags: [integrations]
summary: Your calendar subscription URL.
description: |
Minted on first ask. The token in the URL is the whole credential — a
calendar client cannot log in — so treat the address as a password.
responses:
"200": { $ref: "#/components/responses/FeedURL" }
/api/v1/feed/rotate:
post:
tags: [integrations]
summary: Issue a new feed URL.
description: |
Breaks every existing subscription at once. That is the point: it is what
you do when the old address has ended up somewhere it should not.
responses:
"200": { $ref: "#/components/responses/FeedURL" }
/api/v1/mail-address:
get:
tags: [integrations]
summary: Your personal mail-to-task address.
responses:
"200": { $ref: "#/components/responses/MailAddress" }
/api/v1/mail-address/rotate:
post:
tags: [integrations]
summary: Issue a new mail-to-task address.
responses:
"200": { $ref: "#/components/responses/MailAddress" }
/api/v1/notifications:
get:
tags: [integrations]
summary: Recent notifications, and how many are unread.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
notifications:
type: array
items: { $ref: "#/components/schemas/Notification" }
unread: { type: integer }
/api/v1/notifications/read:
post:
tags: [integrations]
summary: Mark every notification as read.
responses:
"204": { description: Marked. }
/api/v1/notifications/{notificationID}/read:
parameters:
- { name: notificationID, in: path, required: true, schema: { type: string } }
post:
tags: [integrations]
summary: Mark one notification as read.
responses:
"204": { description: Marked. }
/api/v1/push/key:
get:
tags: [integrations]
summary: The instance's VAPID public key.
description: What `PushManager.subscribe` needs before it will produce a subscription.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
public_key: { type: string, description: base64url }
/api/v1/push/subscribe:
post:
tags: [integrations]
summary: Register a Web Push subscription for this device.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/PushSubscription" }
responses:
"204": { description: Registered. }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/push/unsubscribe:
post:
tags: [integrations]
summary: Forget a subscription.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/PushSubscription" }
responses:
"204": { description: Forgotten. }
/api/v1/gmail:
get:
tags: [integrations]
summary: The Gmail connection and what triggers a task.
responses:
"200": { $ref: "#/components/responses/Gmail" }
put:
tags: [integrations]
summary: Change what triggers a task.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
trigger: { type: string, enum: [starred, label, both] }
label: { type: string }
responses:
"204": { description: Saved. }
delete:
tags: [integrations]
summary: Disconnect Gmail and forget its tokens.
responses:
"204": { description: Disconnected. }
/api/v1/gmail/authorize:
post:
tags: [integrations]
summary: Begin the OAuth flow.
description: |
Returns the Google URL to send the browser to. The PKCE verifier and state
are kept server-side and expire in ten minutes.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
url: { type: string }
"409":
$ref: "#/components/responses/Conflict"
/api/v1/gmail/sync:
post:
tags: [integrations]
summary: Poll Gmail now.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
created: { type: integer }
"502": { $ref: "#/components/responses/Upstream" }
/api/v1/ai/settings:
get:
tags: [integrations]
summary: The AI provider in use.
description: |
The key itself is never sent back; `has_key` says whether one is stored.
A settings page that repopulates a password field is one that will
eventually leak a key into a screenshot.
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/AISettings" }
put:
tags: [integrations]
summary: Set the AI provider.
description: An empty `api_key` leaves the stored key alone rather than clearing it.
requestBody:
required: true
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/AISettings"
- type: object
properties:
api_key: { type: string }
responses:
"204": { description: Saved. }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/ai/summary:
post:
tags: [integrations]
summary: A short prioritisation note over what is outstanding.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
summary: { type: string }
"409":
$ref: "#/components/responses/Conflict"
"502": { $ref: "#/components/responses/Upstream" }
/api/v1/ai/tasks/{taskID}/split:
parameters:
- { name: taskID, in: path, required: true, schema: { type: string } }
post:
tags: [integrations]
summary: Break a task into sub-tasks and create them.
responses:
"201":
description: Created
content:
application/json:
schema:
type: object
properties:
subtasks:
type: array
items: { $ref: "#/components/schemas/Task" }
"404": { $ref: "#/components/responses/NotFound" }
"409":
$ref: "#/components/responses/Conflict"
"502": { $ref: "#/components/responses/Upstream" }
/api/v1/mcp:
post:
tags: [integrations]
summary: The Model Context Protocol endpoint.
description: |
JSON-RPC 2.0. A connector authenticating with an API token sees exactly
what its owner sees. A notification — a message with no `id` — is answered
with 202 and no body, as the specification requires.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [jsonrpc, method]
properties:
jsonrpc: { type: string, const: "2.0" }
id: { oneOf: [{ type: string }, { type: integer }] }
method: { type: string }
params: { type: object }
responses:
"200":
description: A JSON-RPC response.
content:
application/json:
schema:
type: object
properties:
jsonrpc: { type: string, const: "2.0" }
id: { oneOf: [{ type: string }, { type: integer }] }
result: { type: object }
error: { type: object }
"202": { description: A notification. No body. }
/api/v1/version:
get:
tags: [integrations]
summary: The running version, and whether a newer one exists.
description: |
The update notice is only filled in for an administrator — telling an
ordinary member the server is out of date is telling them about somebody
else's job.
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/Version" }
/api/v1/ws:
get:
tags: [integrations]
summary: Live changes, over a WebSocket.
description: |
One-way by design: nothing a client sends over the socket changes
anything. Writes go through the REST API, where the permission checks are.
Events: `task.created`, `task.updated`, `task.completed`, `task.reopened`,
`task.moved`, `task.deleted`, `comment.created`, `notification`,
`reminder`.
responses:
"101": { description: Switching protocols. }
"401": { $ref: "#/components/responses/Unauthorized" }
/api/v1/ping:
get:
tags: [integrations]
summary: A liveness check that does not touch the database.
security: []
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
status: { type: string, const: ok }
# --- data -----------------------------------------------------------------
/api/v1/import/todoist:
post:
tags: [data]
summary: Import a Todoist CSV export.
description: |
Priorities are converted, not copied: Todoist's CSV writes 4 for what its
interface calls P1. What could not be brought across exactly comes back in
`warnings` rather than being dropped silently.
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: { type: string, format: binary }
name: { type: string, description: Defaults to the file's own name. }
responses:
"201": { $ref: "#/components/responses/ImportResult" }
"413": { $ref: "#/components/responses/TooLarge" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/import/csv:
post:
tags: [data]
summary: Import mapped rows from any CSV.
description: |
The file is parsed in the browser and the mapped rows arrive as JSON, so
the column-mapping interface does not have to upload the file twice.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [mapping, rows]
properties:
name: { type: string }
mapping:
type: object
additionalProperties: { type: string }
example: { content: Opgave, due: Frist }
rows:
type: array
maxItems: 5000
items:
type: object
additionalProperties: { type: string }
responses:
"201": { $ref: "#/components/responses/ImportResult" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/export/account:
get:
tags: [data]
summary: Everything, as JSON.
description: |
The "you can leave" guarantee, and deliberately the raw shape: an export
that has been tidied is an export that has lost something.
responses:
"200":
description: OK
content:
application/json:
schema: { type: object }
/api/v1/export/projects/{projectID}.csv:
parameters:
- { name: projectID, in: path, required: true, schema: { type: string } }
get:
tags: [data]
summary: One project as a Todoist-compatible CSV.
responses:
"200":
description: OK
content:
text/csv:
schema: { type: string }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/export/projects/{projectID}.ics:
parameters:
- { name: projectID, in: path, required: true, schema: { type: string } }
get:
tags: [data]
summary: One project as a calendar file.
responses:
"200":
description: OK
content:
text/calendar:
schema: { type: string }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/templates:
get:
tags: [data]
summary: Your project templates.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
templates:
type: array
items: { $ref: "#/components/schemas/Template" }
post:
tags: [data]
summary: Save a project as a template.
description: Due dates are stored relative to the project's start.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [project_id]
properties:
project_id: { type: string }
name: { type: string }
description: { type: string }
responses:
"201":
description: Created
content:
application/json:
schema: { $ref: "#/components/schemas/Template" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/templates/{templateID}:
parameters:
- { name: templateID, in: path, required: true, schema: { type: string } }
delete:
tags: [data]
summary: Delete a template.
responses:
"204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/templates/{templateID}/use:
parameters:
- { name: templateID, in: path, required: true, schema: { type: string } }
post:
tags: [data]
summary: Create a project from a template.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name: { type: string }
start_date:
type: string
format: date
description: Day zero for the template's relative dates. Defaults to today.
responses:
"201": { $ref: "#/components/responses/Project" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
# --- outside /api/v1 -------------------------------------------------------
/healthz:
get:
tags: [integrations]
summary: Health check.
description: |
Reads the database rather than merely answering, so a container with a
broken volume reports unhealthy instead of pretending to work.
security: []
responses:
"200":
description: Healthy. Carries the running version — the one thing you can ask without a session.
content:
application/json:
schema:
type: object
properties:
status: { type: string, const: ok }
version: { type: string, example: v0.3.1 }
"503": { description: Unhealthy. }
/ics/{token}:
parameters:
- { name: token, in: path, required: true, schema: { type: string } }
get:
tags: [integrations]
summary: The calendar feed itself.
description: |
Unauthenticated by necessity: a calendar client fetches a URL with no
credentials and no way to prompt for any, so the token in the path is the
credential. A missing feed answers 404 rather than 401, because a
calendar client would show a password prompt nobody can answer.
security: []
responses:
"200":
description: OK
content:
text/calendar:
schema: { type: string }
"404": { description: No such feed. }
/inbound/mail:
post:
tags: [integrations]
summary: Turn a delivered email into a task.
description: |
Called by the mail server, not by a browser. Authenticated by the token in
the recipient address, which is the only credential inbound mail carries.
The subject line goes through the quick-add parser.
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [to]
properties:
to: { type: string }
from: { type: string }
subject: { type: string }
body: { type: string }
responses:
"201":
description: Created
content:
application/json:
schema:
type: object
properties:
task_id: { type: string }
"404": { description: "No such address. Identical to a malformed one, deliberately." }
/oauth/gmail/callback:
get:
tags: [integrations]
summary: Where Google sends the browser after consent.
description: |
Answers with a redirect to `/indstillinger`, not with JSON: the person is
looking at a browser tab, not at a fetch response.
parameters:
- { name: code, in: query, schema: { type: string } }
- { name: state, in: query, schema: { type: string } }
- { name: error, in: query, schema: { type: string } }
responses:
"302": { description: "Back into the app, with the outcome as a query parameter." }
/mcp:
post:
tags: [integrations]
summary: The MCP endpoint, with the token in the query string.
description: |
The same server as `/api/v1/mcp`, for a client that can only be given a
URL. Claude's custom-connector dialog asks for a name, an address and
OAuth client credentials — there is no field for a bearer token — so the
API path cannot be configured from it.
The key is the only credential accepted here. A session cookie is
ignored: this route sits outside the CSRF check, and honouring a cookie
would make it a cross-site request that acts as whoever is signed in.
security: []
parameters:
- name: key
in: query
required: true
schema: { type: string, example: vrd_… }
description: A personal API token. Treat the whole address as a password.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [jsonrpc, method]
properties:
jsonrpc: { type: string, const: "2.0" }
id: { oneOf: [{ type: string }, { type: integer }] }
method: { type: string }
params: { type: object }
responses:
"200":
description: A JSON-RPC response.
content:
application/json:
schema:
type: object
properties:
jsonrpc: { type: string, const: "2.0" }
id: { oneOf: [{ type: string }, { type: integer }] }
result: { type: object }
error: { type: object }
"202": { description: A notification. No body. }
"401": { $ref: "#/components/responses/Unauthorized" }
/.well-known/caldav:
get:
tags: [integrations]
summary: CalDAV discovery.
description: |
The rest of the CalDAV surface lives under `/caldav` and speaks WebDAV
verbs — PROPFIND, REPORT — which are outside what OpenAPI describes. See
the CalDAV page in the documentation.
security: []
responses:
"301": { description: To the CalDAV root. }
components:
securitySchemes:
session:
type: apiKey
in: cookie
name: __Host-verdande_session
description: |
Set by `/auth/login`. `httpOnly`, `SameSite=Lax`, and prefixed `__Host-`
when served over HTTPS. Over plain HTTP the prefix is invalid and the
cookie is named `verdande_session`.
bearer:
type: http
scheme: bearer
description: |
A personal API token, `vrd_…`. Carries its owner's permissions exactly —
there is no separate service identity — and is refused on `/tokens`.
responses:
BadRequest:
description: The body could not be read.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
Unauthorized:
description: Not signed in, or the credential is wrong.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
Forbidden:
description: A cross-site request, or a token where a session is required.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
NotFound:
description: Does not exist, or you cannot see it. The two are not distinguished.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
Conflict:
description: Already exists, or the state does not allow it.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
TooLarge:
description: The body or the file is too large.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
Validation:
description: One or more fields are not valid. See `fields`.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
RateLimited:
description: Too many attempts. Carries `Retry-After`.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
Upstream:
description: An external service failed.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
Task:
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/Task" }
TaskList:
description: OK
content:
application/json:
schema:
type: object
properties:
tasks:
type: array
items: { $ref: "#/components/schemas/Task" }
Project:
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/Project" }
ProjectGroup:
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/ProjectGroup" }
FeedURL:
description: OK
content:
application/json:
schema:
type: object
properties:
url: { type: string }
MailAddress:
description: OK
content:
application/json:
schema:
type: object
properties:
address: { type: string, example: todo+abc123@example.dk }
configured:
type: boolean
description: |
Whether a mail server is set up at all. The address is real
either way, but with no mail server nothing on the internet
routes to it — and an interface that does not say so is
promising something that will not happen.
Gmail:
description: OK
content:
application/json:
schema:
type: object
properties:
connected: { type: boolean }
email: { type: string }
trigger: { type: string, enum: ["", starred, label, both] }
label: { type: string }
ImportResult:
description: Created
content:
application/json:
schema:
type: object
properties:
project_id: { type: string }
projects: { type: integer }
tasks: { type: integer }
sections: { type: integer }
comments: { type: integer }
warnings:
type: array
items: { type: string }
schemas:
Error:
type: object
description: |
The one shape every failure takes. `code` is stable and meant to be
matched on; `error` is English prose for a log, and what a person reads is
decided by the client, which knows what language they are in.
required: [code, error]
properties:
code:
type: string
enum:
- bad_request
- validation_failed
- unauthorized
- totp_required
- forbidden
- not_found
- conflict
- rate_limited
- internal_error
- payload_too_large
- gmail_not_configured
- ai_not_configured
- totp_not_enabled
- inbox_protected
error: { type: string }
fields:
type: object
additionalProperties: { type: string }
example: { due_date: "must be a date like 2026-03-15" }
User:
type: object
description: Never carries the password hash or the TOTP secret.
properties:
id: { type: string }
email: { type: string, format: email }
name: { type: string }
avatar_color: { type: string }
timezone: { type: string }
locale: { type: string, enum: [da, en] }
is_admin: { type: boolean }
totp_enabled: { type: boolean }
created_at: { type: string, format: date-time }
RecoveryCodes:
type: object
properties:
recovery_codes:
type: array
items: { type: string }
AdminUser:
type: object
properties:
id: { type: string }
email: { type: string, format: email }
name: { type: string }
avatar_color: { type: string }
is_admin: { type: boolean }
self:
type: boolean
description: The caller, so the interface can grey out what nobody should do to their own account here.
created_at: { type: string, format: date-time }
last_seen_at:
type: string
format: date-time
description: |
Absent when they have never signed in — which is what an invite that
was accepted and then forgotten looks like. Absent rather than zero,
because 1970 in an interface reads as a bug.
project_count:
type: integer
description: Projects they own, excluding the Inbox every account has.
task_count:
type: integer
description: |
Everything a delete would destroy: tasks in projects they own, plus
every task they wrote anywhere. Not the same as "their tasks", and
the difference is the whole point of showing it.
PendingInvite:
type: object
properties:
id: { type: string }
email: { type: string, format: email }
project_name:
type: string
description: Absent for an invite to the instance itself rather than to a project.
role: { type: string, enum: [owner, editor, viewer] }
invited_by: { type: string }
created_at: { type: string, format: date-time }
expires_at: { type: string, format: date-time }
Session:
type: object
properties:
id: { type: string }
current:
type: boolean
description: The one making this request.
device:
type: string
description: |
A summary of the user agent — "Chrome på macOS". A handful of
substring tests rather than a parsing library: the question it
answers is "is one of these me?", and the raw string is sent beside
it so anything the summary gets wrong is still visible.
example: Chrome på macOS
user_agent: { type: string }
ip: { type: string }
created_at: { type: string, format: date-time }
last_seen_at: { type: string, format: date-time }
expires_at: { type: string, format: date-time }
Task:
type: object
properties:
id: { type: string }
project_id: { type: string }
section_id: { type: string }
parent_id: { type: string }
content: { type: string }
description: { type: string }
priority: { type: integer, minimum: 1, maximum: 4, description: 1 is most urgent. }
due_date: { type: string, format: date }
due_datetime: { type: string, format: date-time }
duration_min: { type: integer }
recurrence_rule: { type: string, description: An RFC 5545 RRULE. }
recurrence_text: { type: string, description: "The rule in words, for display." }
assignee_id: { type: string }
labels:
type: array
items: { type: string }
completed: { type: boolean }
completed_at: { type: string, format: date-time }
created_by: { type: string }
sort_order: { type: number }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
TaskInput:
type: object
properties:
2026-08-17 16:22:36 +02:00
project_id:
type: string
description: |
Omitted on create, the task goes to the Inbox. On update it moves the
task — which needs editor rights on the destination.
section_id: { type: string }
parent_id: { type: string }
content: { type: string }
description: { type: string }
priority: { type: integer, minimum: 1, maximum: 4 }
due_date: { type: string, format: date }
due_time: { type: string, example: "10:00" }
duration_min: { type: integer }
recurrence_rule: { type: string }
assignee_id: { type: string }
labels:
type: array
items: { type: string }
sort_order: { type: number }
2026-08-17 16:22:36 +02:00
QuickAddResult:
allOf:
- $ref: "#/components/schemas/Task"
- type: object
properties:
unknown_project:
type: string
description: |
Present when the text named a `#project` that does not exist. The
task is still created, in the Inbox — a thought is not thrown away
over a typo — but the caller is told, because the alternative is
that the name vanishes from the title and nothing says why.
ParsedTask:
type: object
description: What the quick-add parser made of a line.
properties:
content: { type: string }
project: { type: string }
priority: { type: integer }
due_date: { type: string, format: date }
due_datetime: { type: string, format: date-time }
recurrence: { type: string }
labels:
type: array
items: { type: string }
Project:
type: object
properties:
id: { type: string }
name: { type: string }
color: { $ref: "#/components/schemas/Color" }
icon: { type: string }
view_mode: { type: string, enum: [list, board, calendar] }
owner_id: { type: string }
is_inbox: { type: boolean }
archived: { type: boolean }
sort_order: { type: number }
role: { type: string, enum: [owner, editor, viewer] }
member_count: { type: integer }
shared: { type: boolean }
group_id:
type: string
description: |
The sidebar group *you* have filed it under, absent for none. Always
absent on a project shared with you: grouping is a column on the
project row, so it is the owner's filing rather than yours.
ProjectInput:
type: object
properties:
name: { type: string }
color: { $ref: "#/components/schemas/Color" }
icon: { type: string }
view_mode: { type: string, enum: [list, board, calendar] }
archived: { type: boolean }
sort_order: { type: number }
group_id:
type: string
description: |
Files the project under one of your groups. The empty string takes it
out of one — `null` cannot mean that, because absent and null are the
same thing once decoded, and telling "put it nowhere" apart from "I
did not mention it" is what makes this a PATCH.
ProjectGroup:
type: object
properties:
id: { type: string }
name: { type: string }
color: { $ref: "#/components/schemas/Color" }
collapsed: { type: boolean }
sort_order: { type: number }
ProjectGroupInput:
type: object
properties:
name: { type: string }
color: { $ref: "#/components/schemas/Color" }
collapsed: { type: boolean }
Color:
type: string
default: graphite
enum: [graphite, tomato, amber, lime, green, teal, blue, indigo, violet, magenta]
description: |
A name, not a hex value. The interface resolves it when it paints, so the
palette can be retuned for a new theme or a new display without rewriting
every row — and a database that stores a fact rather than a rendering.
The set is closed: an unknown name is refused rather than stored and
painted as the default, which would look like a colour that did not save.
One palette for all five themes. These are small solid marks beside a
label that carries the meaning, not text or grounds, so what they owe is
to be told apart and to be visible on both a near-black and a white
ground — which is why they all sit in the mid-tone band.
Section:
type: object
properties:
id: { type: string }
project_id: { type: string }
name: { type: string }
sort_order: { type: number }
Member:
type: object
properties:
user_id: { type: string }
email: { type: string, format: email }
name: { type: string }
avatar_color: { type: string }
role: { type: string, enum: [owner, editor, viewer] }
Activity:
type: object
properties:
id: { type: string }
project_id: { type: string }
task_id: { type: string }
actor_id: { type: string }
actor_name: { type: string }
kind: { type: string, example: task.completed }
data: { type: object }
created_at: { type: string, format: date-time }
Label:
type: object
properties:
id: { type: string }
name: { type: string }
color: { type: string }
sort_order: { type: number }
task_count: { type: integer }
Filter:
type: object
properties:
id: { type: string }
name: { type: string }
query: { type: string, example: "i dag & p1" }
color: { type: string }
sort_order: { type: number }
FilterInput:
type: object
properties:
name: { type: string }
query: { type: string }
color: { type: string }
sort_order: { type: number }
Comment:
type: object
properties:
id: { type: string }
task_id: { type: string }
user_id: { type: string }
user_name: { type: string }
user_color: { type: string }
body: { type: string }
attachments:
type: array
items: { $ref: "#/components/schemas/Attachment" }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
Attachment:
type: object
properties:
id: { type: string }
filename: { type: string }
mime_type: { type: string }
size: { type: integer }
url: { type: string }
Reminder:
type: object
properties:
id: { type: string }
task_id: { type: string }
remind_at: { type: string, format: date-time }
offset_min: { type: integer }
sent: { type: boolean }
Template:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
color: { type: string }
task_count: { type: integer }
created_at: { type: string, format: date-time }
ApiToken:
type: object
properties:
id: { type: string }
name: { type: string }
prefix: { type: string, description: "The first characters, which is all that is ever shown again." }
created_at: { type: string, format: date-time }
last_used_at: { type: string, format: date-time }
expires_at: { type: string, format: date-time }
Notification:
type: object
properties:
id: { type: string }
actor_name: { type: string }
project_id: { type: string }
task_id: { type: string }
kind: { type: string }
title: { type: string }
body: { type: string }
read: { type: boolean }
created_at: { type: string, format: date-time }
PushSubscription:
type: object
required: [endpoint]
properties:
endpoint: { type: string }
keys:
type: object
properties:
p256dh: { type: string }
auth: { type: string }
AISettings:
type: object
properties:
provider: { type: string, enum: ["", anthropic, openai, google, compatible] }
base_url: { type: string }
model: { type: string }
has_key: { type: boolean, readOnly: true }
Version:
type: object
properties:
current: { type: string }
latest: { type: string }
update_available: { type: boolean }
url: { type: string }
notes: { type: string }
checked_at: { type: string, format: date-time }
disabled:
type: boolean
description: |
Distinguishes "no update" from "we never looked", which the interface
has to show differently or it implies a guarantee it cannot make.