Files
verdande/docs/openapi.yaml
T

2283 lines
73 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/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/{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/{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.