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/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: "201": { $ref: "#/components/responses/Task" } "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/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 } } delete: tags: [projects] summary: Remove somebody from a project. Its owner only. 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/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" } 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 } 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: project_id: { type: string, description: "Omitted on create, the task goes to the Inbox." } 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 } 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: { type: string } 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 } ProjectInput: type: object properties: name: { type: string } color: { type: string } icon: { type: string } view_mode: { type: string, enum: [list, board, calendar] } archived: { type: boolean } sort_order: { type: number } 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.