2026-08-17 10:40:54 +02:00
|
|
|
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" }
|
|
|
|
|
|
2026-08-18 09:05:36 +02:00
|
|
|
/api/v1/auth/sidebar-sections:
|
|
|
|
|
put:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: Which sidebar headings are folded away.
|
|
|
|
|
description: |
|
|
|
|
|
On the account rather than in the browser. A project group already carries
|
|
|
|
|
`collapsed` on its own row for the same reason: folding a heading is a
|
|
|
|
|
statement about the work — "I am not in Etiketter at the moment" — and that
|
|
|
|
|
is as true on a laptop as on a desktop. The sidebar's *width* is the
|
|
|
|
|
opposite case and stays in the browser, because a narrow laptop and a wide
|
|
|
|
|
monitor deserve different answers.
|
|
|
|
|
|
|
|
|
|
Its own route rather than a field on `PATCH /auth/me`. That one backs a
|
|
|
|
|
form with a save button and validates three fields together; this writes on
|
|
|
|
|
every click of a chevron, and sharing the path would mean a fold sending a
|
|
|
|
|
name and a timezone it was never asked to change.
|
|
|
|
|
|
|
|
|
|
The keys belong to the interface and are not checked here. An unknown one
|
|
|
|
|
costs nothing — the sidebar folds what it recognises and ignores the rest —
|
|
|
|
|
so renaming or removing a heading leaves a harmless entry rather than
|
|
|
|
|
needing a migration.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
sections:
|
|
|
|
|
type: array
|
|
|
|
|
maxItems: 20
|
|
|
|
|
items: { type: string }
|
|
|
|
|
example: ["labels", "filters"]
|
|
|
|
|
responses:
|
|
|
|
|
"204": { description: Saved. }
|
|
|
|
|
"400": { $ref: "#/components/responses/BadRequest" }
|
|
|
|
|
|
2026-08-19 17:19:46 +02:00
|
|
|
/api/v1/auth/nav-order:
|
|
|
|
|
put:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: The order of the fixed views in the sidebar.
|
|
|
|
|
description: |
|
|
|
|
|
I dag, Kommende, Venter and the inbox, in whatever order this person wants
|
|
|
|
|
them. On the account rather than in the browser: which order you want them
|
|
|
|
|
in is a fact about you and should be the same on the phone as on the laptop.
|
|
|
|
|
|
|
|
|
|
A list of keys rather than a number per view. The set of views changes with
|
|
|
|
|
the program, and a key that no longer exists costs nothing — the sidebar
|
|
|
|
|
draws what it recognises and appends anything new at the end, which is what
|
|
|
|
|
makes adding a view later not a migration. An empty list means the order the
|
|
|
|
|
program ships with.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
order:
|
|
|
|
|
type: array
|
|
|
|
|
maxItems: 20
|
|
|
|
|
items: { type: string }
|
|
|
|
|
example: ["inbox", "today", "upcoming", "delegated"]
|
|
|
|
|
responses:
|
|
|
|
|
"204": { description: Saved. }
|
|
|
|
|
"400": { $ref: "#/components/responses/BadRequest" }
|
|
|
|
|
|
2026-08-17 10:40:54 +02:00
|
|
|
/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" }
|
|
|
|
|
|
2026-08-18 11:56:54 +02:00
|
|
|
/api/v1/auth/passkeys:
|
|
|
|
|
get:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: The keys registered on this account.
|
|
|
|
|
description: |
|
|
|
|
|
A passkey is a key on a device rather than a secret in a head. The private
|
|
|
|
|
half never leaves the device, and the signature is bound to the origin that
|
|
|
|
|
asked for it — so a convincing copy of the sign-in page at another address
|
|
|
|
|
gets a signature it cannot use.
|
|
|
|
|
|
|
|
|
|
One row per credential, because a person has a laptop and a phone and losing
|
|
|
|
|
one must not mean losing the way in. The name is theirs to write: a list
|
|
|
|
|
nobody can read is a list nobody revokes from.
|
|
|
|
|
|
|
|
|
|
Sessions only, like `/tokens` — a leaked API token must not be able to add
|
|
|
|
|
its own way in, which would turn a theft into a tenancy.
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
passkeys:
|
|
|
|
|
type: array
|
|
|
|
|
items: { $ref: "#/components/schemas/Passkey" }
|
|
|
|
|
available:
|
|
|
|
|
type: boolean
|
|
|
|
|
description: |
|
|
|
|
|
Whether this deployment can offer passkeys at all. False when
|
|
|
|
|
`VERDANDE_BASE_URL` is an IP address or otherwise not a
|
|
|
|
|
domain an authenticator will accept — the interface asks once
|
|
|
|
|
rather than discovering it from a 503 it caused by offering a
|
|
|
|
|
button.
|
|
|
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
|
|
|
|
|
|
|
|
/api/v1/auth/passkeys/register/begin:
|
|
|
|
|
post:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: Start registering a key. Returns the options and a challenge id.
|
|
|
|
|
description: |
|
|
|
|
|
Registration is done by somebody already signed in: you cannot be handed a
|
|
|
|
|
way into an account you have not proved you own.
|
|
|
|
|
|
|
|
|
|
The challenge is kept server-side rather than in a cookie, because the whole
|
|
|
|
|
point of it is that the server chose it and remembers choosing it. It is
|
|
|
|
|
answerable once and expires after five minutes.
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: Pass `options` to `navigator.credentials.create()`.
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
challenge_id: { type: string }
|
|
|
|
|
options: { type: object, additionalProperties: true }
|
|
|
|
|
"503":
|
|
|
|
|
description: |
|
|
|
|
|
`VERDANDE_BASE_URL` is not an address an authenticator will accept. 503
|
|
|
|
|
rather than 404: this version has passkeys, this deployment cannot offer
|
|
|
|
|
them, and those are different things to fix.
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
|
|
|
|
|
|
|
|
/api/v1/auth/passkeys/register/finish:
|
|
|
|
|
post:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: Finish registering a key.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
challenge_id: { type: string }
|
|
|
|
|
name:
|
|
|
|
|
type: string
|
|
|
|
|
description: What to call it in the list — "min bærbare".
|
|
|
|
|
credential:
|
|
|
|
|
type: object
|
|
|
|
|
additionalProperties: true
|
|
|
|
|
description: The response from `navigator.credentials.create()`.
|
|
|
|
|
responses:
|
|
|
|
|
"201":
|
|
|
|
|
description: Registered.
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/Passkey" }
|
|
|
|
|
"400":
|
|
|
|
|
description: The attempt expired, was already used, or did not verify.
|
|
|
|
|
"503":
|
|
|
|
|
description: Passkeys are unavailable on this instance.
|
|
|
|
|
|
|
|
|
|
/api/v1/auth/passkeys/{passkeyID}:
|
|
|
|
|
parameters:
|
|
|
|
|
- { name: passkeyID, in: path, required: true, schema: { type: string } }
|
|
|
|
|
patch:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: Rename a key.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
name: { type: string }
|
|
|
|
|
responses:
|
|
|
|
|
"204": { description: Renamed. }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
delete:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: Remove a key.
|
|
|
|
|
description: |
|
|
|
|
|
Scoped in the SQL rather than checked first: an id read in one step and
|
|
|
|
|
deleted in the next is a window, and the reason somebody is on this screen
|
|
|
|
|
is usually that they think a device is gone.
|
|
|
|
|
responses:
|
|
|
|
|
"204": { description: Removed. }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
|
|
|
|
|
/api/v1/auth/passkey/login/begin:
|
|
|
|
|
post:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: Start signing in with a key. No session, and no email.
|
|
|
|
|
description: |
|
|
|
|
|
A discoverable login: nobody has said who they are yet, and that is the
|
|
|
|
|
point. The device knows which account its key belongs to, so there is no
|
|
|
|
|
address to type and no list of accounts for anybody to probe.
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: Pass `options` to `navigator.credentials.get()`.
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
challenge_id: { type: string }
|
|
|
|
|
options: { type: object, additionalProperties: true }
|
|
|
|
|
"429": { $ref: "#/components/responses/RateLimited" }
|
|
|
|
|
"503":
|
|
|
|
|
description: Passkeys are unavailable on this instance.
|
|
|
|
|
|
|
|
|
|
/api/v1/auth/passkey/login/finish:
|
|
|
|
|
post:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: Finish signing in with a key.
|
|
|
|
|
description: |
|
|
|
|
|
A key that verified its owner — a PIN, a fingerprint, a face — is both
|
|
|
|
|
factors at once, and completes the login outright. One that only proved
|
|
|
|
|
possession still stops at the TOTP step, exactly as a password does.
|
|
|
|
|
|
|
|
|
|
A counter that has gone backwards means the credential has been cloned. It
|
|
|
|
|
is refused rather than logged: that counter is the only signal this design
|
|
|
|
|
gives.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
challenge_id: { type: string }
|
|
|
|
|
credential:
|
|
|
|
|
type: object
|
|
|
|
|
additionalProperties: true
|
|
|
|
|
description: The response from `navigator.credentials.get()`.
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: Signed in, or `totp_required` when the key was possession only.
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
user: { $ref: "#/components/schemas/User" }
|
|
|
|
|
totp_required: { type: boolean }
|
|
|
|
|
"401":
|
|
|
|
|
description: |
|
|
|
|
|
One message for an unknown key and for a bad signature. Telling them
|
|
|
|
|
apart is a way to learn which credentials exist here.
|
|
|
|
|
"429": { $ref: "#/components/responses/RateLimited" }
|
|
|
|
|
"503":
|
|
|
|
|
description: Passkeys are unavailable on this instance.
|
|
|
|
|
|
2026-08-17 19:43:49 +02:00
|
|
|
/api/v1/auth/sessions:
|
|
|
|
|
get:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: Where you are signed in.
|
|
|
|
|
description: |
|
|
|
|
|
Most recently used first. `id` is the stored hash of the cookie and never
|
|
|
|
|
the cookie itself — it cannot be pasted into a browser, and it is what the
|
|
|
|
|
delete below takes.
|
|
|
|
|
|
|
|
|
|
Session only: an API token cannot list the devices of the person it was
|
|
|
|
|
stolen from.
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
sessions:
|
|
|
|
|
type: array
|
|
|
|
|
items: { $ref: "#/components/schemas/Session" }
|
|
|
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
|
|
|
|
|
|
|
|
/api/v1/auth/sessions/{sessionID}:
|
|
|
|
|
parameters:
|
|
|
|
|
- { name: sessionID, in: path, required: true, schema: { type: string } }
|
|
|
|
|
delete:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: Sign one device out.
|
|
|
|
|
description: |
|
|
|
|
|
Ending the current session is allowed and is simply logging out —
|
|
|
|
|
somebody clearing every device wants this one gone too.
|
|
|
|
|
responses:
|
|
|
|
|
"204": { description: Ended. }
|
|
|
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
|
2026-08-17 10:40:54 +02:00
|
|
|
/api/v1/auth/recovery-codes:
|
|
|
|
|
get:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: How many recovery codes are left.
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
remaining: { type: integer }
|
|
|
|
|
post:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: Issue a fresh set and retire the old one.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
required: [password]
|
|
|
|
|
properties:
|
|
|
|
|
password: { type: string }
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/RecoveryCodes" }
|
|
|
|
|
"409": { $ref: "#/components/responses/Conflict" }
|
|
|
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
|
|
|
|
|
|
# --- tasks ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/api/v1/tasks:
|
|
|
|
|
get:
|
|
|
|
|
tags: [tasks]
|
|
|
|
|
summary: List tasks.
|
|
|
|
|
parameters:
|
|
|
|
|
- { name: project_id, in: query, schema: { type: string } }
|
|
|
|
|
- { name: section_id, in: query, schema: { type: string } }
|
|
|
|
|
- { name: parent_id, in: query, schema: { type: string }, description: Sub-tasks of one task. }
|
|
|
|
|
- { name: label_id, in: query, schema: { type: string } }
|
|
|
|
|
- { name: priority, in: query, schema: { type: integer, minimum: 1, maximum: 4 } }
|
|
|
|
|
- { name: assignee_id, in: query, schema: { type: string }, description: "`me` resolves to the caller." }
|
|
|
|
|
- { name: due_before, in: query, schema: { type: string, format: date } }
|
|
|
|
|
- { name: due_from, in: query, schema: { type: string, format: date } }
|
|
|
|
|
- { name: no_date, in: query, schema: { type: boolean } }
|
|
|
|
|
- { name: q, in: query, schema: { type: string }, description: "Full-text search, diacritic-insensitive for Danish." }
|
|
|
|
|
- name: completed
|
|
|
|
|
in: query
|
|
|
|
|
schema: { type: string, enum: [include, only] }
|
|
|
|
|
description: Omitted, only open tasks are returned.
|
|
|
|
|
- { name: limit, in: query, schema: { type: integer } }
|
|
|
|
|
responses:
|
|
|
|
|
"200": { $ref: "#/components/responses/TaskList" }
|
|
|
|
|
post:
|
|
|
|
|
tags: [tasks]
|
|
|
|
|
summary: Create a task.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/TaskInput" }
|
|
|
|
|
responses:
|
|
|
|
|
"201": { $ref: "#/components/responses/Task" }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
|
|
|
|
|
|
/api/v1/tasks/{taskID}:
|
|
|
|
|
parameters:
|
|
|
|
|
- { name: taskID, in: path, required: true, schema: { type: string } }
|
|
|
|
|
get:
|
|
|
|
|
tags: [tasks]
|
|
|
|
|
summary: One task.
|
|
|
|
|
responses:
|
|
|
|
|
"200": { $ref: "#/components/responses/Task" }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
patch:
|
|
|
|
|
tags: [tasks]
|
|
|
|
|
summary: Change a task.
|
|
|
|
|
description: Only the fields present are changed.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/TaskInput" }
|
|
|
|
|
responses:
|
|
|
|
|
"200": { $ref: "#/components/responses/Task" }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
|
delete:
|
|
|
|
|
tags: [tasks]
|
|
|
|
|
summary: Delete a task.
|
|
|
|
|
description: Soft-deleted, and purged from the trash after 30 days.
|
|
|
|
|
responses:
|
|
|
|
|
"204": { description: Deleted. }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
|
|
|
|
|
/api/v1/tasks/{taskID}/complete:
|
|
|
|
|
parameters:
|
|
|
|
|
- { name: taskID, in: path, required: true, schema: { type: string } }
|
|
|
|
|
post:
|
|
|
|
|
tags: [tasks]
|
|
|
|
|
summary: Tick a task off.
|
|
|
|
|
description: |
|
|
|
|
|
A repeating task is not closed: it moves forward in the same row, keeping
|
|
|
|
|
its id, so sub-tasks and comments stay attached. `recurred` and `next_due`
|
|
|
|
|
say when that happened.
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
allOf:
|
|
|
|
|
- $ref: "#/components/schemas/Task"
|
|
|
|
|
- type: object
|
|
|
|
|
properties:
|
|
|
|
|
recurred: { type: boolean }
|
|
|
|
|
next_due: { type: string, format: date }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
|
|
|
|
|
/api/v1/tasks/{taskID}/reopen:
|
|
|
|
|
parameters:
|
|
|
|
|
- { name: taskID, in: path, required: true, schema: { type: string } }
|
|
|
|
|
post:
|
|
|
|
|
tags: [tasks]
|
|
|
|
|
summary: Un-tick a task.
|
|
|
|
|
responses:
|
|
|
|
|
"200": { $ref: "#/components/responses/Task" }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
|
|
|
|
|
/api/v1/tasks/{taskID}/move:
|
|
|
|
|
parameters:
|
|
|
|
|
- { name: taskID, in: path, required: true, schema: { type: string } }
|
|
|
|
|
post:
|
|
|
|
|
tags: [tasks]
|
|
|
|
|
summary: Move a task, and place it between two others.
|
|
|
|
|
description: |
|
|
|
|
|
`after_id` and `before_id` are the tasks either side of the gap it lands
|
|
|
|
|
in. Give both to place between them, one to place at an end, neither to
|
|
|
|
|
place first.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
project_id: { type: string, description: "Omitted, the task stays in its project." }
|
|
|
|
|
section_id: { type: string, description: Empty string means no section. }
|
|
|
|
|
after_id: { type: string }
|
|
|
|
|
before_id: { type: string }
|
|
|
|
|
responses:
|
|
|
|
|
"200": { $ref: "#/components/responses/Task" }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
|
|
|
|
|
/api/v1/tasks/quick-add:
|
|
|
|
|
post:
|
|
|
|
|
tags: [tasks]
|
|
|
|
|
summary: Create a task from one line of natural language.
|
|
|
|
|
description: |
|
|
|
|
|
Danish and English. `betal moms i morgen kl 10 p1 #Firma @vigtigt` becomes
|
|
|
|
|
a task due tomorrow at 10:00, priority 1, in the project Firma, labelled
|
|
|
|
|
vigtigt. An unknown `#project` is not an error — the task goes to the
|
|
|
|
|
Inbox rather than a thought being thrown away over a typo.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
required: [text]
|
|
|
|
|
properties:
|
|
|
|
|
text: { type: string }
|
|
|
|
|
project_id:
|
|
|
|
|
type: string
|
|
|
|
|
description: Where it goes when the text names no project.
|
|
|
|
|
responses:
|
2026-08-17 16:22:36 +02:00
|
|
|
"201":
|
|
|
|
|
description: Created
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/QuickAddResult" }
|
2026-08-17 10:40:54 +02:00
|
|
|
"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" }
|
|
|
|
|
|
2026-08-17 15:49:52 +02:00
|
|
|
/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" }
|
|
|
|
|
|
2026-08-17 10:40:54 +02:00
|
|
|
/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" }
|
|
|
|
|
|
2026-08-18 09:05:36 +02:00
|
|
|
/api/v1/activity:
|
|
|
|
|
get:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: What has been done on this instance. Administrators only.
|
|
|
|
|
description: |
|
|
|
|
|
The same rows as `/projects/{projectID}/activity`, read across every
|
|
|
|
|
project instead of one at a time.
|
|
|
|
|
|
|
|
|
|
The per-project history is a question a project's members ask about their
|
|
|
|
|
own work. This is a different question — "what has happened on this
|
|
|
|
|
server" — and nothing could answer it: the per-project endpoint takes one
|
|
|
|
|
project id, and an administrator is not necessarily a member of the
|
|
|
|
|
project they need to look into.
|
|
|
|
|
|
|
|
|
|
It is the other half of `/errors`. That one is what broke; this is what was
|
|
|
|
|
done.
|
|
|
|
|
|
|
|
|
|
Rows outlive the accounts that wrote them. `activity.user_id` is
|
|
|
|
|
`ON DELETE SET NULL`, so a deleted account leaves its trail behind with
|
|
|
|
|
`user_id` and `user_name` absent — a log that lost those rows would be
|
|
|
|
|
missing the part most worth keeping.
|
|
|
|
|
|
|
|
|
|
Paged with a keyset cursor rather than an offset. The log is written to
|
|
|
|
|
constantly and read rarely, so a page fetched a minute after the one
|
|
|
|
|
before it would be shifted by everything written in between: a row shown
|
|
|
|
|
twice and the row behind it skipped.
|
|
|
|
|
parameters:
|
|
|
|
|
- { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
|
|
|
|
|
- name: before
|
|
|
|
|
in: query
|
|
|
|
|
schema: { type: string }
|
|
|
|
|
description: |
|
|
|
|
|
`next_cursor` from the previous page, as `<unix>:<id>`. Both halves
|
|
|
|
|
are required: the log is written in whole seconds, so a timestamp
|
|
|
|
|
alone cannot separate two rows written in the same one.
|
|
|
|
|
example: "1755432000:01J8Z9"
|
|
|
|
|
- { name: user_id, in: query, schema: { type: string } }
|
|
|
|
|
- { name: project_id, in: query, schema: { type: string } }
|
|
|
|
|
- name: event
|
|
|
|
|
in: query
|
|
|
|
|
schema: { type: string }
|
|
|
|
|
description: An exact event name, as listed by `/activity/events`.
|
|
|
|
|
example: task.completed
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
activity:
|
|
|
|
|
type: array
|
|
|
|
|
items:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
id: { type: string }
|
|
|
|
|
at: { type: string, format: date-time }
|
|
|
|
|
project_id: { type: string }
|
|
|
|
|
project_name: { type: string }
|
|
|
|
|
task_id: { type: string }
|
|
|
|
|
user_id:
|
|
|
|
|
type: string
|
|
|
|
|
description: Absent when the account has been deleted.
|
|
|
|
|
user_name:
|
|
|
|
|
type: string
|
|
|
|
|
description: Absent when the account has been deleted.
|
|
|
|
|
event: { type: string, example: task.completed }
|
|
|
|
|
payload: { type: object, additionalProperties: true }
|
|
|
|
|
next_cursor:
|
|
|
|
|
type: string
|
|
|
|
|
description: |
|
|
|
|
|
Absent on the last page. Present only when the page came
|
|
|
|
|
back full — a short page is the end of the log, and a
|
|
|
|
|
cursor there invites one more request that can only come
|
|
|
|
|
back empty.
|
|
|
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
|
|
|
|
|
|
|
|
/api/v1/activity/events:
|
|
|
|
|
get:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: The event names the log contains, with counts. Administrators only.
|
|
|
|
|
description: |
|
|
|
|
|
What the `event` filter is drawn from, most frequent first.
|
|
|
|
|
|
|
|
|
|
Offered rather than typed. Event names are an internal vocabulary —
|
|
|
|
|
`member.role_changed`, not a sentence anybody would guess — so a text
|
|
|
|
|
field for them is a field that returns nothing until you have read the
|
|
|
|
|
source. The counts are worth having on their own: they are the shape of
|
|
|
|
|
what the instance does.
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
events:
|
|
|
|
|
type: array
|
|
|
|
|
items:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
event: { type: string, example: task.completed }
|
|
|
|
|
count: { type: integer }
|
|
|
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
|
|
|
|
|
|
|
|
/api/v1/gmail/client:
|
|
|
|
|
get:
|
|
|
|
|
tags: [integrations]
|
|
|
|
|
summary: The Gmail OAuth client this instance uses. Administrators only.
|
|
|
|
|
description: |
|
|
|
|
|
The registration Google requires, enterable rather than deployed.
|
|
|
|
|
|
|
|
|
|
The one click that opens Google's consent screen already exists. What it
|
|
|
|
|
needs behind it is a registered client, and no app can conjure one: Google
|
|
|
|
|
issues no Gmail access to an unregistered client, and `gmail.readonly` is a
|
|
|
|
|
restricted scope, so an id shipped inside a public image would be both
|
|
|
|
|
extractable from it and unusable without Google's security review of every
|
|
|
|
|
deployment that used it.
|
|
|
|
|
|
|
|
|
|
The registration is a one-off. Having to edit a Rune's manifest and
|
|
|
|
|
recreate the container to paste the result was not, which is what this
|
|
|
|
|
replaces.
|
|
|
|
|
|
|
|
|
|
`VERDANDE_GMAIL_CLIENT_ID` and `_SECRET` still win when they are set. A
|
|
|
|
|
value in the environment is a deliberate deployment decision — under
|
|
|
|
|
version control, reapplied on every recreate — and a form that could
|
|
|
|
|
quietly override it would make the manifest a lie. `from_env` says so, and
|
|
|
|
|
`PUT` answers 409.
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/GmailClient" }
|
|
|
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
|
|
|
put:
|
|
|
|
|
tags: [integrations]
|
|
|
|
|
summary: Set the Gmail OAuth client. Administrators only.
|
|
|
|
|
description: |
|
|
|
|
|
An empty `client_secret` means "leave the stored one alone", not "delete
|
|
|
|
|
it" — otherwise correcting a typo in the id would silently clear the
|
|
|
|
|
secret. The secret is never read back, for the same reason the AI key is
|
|
|
|
|
not: a settings page that repopulates a password field is one that will
|
|
|
|
|
eventually leak into a screenshot.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
client_id: { type: string }
|
|
|
|
|
client_secret: { type: string }
|
|
|
|
|
responses:
|
|
|
|
|
"204": { description: Saved. }
|
|
|
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
|
|
|
"409":
|
|
|
|
|
description: The environment sets it, so this cannot.
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
|
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
|
|
|
|
|
|
/api/v1/backups:
|
|
|
|
|
get:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: The nightly backups. Administrators only.
|
|
|
|
|
description: |
|
|
|
|
|
The backup job has run since the beginning and nothing showed it. A backup
|
|
|
|
|
nobody can see is a backup nobody knows has been failing, and the failure
|
|
|
|
|
is quiet: a volume that went read-only, a disk that filled, a container
|
|
|
|
|
that has not been up at midnight all week. The first anybody hears of it
|
|
|
|
|
is the day they need one.
|
|
|
|
|
|
|
|
|
|
The most recent fourteen files are kept — counted by file rather than by
|
|
|
|
|
age, so a container that was off for a month does not come back and delete
|
|
|
|
|
every backup for being too old. Rows older than that stay in the list with
|
|
|
|
|
`present: false`: a true record of a backup that was made and a file that
|
|
|
|
|
is no longer there.
|
|
|
|
|
|
|
|
|
|
Sessions-only as well as administrators-only. A backup is a complete copy
|
|
|
|
|
of the database, so this is everybody's data in one file.
|
|
|
|
|
parameters:
|
|
|
|
|
- { name: limit, in: query, schema: { type: integer, default: 30, maximum: 100 } }
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
backups:
|
|
|
|
|
type: array
|
|
|
|
|
items: { $ref: "#/components/schemas/BackupRun" }
|
|
|
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
|
|
|
post:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: Take a backup now. Administrators only.
|
|
|
|
|
description: |
|
|
|
|
|
The same work the nightly job does, without its "one a day" check.
|
|
|
|
|
|
|
|
|
|
Two reasons it exists: waiting until tonight to find out whether backups
|
|
|
|
|
work at all is how somebody finds out on the day they need one, and it is
|
|
|
|
|
the only way to get a snapshot from *before* something you are about to
|
|
|
|
|
do.
|
|
|
|
|
|
|
|
|
|
Synchronous — a homelab database is a second or two, and a button that
|
|
|
|
|
returns immediately and says nothing is a button people press twice.
|
|
|
|
|
responses:
|
|
|
|
|
"201":
|
|
|
|
|
description: Written.
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/BackupRun" }
|
|
|
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
|
|
|
"500":
|
|
|
|
|
description: |
|
|
|
|
|
The snapshot failed — a full volume, or one that has gone read-only,
|
|
|
|
|
which is precisely the condition this button exists to surface.
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
|
|
|
|
|
|
|
|
/api/v1/backups/{backupID}:
|
|
|
|
|
parameters:
|
|
|
|
|
- { name: backupID, in: path, required: true, schema: { type: string } }
|
|
|
|
|
get:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: Download one backup file. Administrators only.
|
|
|
|
|
description: |
|
|
|
|
|
A SQLite database, served as `application/octet-stream` with
|
|
|
|
|
`Content-Disposition: attachment` — nothing should ever be tempted to
|
|
|
|
|
render it inline.
|
|
|
|
|
|
|
|
|
|
Restoring is deliberately not an API call. Putting a database back under a
|
|
|
|
|
running server means stopping every writer, swapping the file and
|
|
|
|
|
reopening, and a half-done version of that corrupts the thing it was
|
|
|
|
|
restoring. Stop the container, put the file in place of `verdande.db` in
|
|
|
|
|
the data volume, delete the `-wal` and `-shm` beside it, and start it
|
|
|
|
|
again.
|
|
|
|
|
|
|
|
|
|
A row whose file has been rotated away answers 404. That is the honest
|
|
|
|
|
answer to "send me that one".
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: The database file.
|
|
|
|
|
content:
|
|
|
|
|
application/octet-stream:
|
|
|
|
|
schema: { type: string, format: binary }
|
|
|
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
|
2026-08-18 12:18:35 +02:00
|
|
|
/api/v1/panel:
|
|
|
|
|
get:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: Whether this instance can restart itself. Administrators only.
|
|
|
|
|
description: |
|
|
|
|
|
A container cannot replace its own image from the inside, so "update" is
|
|
|
|
|
really "ask whoever owns the container to recreate it" — Yggdrasil pulls
|
|
|
|
|
`:latest` on the way, which is what makes a restart an update at all.
|
|
|
|
|
|
|
|
|
|
This says whether the three settings are present. The token is never sent:
|
|
|
|
|
the page needs to know whether the button can work, not what the credential
|
|
|
|
|
is. When it cannot, the missing variables are named — an operator reading
|
|
|
|
|
"not configured" should not have to go and find out which of three.
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
configured: { type: boolean }
|
|
|
|
|
panel_url: { type: string }
|
|
|
|
|
missing:
|
|
|
|
|
type: array
|
|
|
|
|
items: { type: string }
|
|
|
|
|
description: The environment variables that are not set.
|
|
|
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
|
|
|
|
2026-08-20 11:03:02 +02:00
|
|
|
/api/v1/notes/bulk:
|
|
|
|
|
post:
|
|
|
|
|
tags: [notes]
|
|
|
|
|
summary: Archive, unarchive or delete a set of notes in one call.
|
|
|
|
|
description: |
|
|
|
|
|
One call rather than one per note, because the thing being asked is one
|
|
|
|
|
thing: "put these away". Fifty separate requests would be fifty round
|
|
|
|
|
trips, fifty chances for half of them to fail, and a list that redraws
|
|
|
|
|
fifty times — and after an import of twelve hundred notes, selecting fifty
|
|
|
|
|
is the ordinary case rather than the extreme one.
|
|
|
|
|
|
|
|
|
|
Each note is still checked on its own. A set is a convenience for the
|
|
|
|
|
caller and never a way to touch something they could not have touched one
|
|
|
|
|
at a time; anything they may not write to is counted in `skipped` rather
|
|
|
|
|
than failing the whole call, so one note somebody else just deleted does
|
|
|
|
|
not stop the other forty-nine.
|
|
|
|
|
|
|
|
|
|
Archiving is not deleting. The trash says "this was a mistake, and in
|
|
|
|
|
thirty days it is gone"; the archive says "this is finished, and I want to
|
|
|
|
|
keep it".
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
required: [ids]
|
|
|
|
|
properties:
|
|
|
|
|
ids:
|
|
|
|
|
type: array
|
|
|
|
|
items: { type: string }
|
|
|
|
|
description: At most 500.
|
|
|
|
|
archived:
|
|
|
|
|
type: boolean
|
|
|
|
|
description: Put away, or bring back out.
|
|
|
|
|
delete:
|
|
|
|
|
type: boolean
|
|
|
|
|
description: Into the trash instead. Wins over `archived`.
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: How many were changed, and how many were passed over.
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
done: { type: integer }
|
|
|
|
|
skipped: { type: integer }
|
|
|
|
|
"422":
|
|
|
|
|
description: No ids, too many, or neither archived nor delete was asked for.
|
|
|
|
|
|
2026-08-20 07:32:03 +02:00
|
|
|
/api/v1/notes/{noteID}/attachments:
|
|
|
|
|
post:
|
|
|
|
|
tags: [notes]
|
|
|
|
|
summary: Put a file into a note.
|
|
|
|
|
description: |
|
|
|
|
|
A picture pasted or dropped into a note. Until this existed the import
|
|
|
|
|
could bring files in and nothing else could, so the most ordinary act in
|
|
|
|
|
a notes app — paste a screenshot — did nothing at all.
|
|
|
|
|
|
|
|
|
|
Editor rights on the note. The same answer for "no such note" and "not
|
|
|
|
|
yours", so an id cannot be probed.
|
|
|
|
|
|
|
|
|
|
The response carries the attachment's URL; the editor writes that into the
|
|
|
|
|
note's text as a Markdown image. The file is never inlined as a data URL —
|
|
|
|
|
a photograph as base64 would make the note itself megabytes of text.
|
|
|
|
|
parameters:
|
|
|
|
|
- name: noteID
|
|
|
|
|
in: path
|
|
|
|
|
required: true
|
|
|
|
|
schema: { type: string }
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
multipart/form-data:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
required: [file]
|
|
|
|
|
properties:
|
|
|
|
|
file:
|
|
|
|
|
type: string
|
|
|
|
|
format: binary
|
|
|
|
|
responses:
|
|
|
|
|
"201":
|
|
|
|
|
description: Stored, and hung on the note.
|
|
|
|
|
"404":
|
|
|
|
|
description: No such note, or not yours to write in.
|
|
|
|
|
"413":
|
|
|
|
|
description: Larger than 25 MB.
|
|
|
|
|
|
2026-08-20 06:59:50 +02:00
|
|
|
/api/v1/beacon:
|
|
|
|
|
post:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: Report that an installation exists. Unauthenticated.
|
|
|
|
|
description: |
|
|
|
|
|
The receiving end of the beacon. Open on purpose: the caller is a
|
|
|
|
|
stranger's installation, which is the whole feature.
|
|
|
|
|
|
|
|
|
|
Answers 404 unless this instance has been told it is the collector, so an
|
|
|
|
|
ordinary installation is not a spam sink that answers 200 to anything.
|
|
|
|
|
Rate limited like the login routes, and the table behind it has a ceiling:
|
|
|
|
|
the endpoint inserts a row keyed on an id the caller invented, so without
|
|
|
|
|
one anybody could grow it until the volume is full.
|
|
|
|
|
|
|
|
|
|
Nothing about the request is recorded but the two values in the body. No
|
|
|
|
|
source address is read or written here.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
required: [instance_id]
|
|
|
|
|
properties:
|
|
|
|
|
instance_id:
|
|
|
|
|
type: string
|
|
|
|
|
description: A random id the installation generated for itself.
|
|
|
|
|
version:
|
|
|
|
|
type: string
|
|
|
|
|
responses:
|
|
|
|
|
"204":
|
|
|
|
|
description: Recorded.
|
|
|
|
|
"400":
|
|
|
|
|
description: The id or the version is not the expected shape.
|
|
|
|
|
"404":
|
|
|
|
|
description: This instance is not a collector.
|
|
|
|
|
|
|
|
|
|
/api/v1/beacon/count:
|
|
|
|
|
get:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: How many installations reported in the last thirty days.
|
|
|
|
|
description: |
|
|
|
|
|
Unauthenticated and cached for a minute, and only answered when the
|
|
|
|
|
collector has been told to publish it.
|
|
|
|
|
|
|
|
|
|
A window rather than a running total: a total only climbs, so it stops
|
|
|
|
|
meaning "how many people use this" and starts meaning "how many ever
|
|
|
|
|
tried it".
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: The count.
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
installs: { type: integer }
|
|
|
|
|
window_days: { type: integer }
|
|
|
|
|
"404":
|
|
|
|
|
description: Not a collector, or not published.
|
|
|
|
|
|
|
|
|
|
/api/v1/beacon/settings:
|
|
|
|
|
get:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: What this instance reports, and to whom. Administrators only.
|
|
|
|
|
description: |
|
|
|
|
|
Includes the exact payload that will be sent, so the settings page can
|
|
|
|
|
print it in full rather than describe it.
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: The current configuration, and the counts if this is the collector.
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
enabled: { type: boolean }
|
|
|
|
|
collector_url: { type: string }
|
|
|
|
|
instance_id: { type: string }
|
|
|
|
|
version: { type: string }
|
|
|
|
|
is_collector: { type: boolean }
|
|
|
|
|
publish_count: { type: boolean }
|
|
|
|
|
last_ping_at: { type: string, format: date-time }
|
|
|
|
|
put:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: Turn the beacon off, or point it somewhere else. Administrators only.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
enabled: { type: boolean }
|
|
|
|
|
collector_url:
|
|
|
|
|
type: string
|
|
|
|
|
description: |
|
|
|
|
|
Where reports go. Fetched through a dialer that refuses
|
|
|
|
|
loopback and private address space, so this cannot be used to
|
|
|
|
|
make the server reach inside its own network.
|
|
|
|
|
is_collector: { type: boolean }
|
|
|
|
|
publish_count: { type: boolean }
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: The configuration as it now stands.
|
|
|
|
|
"422":
|
|
|
|
|
description: The collector address is not an http or https URL.
|
|
|
|
|
|
2026-08-18 12:18:35 +02:00
|
|
|
/api/v1/panel/restart:
|
|
|
|
|
post:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: Ask the panel to recreate this container. Administrators only.
|
|
|
|
|
description: |
|
|
|
|
|
The panel does the work and records who asked; this removes the second
|
|
|
|
|
browser tab, not the panel's authority.
|
|
|
|
|
|
|
|
|
|
Answers 202 rather than waiting: the panel stops this container as part of
|
|
|
|
|
answering, so the reply may never arrive. A request cut off mid-flight is
|
|
|
|
|
the successful case wearing a failure's clothes — the interface polls
|
|
|
|
|
`/healthz` rather than believing either answer.
|
|
|
|
|
|
|
|
|
|
Sessions only, like the rest of this group. A leaked API token that could
|
|
|
|
|
restart the server is a denial of service in one request.
|
|
|
|
|
responses:
|
|
|
|
|
"202":
|
|
|
|
|
description: The panel accepted, or was already restarting this container.
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
restarting: { type: boolean }
|
|
|
|
|
note: { type: string }
|
|
|
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
|
|
|
"502":
|
|
|
|
|
description: |
|
|
|
|
|
The panel refused. Usually a token without control of this server.
|
|
|
|
|
"503":
|
|
|
|
|
description: The three settings are not all present.
|
|
|
|
|
|
2026-08-17 22:58:02 +02:00
|
|
|
/api/v1/errors:
|
|
|
|
|
get:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: What has gone wrong lately. Administrators only.
|
|
|
|
|
description: |
|
|
|
|
|
Every 500 the API has answered, newest first, with what the handler was
|
|
|
|
|
attempting and the error it hit.
|
|
|
|
|
|
|
|
|
|
These are written to the database rather than only to the log because the
|
|
|
|
|
log does not survive. On a Rune the log belongs to the container, and the
|
|
|
|
|
container is replaced on every restart — so a panel watcher reports "HTTP
|
|
|
|
|
5xx, twice, at 11:49" long after the line explaining it has gone, which
|
|
|
|
|
tells an operator that something broke and gives them no way to find out
|
|
|
|
|
what.
|
|
|
|
|
|
|
|
|
|
Kept for thirty days by the nightly sweep: long enough to answer "what
|
|
|
|
|
happened last week", short enough that a fault loop cannot fill the volume
|
|
|
|
|
the tasks live on.
|
|
|
|
|
parameters:
|
|
|
|
|
- { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
errors:
|
|
|
|
|
type: array
|
|
|
|
|
items:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
id: { type: string }
|
|
|
|
|
at: { type: string, format: date-time }
|
|
|
|
|
method: { type: string }
|
|
|
|
|
path: { type: string }
|
|
|
|
|
status: { type: integer }
|
|
|
|
|
what:
|
|
|
|
|
type: string
|
|
|
|
|
description: What the handler was attempting, in its own words.
|
|
|
|
|
example: list projects
|
|
|
|
|
message: { type: string }
|
|
|
|
|
user_name:
|
|
|
|
|
type: string
|
|
|
|
|
description: |
|
|
|
|
|
Who was asking, when there was somebody. A fault only
|
|
|
|
|
one account hits is a different problem from one
|
|
|
|
|
everybody hits.
|
|
|
|
|
request_id:
|
|
|
|
|
type: string
|
|
|
|
|
description: Ties the row back to the log line, for whoever still has it.
|
|
|
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
|
|
|
|
2026-08-17 21:10:46 +02:00
|
|
|
/api/v1/users:
|
|
|
|
|
get:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: Everybody on the instance, and the invites not yet accepted.
|
|
|
|
|
description: |
|
|
|
|
|
Administrators only. Each user carries what deleting them would destroy,
|
|
|
|
|
so the confirmation can name real numbers without a second request in the
|
|
|
|
|
middle of a decision.
|
|
|
|
|
|
|
|
|
|
Session only: an API token cannot enumerate the instance's membership.
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
users:
|
|
|
|
|
type: array
|
|
|
|
|
items: { $ref: "#/components/schemas/AdminUser" }
|
|
|
|
|
invites:
|
|
|
|
|
type: array
|
|
|
|
|
items: { $ref: "#/components/schemas/PendingInvite" }
|
|
|
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
|
|
|
post:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: Invite somebody to the instance.
|
|
|
|
|
description: |
|
|
|
|
|
There is no open registration and no account created with a password
|
|
|
|
|
somebody else chose: this issues an invite with no project attached, and
|
|
|
|
|
the link ends with a password only its owner has seen. The same link and
|
|
|
|
|
the same `/auth/signup` as a project invite — an invite whose project is
|
|
|
|
|
empty simply grants no membership when it is accepted.
|
|
|
|
|
|
|
|
|
|
No name is asked for. The signup form asks the person themselves, and a
|
|
|
|
|
name an administrator guesses is a name somebody has to correct.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
required: [email]
|
|
|
|
|
properties:
|
|
|
|
|
email: { type: string, format: email }
|
|
|
|
|
responses:
|
|
|
|
|
"201":
|
|
|
|
|
description: Created
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
link:
|
|
|
|
|
type: string
|
|
|
|
|
description: |
|
|
|
|
|
Returned so the interface can offer to copy it. On an
|
|
|
|
|
instance with no mail server that is the only way the invite
|
|
|
|
|
reaches anybody.
|
|
|
|
|
emailed: { type: boolean }
|
|
|
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
|
|
|
"409":
|
|
|
|
|
description: That address already has an account.
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
|
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
|
|
|
|
|
|
/api/v1/users/{userID}:
|
|
|
|
|
parameters:
|
|
|
|
|
- { name: userID, in: path, required: true, schema: { type: string } }
|
|
|
|
|
patch:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: Make somebody an administrator, or stop them being one.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
is_admin: { type: boolean }
|
|
|
|
|
responses:
|
|
|
|
|
"204": { description: Changed. }
|
|
|
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
"409":
|
|
|
|
|
description: |
|
|
|
|
|
The last administrator cannot be demoted — `code` is `last_admin`.
|
|
|
|
|
An instance with no administrator has no way back: there is no
|
|
|
|
|
console, and setup refuses to run once an account exists.
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
|
|
|
delete:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: Remove an account and everything the foreign keys take with it.
|
|
|
|
|
description: |
|
|
|
|
|
The only hard delete in this API. Everything else is soft-deleted with a
|
|
|
|
|
trash behind it; this is not recoverable.
|
|
|
|
|
|
2026-08-18 09:05:36 +02:00
|
|
|
What goes: their projects, and every task in those projects.
|
|
|
|
|
|
|
|
|
|
What stays: everything they wrote in **somebody else's** project.
|
|
|
|
|
`tasks.created_by` is `ON DELETE SET NULL`, so that work survives and
|
|
|
|
|
loses its author rather than disappearing with the account. Tasks merely
|
|
|
|
|
assigned to them are unassigned and survive by the same rule.
|
2026-08-17 21:10:46 +02:00
|
|
|
|
|
|
|
|
Refused for your own account, and for the last administrator.
|
|
|
|
|
responses:
|
|
|
|
|
"204": { description: Deleted. }
|
|
|
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
"409":
|
|
|
|
|
description: Your own account, or the last administrator.
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
|
|
|
|
|
|
|
|
/api/v1/invites/{inviteID}:
|
|
|
|
|
parameters:
|
|
|
|
|
- { name: inviteID, in: path, required: true, schema: { type: string } }
|
|
|
|
|
delete:
|
|
|
|
|
tags: [auth]
|
|
|
|
|
summary: Withdraw an invite that has not been accepted.
|
|
|
|
|
description: |
|
|
|
|
|
Only the token's hash is stored, so a link sent to the wrong address
|
|
|
|
|
cannot be looked up and resent — withdrawing it is the only way to stop
|
|
|
|
|
it working before it expires.
|
|
|
|
|
responses:
|
|
|
|
|
"204": { description: Withdrawn. }
|
|
|
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
/api/v1/project-groups:
|
|
|
|
|
get:
|
|
|
|
|
tags: [projects]
|
|
|
|
|
summary: Your foldable sidebar groups.
|
|
|
|
|
description: |
|
|
|
|
|
A group is a heading over some of your projects. It is yours rather than
|
|
|
|
|
the project's: it has no members and no roles, and a project shared with
|
|
|
|
|
you can be filed under one of your groups without the owner seeing it.
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
groups:
|
|
|
|
|
type: array
|
|
|
|
|
items: { $ref: "#/components/schemas/ProjectGroup" }
|
|
|
|
|
post:
|
|
|
|
|
tags: [projects]
|
|
|
|
|
summary: Make a group.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/ProjectGroupInput" }
|
|
|
|
|
responses:
|
|
|
|
|
"201": { $ref: "#/components/responses/ProjectGroup" }
|
|
|
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
|
|
|
|
|
|
/api/v1/project-groups/reorder:
|
|
|
|
|
post:
|
|
|
|
|
tags: [projects]
|
|
|
|
|
summary: Set the order groups appear in.
|
|
|
|
|
description: The whole list at once, for the same reasons as `/projects/reorder`.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
required: [ids]
|
|
|
|
|
properties:
|
|
|
|
|
ids:
|
|
|
|
|
type: array
|
|
|
|
|
maxItems: 500
|
|
|
|
|
items: { type: string }
|
|
|
|
|
description: Group ids, top to bottom.
|
|
|
|
|
responses:
|
|
|
|
|
"204": { description: Reordered. }
|
|
|
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
|
|
|
|
|
|
/api/v1/project-groups/{groupID}:
|
|
|
|
|
parameters:
|
|
|
|
|
- { name: groupID, in: path, required: true, schema: { type: string } }
|
2026-08-18 09:20:03 +02:00
|
|
|
get:
|
|
|
|
|
tags: [projects]
|
|
|
|
|
summary: The group as a place, with what is in it.
|
|
|
|
|
description: |
|
|
|
|
|
A heading can carry a name. A page carries what the group *is*: the
|
|
|
|
|
sentence saying why these projects are one body of work, the documents
|
|
|
|
|
that belong to all of them rather than to any one, and the projects
|
|
|
|
|
themselves.
|
|
|
|
|
|
|
|
|
|
The projects come with the group rather than being fetched per row — the
|
|
|
|
|
page is nothing without them, and two round trips to draw one list is one
|
|
|
|
|
too many.
|
|
|
|
|
|
|
|
|
|
A group that is not yours is not found. That is the rule for every route
|
|
|
|
|
here, and it is enforced by the lookup rather than by a separate check.
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
group: { $ref: "#/components/schemas/ProjectGroup" }
|
|
|
|
|
projects:
|
|
|
|
|
type: array
|
|
|
|
|
items: { $ref: "#/components/schemas/Project" }
|
|
|
|
|
attachments:
|
|
|
|
|
type: array
|
|
|
|
|
items: { $ref: "#/components/schemas/Attachment" }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
2026-08-17 19:25:10 +02:00
|
|
|
patch:
|
|
|
|
|
tags: [projects]
|
|
|
|
|
summary: Rename a group, or fold it.
|
|
|
|
|
description: |
|
|
|
|
|
`collapsed` is stored on the account rather than in the browser. The
|
|
|
|
|
sidebar's width is a property of the screen you are at; a folded group is
|
|
|
|
|
a statement about the group, and is true wherever you sign in.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/ProjectGroupInput" }
|
|
|
|
|
responses:
|
|
|
|
|
"200": { $ref: "#/components/responses/ProjectGroup" }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
|
delete:
|
|
|
|
|
tags: [projects]
|
|
|
|
|
summary: Delete a group.
|
|
|
|
|
description: |
|
|
|
|
|
The heading goes; the projects under it come back out as ungrouped. A
|
|
|
|
|
group holds no work of its own, so this is not soft-deleted — there is
|
|
|
|
|
nothing in it to recover.
|
|
|
|
|
responses:
|
|
|
|
|
"204": { description: Deleted. }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
|
2026-08-18 09:20:03 +02:00
|
|
|
/api/v1/project-groups/{groupID}/attachments:
|
|
|
|
|
parameters:
|
|
|
|
|
- { name: groupID, in: path, required: true, schema: { type: string } }
|
|
|
|
|
post:
|
|
|
|
|
tags: [projects]
|
|
|
|
|
summary: Put a document on the group itself.
|
|
|
|
|
description: |
|
|
|
|
|
A file that belongs to the whole group rather than to any one task in it —
|
|
|
|
|
the contract that governs all of "Arbejde", not a step in it.
|
|
|
|
|
|
|
|
|
|
The same storage as every other attachment: content-addressed by hash, so
|
|
|
|
|
the path never contains anything the uploader chose, and the same file
|
|
|
|
|
uploaded twice is stored once. 25 MB maximum.
|
|
|
|
|
|
|
|
|
|
Downloading and deleting go through `/attachments/{attachmentID}` like any
|
|
|
|
|
other. A group's attachment is reachable by its owner and nobody else,
|
|
|
|
|
because a group belongs to one person and is never shared.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
multipart/form-data:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
file: { type: string, format: binary }
|
|
|
|
|
responses:
|
|
|
|
|
"201":
|
|
|
|
|
description: Stored.
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/Attachment" }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
"413": { $ref: "#/components/responses/TooLarge" }
|
|
|
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
|
|
2026-08-17 14:59:51 +02:00
|
|
|
/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.
|
|
|
|
|
|
2026-08-17 10:40:54 +02:00
|
|
|
/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 } }
|
2026-08-17 22:14:33 +02:00
|
|
|
patch:
|
|
|
|
|
tags: [projects]
|
|
|
|
|
summary: Change what an existing member may do. The project's owner only.
|
|
|
|
|
description: |
|
|
|
|
|
Editor or viewer. An UPDATE against a membership that already exists —
|
|
|
|
|
somebody who is not a member is not silently made one, because that is an
|
|
|
|
|
invite and belongs to the invite flow.
|
|
|
|
|
|
|
|
|
|
Not the owner: ownership is transferred, not granted, and two owners is a
|
|
|
|
|
state with no way to settle a disagreement about who may remove whom. The
|
|
|
|
|
owner also has no membership row at all — ownership lives on the project —
|
|
|
|
|
so this answers 409 with the real reason rather than "not a member".
|
|
|
|
|
|
|
|
|
|
Correcting a role used to mean removing the person and inviting them
|
|
|
|
|
again, which also unassigns every task they were responsible for.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
required: [role]
|
|
|
|
|
properties:
|
|
|
|
|
role: { type: string, enum: [editor, viewer] }
|
|
|
|
|
responses:
|
|
|
|
|
"204": { description: Changed. }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
"409":
|
|
|
|
|
description: The owner's standing cannot be changed.
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
|
|
|
"422": { $ref: "#/components/responses/Validation" }
|
2026-08-17 10:40:54 +02:00
|
|
|
delete:
|
|
|
|
|
tags: [projects]
|
|
|
|
|
summary: Remove somebody from a project. Its owner only.
|
2026-08-17 22:14:33 +02:00
|
|
|
description: |
|
|
|
|
|
Their tasks are unassigned rather than left pointing at somebody who can
|
|
|
|
|
no longer see the project — which is why changing a role is a PATCH and
|
|
|
|
|
not a remove-and-reinvite.
|
2026-08-17 10:40:54 +02:00
|
|
|
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" }
|
|
|
|
|
|
2026-08-17 22:31:43 +02:00
|
|
|
/api/v1/people:
|
|
|
|
|
get:
|
|
|
|
|
tags: [views]
|
|
|
|
|
summary: Everybody you share a project with, and yourself.
|
|
|
|
|
description: |
|
|
|
|
|
What an interface needs to put a name and a face on an `assignee_id` — in
|
|
|
|
|
a task row, in a filter, anywhere a person appears beside work. One
|
|
|
|
|
request per session rather than a lookup per row, and per project members
|
|
|
|
|
would mean knowing which project every task belongs to first.
|
|
|
|
|
|
|
|
|
|
Deliberately not the instance's user list: that is
|
|
|
|
|
`/users`, administrators only. A task list has no business enumerating
|
|
|
|
|
everybody with an account here.
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
people:
|
|
|
|
|
type: array
|
|
|
|
|
items:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
id: { type: string }
|
|
|
|
|
name: { type: string }
|
|
|
|
|
avatar_color: { type: string }
|
|
|
|
|
|
2026-08-17 19:36:37 +02:00
|
|
|
/api/v1/delegated:
|
|
|
|
|
get:
|
|
|
|
|
tags: [views]
|
|
|
|
|
summary: What you are waiting on other people for.
|
|
|
|
|
description: |
|
|
|
|
|
Everything assigned to somebody who is not you, in the projects you can
|
|
|
|
|
see, grouped by the person it is assigned to.
|
|
|
|
|
|
|
|
|
|
Grouped rather than flat because the question is about people, and a
|
|
|
|
|
client grouping it itself would need every assignee's name — which it has
|
|
|
|
|
no way to ask for. The names come back with the tasks, so one request is
|
|
|
|
|
one screen.
|
|
|
|
|
parameters:
|
|
|
|
|
- { name: limit, in: query, schema: { type: integer, default: 200, maximum: 500 } }
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
people:
|
|
|
|
|
type: array
|
|
|
|
|
items:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
user_id: { type: string }
|
|
|
|
|
name: { type: string }
|
|
|
|
|
avatar_color: { type: string }
|
|
|
|
|
tasks:
|
|
|
|
|
type: array
|
|
|
|
|
items: { $ref: "#/components/schemas/Task" }
|
|
|
|
|
|
2026-08-17 10:40:54 +02:00
|
|
|
/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" }
|
|
|
|
|
|
2026-08-20 07:01:14 +02:00
|
|
|
/api/v1/calendar:
|
|
|
|
|
get:
|
|
|
|
|
tags: [integrations]
|
|
|
|
|
summary: The Google Calendar connection and the calendars in it.
|
|
|
|
|
description: |
|
|
|
|
|
Read-only. verdande shows a Google calendar beside its own dated tasks and
|
|
|
|
|
never writes one back — writing needs the `calendar.events` scope and an
|
|
|
|
|
answer to what happens when both sides moved the same meeting, which is a
|
|
|
|
|
synchronisation model rather than a scope.
|
|
|
|
|
|
|
|
|
|
The list of calendars is fetched from Google when it is empty, so an
|
|
|
|
|
account that connected during a hiccup corrects itself on the next look
|
|
|
|
|
rather than showing an empty panel for ever.
|
|
|
|
|
responses:
|
|
|
|
|
"200": { $ref: "#/components/responses/Calendar" }
|
|
|
|
|
delete:
|
|
|
|
|
tags: [integrations]
|
|
|
|
|
summary: Disconnect the calendar and forget its tokens.
|
|
|
|
|
description: >
|
|
|
|
|
The cached events go too. Unlike the tasks a mailbox made, none of this is
|
|
|
|
|
the person's own work — it is a copy of somebody else's calendar, and it
|
|
|
|
|
means nothing without the account.
|
|
|
|
|
responses:
|
|
|
|
|
"204": { description: Disconnected. }
|
|
|
|
|
|
|
|
|
|
/api/v1/calendar/calendars:
|
|
|
|
|
put:
|
|
|
|
|
tags: [integrations]
|
|
|
|
|
summary: Choose which calendars are drawn.
|
|
|
|
|
description: >
|
|
|
|
|
The whole set in one write rather than one flip at a time, for the reason
|
|
|
|
|
the projects' order is written the same way: the question is "these ones",
|
|
|
|
|
and a list built out of several requests can land half applied. A calendar
|
|
|
|
|
turned off has its cached events removed; they come back if it is turned
|
|
|
|
|
on again.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
shown:
|
|
|
|
|
type: array
|
|
|
|
|
items: { type: string }
|
|
|
|
|
responses:
|
|
|
|
|
"204": { description: Saved. }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
|
|
|
|
|
/api/v1/calendar/authorize:
|
|
|
|
|
post:
|
|
|
|
|
tags: [integrations]
|
|
|
|
|
summary: Begin the OAuth flow for a calendar.
|
|
|
|
|
description: |
|
|
|
|
|
The same registration Gmail signs in through, asking for
|
|
|
|
|
`calendar.readonly` and coming back to `/oauth/calendar/callback`. Google
|
|
|
|
|
issues a refresh token per authorisation, so connecting a calendar does not
|
|
|
|
|
disturb a working Gmail connection.
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
url: { type: string }
|
|
|
|
|
"409":
|
|
|
|
|
$ref: "#/components/responses/Conflict"
|
|
|
|
|
|
|
|
|
|
/api/v1/calendar/sync:
|
|
|
|
|
post:
|
|
|
|
|
tags: [integrations]
|
|
|
|
|
summary: Refresh the chosen calendars now.
|
|
|
|
|
description: >
|
|
|
|
|
Refreshes the list of calendars and replaces the cached window of events
|
|
|
|
|
for the ones that are shown. `partial` means the budget ran out before the
|
|
|
|
|
last calendar; what was read is kept, and the rest arrive on the next run.
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
events: { type: integer }
|
|
|
|
|
partial: { type: boolean }
|
|
|
|
|
"502": { $ref: "#/components/responses/Upstream" }
|
|
|
|
|
|
|
|
|
|
/api/v1/calendar/events:
|
|
|
|
|
get:
|
|
|
|
|
tags: [integrations]
|
|
|
|
|
summary: The events of the chosen calendars, between two days.
|
|
|
|
|
description: |
|
|
|
|
|
Both ends inclusive, and an event that merely overlaps the range comes
|
|
|
|
|
back — a grid asks which cells an event covers, not where it begins.
|
|
|
|
|
|
|
|
|
|
`from` and `to` in the answer are the window verdande holds a copy of,
|
|
|
|
|
which is not the window that was asked for. A grid paged past it gets no
|
|
|
|
|
events, and an empty answer with no explanation is a calendar quietly
|
|
|
|
|
claiming a day is clear.
|
|
|
|
|
parameters:
|
|
|
|
|
- { name: from, in: query, required: true, schema: { type: string, format: date } }
|
|
|
|
|
- { name: to, in: query, required: true, schema: { type: string, format: date } }
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
events:
|
|
|
|
|
type: array
|
|
|
|
|
items: { $ref: "#/components/schemas/CalendarEvent" }
|
|
|
|
|
from: { type: string, format: date }
|
|
|
|
|
to: { type: string, format: date }
|
|
|
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
|
|
2026-08-19 19:03:25 +02:00
|
|
|
/api/v1/export/notes.zip:
|
|
|
|
|
get:
|
|
|
|
|
tags: [notes]
|
|
|
|
|
summary: Every note as a Markdown file, in a zip.
|
|
|
|
|
description: >
|
|
|
|
|
One file per note, named after its title, containing exactly what is
|
|
|
|
|
stored. Nothing is converted on the way out — the note on disk is already
|
|
|
|
|
the file you would export — so it opens in Obsidian, in a text editor, or
|
|
|
|
|
in anything else that reads Markdown.
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: A zip archive.
|
|
|
|
|
content:
|
|
|
|
|
application/zip:
|
|
|
|
|
schema: { type: string, format: binary }
|
|
|
|
|
|
2026-08-19 13:00:41 +02:00
|
|
|
/api/v1/notes:
|
|
|
|
|
get:
|
|
|
|
|
tags: [notes]
|
|
|
|
|
summary: Notes — listed, narrowed to a project, or searched.
|
|
|
|
|
description: >
|
|
|
|
|
With no parameters: the notes filed under no project, which are the
|
|
|
|
|
person's own. With `project`: that project's, which needs read access to
|
|
|
|
|
it. With `q`: a full-text search across every note they can see, matching
|
|
|
|
|
Danish spelling either way round — "grøn" finds "gron" and the reverse.
|
|
|
|
|
parameters:
|
|
|
|
|
- name: q
|
|
|
|
|
in: query
|
|
|
|
|
schema: { type: string }
|
|
|
|
|
- name: project
|
|
|
|
|
in: query
|
|
|
|
|
schema: { type: string }
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
notes:
|
|
|
|
|
type: array
|
|
|
|
|
items: { $ref: "#/components/schemas/Note" }
|
|
|
|
|
post:
|
|
|
|
|
tags: [notes]
|
|
|
|
|
summary: Write a note.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/NoteInput" }
|
|
|
|
|
responses:
|
|
|
|
|
"201":
|
|
|
|
|
description: Written
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/Note" }
|
|
|
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
|
|
|
|
2026-08-19 19:12:54 +02:00
|
|
|
/api/v1/notes/import:
|
|
|
|
|
post:
|
|
|
|
|
tags: [notes]
|
|
|
|
|
summary: A zip of Markdown files becomes notes.
|
|
|
|
|
description: >
|
|
|
|
|
The mirror of the export, and the way in from anywhere else: a folder of
|
|
|
|
|
Markdown is what Obsidian, Bear, iA Writer and a shell script all produce.
|
|
|
|
|
Filenames are ignored — the title is the first line of the body, here as
|
|
|
|
|
everywhere, and taking it from the filename would be a second rule for the
|
|
|
|
|
same thing. Directories, macOS resource forks and anything that is not .md
|
|
|
|
|
are passed over.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
multipart/form-data:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
file: { type: string, format: binary }
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: Read
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
created: { type: integer }
|
|
|
|
|
skipped: { type: integer, description: Files that were not usable as a note. }
|
|
|
|
|
"413": { description: The archive is too large. }
|
|
|
|
|
|
2026-08-19 13:00:41 +02:00
|
|
|
/api/v1/notes/{noteID}:
|
|
|
|
|
parameters:
|
|
|
|
|
- name: noteID
|
|
|
|
|
in: path
|
|
|
|
|
required: true
|
|
|
|
|
schema: { type: string }
|
|
|
|
|
get:
|
|
|
|
|
tags: [notes]
|
|
|
|
|
summary: One note.
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/Note" }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
patch:
|
|
|
|
|
tags: [notes]
|
|
|
|
|
summary: Change a note. Every field is optional; what is sent is what changes.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/NoteInput" }
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/Note" }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
delete:
|
|
|
|
|
tags: [notes]
|
|
|
|
|
summary: Put a note in the trash. It can be brought back.
|
|
|
|
|
responses:
|
|
|
|
|
"204": { description: Deleted }
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
|
|
|
|
|
/api/v1/notes/linking/{kind}/{targetID}:
|
|
|
|
|
get:
|
|
|
|
|
tags: [notes]
|
|
|
|
|
summary: What has been written about this.
|
|
|
|
|
description: >
|
|
|
|
|
The backwards direction. A task's id gives every note that mentions it,
|
|
|
|
|
a project's name gives every note carrying that #tag.
|
|
|
|
|
parameters:
|
|
|
|
|
- name: kind
|
|
|
|
|
in: path
|
|
|
|
|
required: true
|
|
|
|
|
schema: { type: string, enum: [task, note, project] }
|
|
|
|
|
- name: targetID
|
|
|
|
|
in: path
|
|
|
|
|
required: true
|
|
|
|
|
schema: { type: string }
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
notes:
|
|
|
|
|
type: array
|
|
|
|
|
items: { $ref: "#/components/schemas/Note" }
|
|
|
|
|
"400": { $ref: "#/components/responses/BadRequest" }
|
|
|
|
|
|
2026-08-18 22:22:31 +02:00
|
|
|
/api/v1/mailboxes:
|
|
|
|
|
get:
|
|
|
|
|
tags: [integrations]
|
|
|
|
|
summary: The mailboxes this person has connected.
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
mailboxes:
|
|
|
|
|
type: array
|
|
|
|
|
items: { $ref: "#/components/schemas/Mailbox" }
|
|
|
|
|
post:
|
|
|
|
|
tags: [integrations]
|
|
|
|
|
summary: Connect a mailbox over IMAP.
|
|
|
|
|
description: >
|
|
|
|
|
The connection is tested before it is saved, so a mailbox that cannot be
|
|
|
|
|
read is refused here rather than failing quietly every ten minutes.
|
|
|
|
|
requestBody:
|
|
|
|
|
required: true
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
required: [host, username, password]
|
|
|
|
|
properties:
|
|
|
|
|
name: { type: string }
|
|
|
|
|
host: { type: string, example: "imap.mail.me.com:993" }
|
|
|
|
|
username: { type: string }
|
|
|
|
|
password: { type: string, description: "An app-specific password on every host worth naming. Stored sealed." }
|
|
|
|
|
folder: { type: string, default: INBOX }
|
|
|
|
|
responses:
|
|
|
|
|
"201":
|
|
|
|
|
description: Connected
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/Mailbox" }
|
|
|
|
|
"400": { $ref: "#/components/responses/BadRequest" }
|
|
|
|
|
"409": { description: That mailbox is already connected }
|
|
|
|
|
"422": { $ref: "#/components/responses/Upstream" }
|
|
|
|
|
|
|
|
|
|
/api/v1/mailboxes/{mailboxID}:
|
|
|
|
|
delete:
|
|
|
|
|
tags: [integrations]
|
|
|
|
|
summary: Disconnect a mailbox. The tasks it made stay.
|
|
|
|
|
parameters:
|
|
|
|
|
- name: mailboxID
|
|
|
|
|
in: path
|
|
|
|
|
required: true
|
|
|
|
|
schema: { type: string }
|
|
|
|
|
responses:
|
|
|
|
|
"204": { description: Disconnected }
|
|
|
|
|
|
|
|
|
|
/api/v1/mailboxes/{mailboxID}/sync:
|
|
|
|
|
post:
|
|
|
|
|
tags: [integrations]
|
|
|
|
|
summary: Read one mailbox now.
|
|
|
|
|
parameters:
|
|
|
|
|
- name: mailboxID
|
|
|
|
|
in: path
|
|
|
|
|
required: true
|
|
|
|
|
schema: { type: string }
|
|
|
|
|
responses:
|
|
|
|
|
"200":
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
created: { type: integer }
|
|
|
|
|
partial:
|
|
|
|
|
type: boolean
|
|
|
|
|
description: The run hit its time budget. What it made is kept.
|
|
|
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
|
"422": { $ref: "#/components/responses/Upstream" }
|
|
|
|
|
|
2026-08-17 10:40:54 +02:00
|
|
|
/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:
|
2026-08-17 14:33:00 +02:00
|
|
|
"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 }
|
2026-08-17 10:40:54 +02:00
|
|
|
"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." }
|
|
|
|
|
|
2026-08-20 07:01:14 +02:00
|
|
|
/oauth/calendar/callback:
|
|
|
|
|
get:
|
|
|
|
|
tags: [integrations]
|
|
|
|
|
summary: Where Google sends the browser after consent to a calendar.
|
|
|
|
|
description: |
|
|
|
|
|
A URI of its own rather than a reuse of Gmail's. One callback deciding from
|
|
|
|
|
stored state which of the two flows had come back would put the choice of
|
|
|
|
|
which token store to write behind a lookup — and when that goes wrong it
|
|
|
|
|
goes wrong by overwriting a working Gmail connection with calendar tokens.
|
|
|
|
|
|
|
|
|
|
Answers with a redirect to `/indstillinger/integrationer`, not with JSON.
|
|
|
|
|
An `org_internal` error here means the OAuth app is registered as Internal
|
|
|
|
|
and the account is outside the organisation.
|
|
|
|
|
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." }
|
|
|
|
|
|
2026-08-17 14:00:39 +02:00
|
|
|
/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" }
|
|
|
|
|
|
2026-08-17 10:40:54 +02:00
|
|
|
/.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" }
|
2026-08-17 19:25:10 +02:00
|
|
|
ProjectGroup:
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema: { $ref: "#/components/schemas/ProjectGroup" }
|
2026-08-17 10:40:54 +02:00
|
|
|
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 }
|
2026-08-17 14:32:42 +02:00
|
|
|
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.
|
2026-08-17 10:40:54 +02:00
|
|
|
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 }
|
2026-08-20 07:01:14 +02:00
|
|
|
Calendar:
|
|
|
|
|
description: OK
|
|
|
|
|
content:
|
|
|
|
|
application/json:
|
|
|
|
|
schema:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
connected: { type: boolean }
|
|
|
|
|
account:
|
|
|
|
|
type: string
|
|
|
|
|
description: >
|
|
|
|
|
The address of the connected account, taken from the id of its
|
|
|
|
|
primary calendar — which is where Google puts it, so no profile
|
|
|
|
|
scope has to be asked for to learn it.
|
|
|
|
|
read_only:
|
|
|
|
|
type: boolean
|
|
|
|
|
description: >
|
|
|
|
|
Always true today. Stated rather than assumed: an interface that
|
|
|
|
|
quietly looks editable is one somebody will try to edit.
|
|
|
|
|
has_client:
|
|
|
|
|
type: boolean
|
|
|
|
|
description: >
|
|
|
|
|
Whether an OAuth client is registered on this instance at all, so
|
|
|
|
|
the page can explain rather than offer a button that answers 409.
|
|
|
|
|
redirect_uri:
|
|
|
|
|
type: string
|
|
|
|
|
description: >
|
|
|
|
|
What has to be registered with Google, spelled out. Derived from
|
|
|
|
|
VERDANDE_BASE_URL and matched exactly — the single most likely
|
|
|
|
|
thing to be wrong, and the error Google gives names neither value.
|
|
|
|
|
last_sync_at: { type: string, format: date-time }
|
|
|
|
|
calendars:
|
|
|
|
|
type: array
|
|
|
|
|
items: { $ref: "#/components/schemas/Calendar" }
|
2026-08-17 10:40:54 +02:00
|
|
|
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:
|
2026-08-20 07:01:14 +02:00
|
|
|
Calendar:
|
|
|
|
|
type: object
|
|
|
|
|
description: >
|
|
|
|
|
One calendar inside a connected account. Kept whether or not it is shown,
|
|
|
|
|
so turning one off and on again gives back the same row.
|
|
|
|
|
properties:
|
|
|
|
|
id: { type: string }
|
|
|
|
|
remote_id: { type: string, description: "The id Google knows it by; an address for the primary calendar." }
|
|
|
|
|
name: { type: string }
|
|
|
|
|
colour:
|
|
|
|
|
type: string
|
|
|
|
|
description: >
|
|
|
|
|
Google's own hex value, not one of verdande's ten named colours. Those
|
|
|
|
|
are names so the interface can restyle them per theme and they are
|
|
|
|
|
verdande's palette; a Google calendar is told apart by the colour the
|
|
|
|
|
person already knows it by.
|
|
|
|
|
time_zone: { type: string }
|
|
|
|
|
primary: { type: boolean }
|
|
|
|
|
writable:
|
|
|
|
|
type: boolean
|
|
|
|
|
description: >
|
|
|
|
|
Whether the connected account could write to it. Nothing does — the
|
|
|
|
|
scope is read-only — and it is stored so the day something can, the
|
|
|
|
|
interface knows which calendars could even be offered.
|
|
|
|
|
shown: { type: boolean }
|
|
|
|
|
|
|
|
|
|
CalendarEvent:
|
|
|
|
|
type: object
|
|
|
|
|
description: >
|
|
|
|
|
One occurrence, in the shape a grid asks about. A recurrence is expanded by
|
|
|
|
|
Google into its occurrences, so what arrives here is a day, not a rule.
|
|
|
|
|
properties:
|
|
|
|
|
id: { type: string }
|
|
|
|
|
calendar_id: { type: string }
|
|
|
|
|
summary: { type: string }
|
|
|
|
|
starts_at:
|
|
|
|
|
type: string
|
|
|
|
|
description: >
|
|
|
|
|
RFC 3339 as Google wrote it, offset and all. Empty for an all-day
|
|
|
|
|
event, which has a day rather than a moment — inventing midnight for it
|
|
|
|
|
is how an event moves a day when a container moves time zone.
|
|
|
|
|
ends_at: { type: string }
|
|
|
|
|
start_day: { type: string, format: date }
|
|
|
|
|
end_day:
|
|
|
|
|
type: string
|
|
|
|
|
format: date
|
|
|
|
|
description: >
|
|
|
|
|
The last day the event covers, inclusive. Google's all-day end date is
|
|
|
|
|
exclusive and is corrected on the way in, once, rather than by every
|
|
|
|
|
reader.
|
|
|
|
|
all_day: { type: boolean }
|
|
|
|
|
location: { type: string }
|
|
|
|
|
url: { type: string, description: "Opens the event in Google's own interface." }
|
|
|
|
|
calendar_name: { type: string }
|
|
|
|
|
colour: { type: string }
|
|
|
|
|
|
2026-08-19 13:00:41 +02:00
|
|
|
Note:
|
|
|
|
|
type: object
|
|
|
|
|
description: A page of Markdown. The body is the stored form and the exported form.
|
|
|
|
|
properties:
|
|
|
|
|
id: { type: string }
|
|
|
|
|
project_id: { type: string }
|
2026-08-19 18:36:31 +02:00
|
|
|
title: { type: string, readOnly: true, description: "The note's first line. Derived on every save; it cannot be set." }
|
2026-08-19 13:00:41 +02:00
|
|
|
body: { type: string, description: Markdown }
|
|
|
|
|
pinned: { type: boolean }
|
|
|
|
|
created_at: { type: string, format: date-time }
|
|
|
|
|
updated_at: { type: string, format: date-time }
|
|
|
|
|
links:
|
|
|
|
|
type: array
|
|
|
|
|
description: What the body points at, read out of the text on the way in.
|
|
|
|
|
items:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
kind: { type: string, enum: [task, note, project] }
|
|
|
|
|
target_id: { type: string }
|
|
|
|
|
|
|
|
|
|
NoteInput:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
project_id: { type: string, description: "Empty files it under no project." }
|
2026-08-19 18:36:31 +02:00
|
|
|
body: { type: string, description: "Markdown. Its first line becomes the title." }
|
2026-08-19 13:00:41 +02:00
|
|
|
pinned: { type: boolean }
|
|
|
|
|
|
2026-08-18 22:22:31 +02:00
|
|
|
Mailbox:
|
|
|
|
|
type: object
|
|
|
|
|
description: One connected mailbox. The password and any token are never returned.
|
|
|
|
|
properties:
|
|
|
|
|
id: { type: string }
|
|
|
|
|
kind: { type: string, enum: [gmail, imap] }
|
|
|
|
|
name: { type: string }
|
|
|
|
|
host: { type: string }
|
|
|
|
|
username: { type: string }
|
|
|
|
|
folder: { type: string }
|
|
|
|
|
last_uid: { type: integer }
|
|
|
|
|
last_sync_at: { type: string, format: date-time }
|
|
|
|
|
created_at: { type: string, format: date-time }
|
|
|
|
|
|
2026-08-17 10:40:54 +02:00
|
|
|
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
|
2026-08-17 14:32:42 +02:00
|
|
|
- gmail_not_configured
|
|
|
|
|
- ai_not_configured
|
|
|
|
|
- totp_not_enabled
|
|
|
|
|
- inbox_protected
|
2026-08-17 10:40:54 +02:00
|
|
|
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 }
|
2026-08-18 09:05:36 +02:00
|
|
|
sidebar_collapsed:
|
|
|
|
|
type: array
|
|
|
|
|
items: { type: string }
|
|
|
|
|
description: |
|
|
|
|
|
The sidebar headings this person has folded away. Always an array,
|
|
|
|
|
never null: a client that has to check for both an absent array and an
|
|
|
|
|
empty one will one day check for only one of them.
|
|
|
|
|
example: ["labels"]
|
2026-08-17 10:40:54 +02:00
|
|
|
created_at: { type: string, format: date-time }
|
|
|
|
|
|
|
|
|
|
RecoveryCodes:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
recovery_codes:
|
|
|
|
|
type: array
|
|
|
|
|
items: { type: string }
|
|
|
|
|
|
2026-08-17 21:10:46 +02:00
|
|
|
AdminUser:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
id: { type: string }
|
|
|
|
|
email: { type: string, format: email }
|
|
|
|
|
name: { type: string }
|
|
|
|
|
avatar_color: { type: string }
|
|
|
|
|
is_admin: { type: boolean }
|
|
|
|
|
self:
|
|
|
|
|
type: boolean
|
|
|
|
|
description: The caller, so the interface can grey out what nobody should do to their own account here.
|
|
|
|
|
created_at: { type: string, format: date-time }
|
|
|
|
|
last_seen_at:
|
|
|
|
|
type: string
|
|
|
|
|
format: date-time
|
|
|
|
|
description: |
|
|
|
|
|
Absent when they have never signed in — which is what an invite that
|
|
|
|
|
was accepted and then forgotten looks like. Absent rather than zero,
|
|
|
|
|
because 1970 in an interface reads as a bug.
|
|
|
|
|
project_count:
|
|
|
|
|
type: integer
|
|
|
|
|
description: Projects they own, excluding the Inbox every account has.
|
|
|
|
|
task_count:
|
|
|
|
|
type: integer
|
|
|
|
|
description: |
|
2026-08-18 09:05:36 +02:00
|
|
|
What a delete would destroy: the tasks in the projects they own,
|
|
|
|
|
which go with those projects.
|
|
|
|
|
authored_elsewhere:
|
|
|
|
|
type: integer
|
|
|
|
|
description: |
|
|
|
|
|
What a delete would leave behind: tasks they wrote in projects other
|
|
|
|
|
people own. Those survive without an author. A separate number from
|
|
|
|
|
`task_count` because it is a separate sentence — one is what
|
|
|
|
|
disappears, the other is what stays unattributed.
|
|
|
|
|
|
|
|
|
|
GmailClient:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
client_id: { type: string }
|
|
|
|
|
has_secret:
|
|
|
|
|
type: boolean
|
|
|
|
|
description: Whether a secret is stored. The secret itself is never sent back.
|
|
|
|
|
from_env:
|
|
|
|
|
type: boolean
|
|
|
|
|
description: |
|
|
|
|
|
The deployment set this, so the stored pair is ignored and the fields
|
|
|
|
|
are not worth editing.
|
|
|
|
|
redirect_uri:
|
|
|
|
|
type: string
|
|
|
|
|
description: |
|
|
|
|
|
What has to be registered with Google, spelled out. Derived from
|
|
|
|
|
`VERDANDE_BASE_URL` and matched exactly — it is the single most likely
|
|
|
|
|
thing to be wrong, and the error Google returns for it names neither
|
|
|
|
|
value. Google rejects private IP addresses over http, so a LAN address
|
|
|
|
|
will not do: it has to be https, or `http://localhost`.
|
|
|
|
|
example: https://verdande.example.com/oauth/gmail/callback
|
|
|
|
|
|
|
|
|
|
BackupRun:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
id: { type: string }
|
|
|
|
|
started_at: { type: string, format: date-time }
|
|
|
|
|
finished_at:
|
|
|
|
|
type: string
|
|
|
|
|
format: date-time
|
|
|
|
|
description: |
|
|
|
|
|
Absent while a run is still going, and on one the process died in the
|
|
|
|
|
middle of — which is the same shape from outside, and honestly so: a
|
|
|
|
|
run that was interrupted never finished.
|
|
|
|
|
size_bytes: { type: integer }
|
|
|
|
|
error:
|
|
|
|
|
type: string
|
|
|
|
|
description: Present only on a run that failed, in the words it failed with.
|
|
|
|
|
present:
|
|
|
|
|
type: boolean
|
|
|
|
|
description: |
|
|
|
|
|
Whether the file is still on disk. Rotation keeps the most recent
|
|
|
|
|
fourteen, so an older row is a record with nothing behind it and its
|
|
|
|
|
download answers 404.
|
2026-08-17 21:10:46 +02:00
|
|
|
|
|
|
|
|
PendingInvite:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
id: { type: string }
|
|
|
|
|
email: { type: string, format: email }
|
|
|
|
|
project_name:
|
|
|
|
|
type: string
|
|
|
|
|
description: Absent for an invite to the instance itself rather than to a project.
|
|
|
|
|
role: { type: string, enum: [owner, editor, viewer] }
|
|
|
|
|
invited_by: { type: string }
|
|
|
|
|
created_at: { type: string, format: date-time }
|
|
|
|
|
expires_at: { type: string, format: date-time }
|
|
|
|
|
|
2026-08-17 19:43:49 +02:00
|
|
|
Session:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
id: { type: string }
|
|
|
|
|
current:
|
|
|
|
|
type: boolean
|
|
|
|
|
description: The one making this request.
|
|
|
|
|
device:
|
|
|
|
|
type: string
|
|
|
|
|
description: |
|
|
|
|
|
A summary of the user agent — "Chrome på macOS". A handful of
|
|
|
|
|
substring tests rather than a parsing library: the question it
|
|
|
|
|
answers is "is one of these me?", and the raw string is sent beside
|
|
|
|
|
it so anything the summary gets wrong is still visible.
|
|
|
|
|
example: Chrome på macOS
|
|
|
|
|
user_agent: { type: string }
|
|
|
|
|
ip: { type: string }
|
|
|
|
|
created_at: { type: string, format: date-time }
|
|
|
|
|
last_seen_at: { type: string, format: date-time }
|
|
|
|
|
expires_at: { type: string, format: date-time }
|
|
|
|
|
|
2026-08-17 10:40:54 +02:00
|
|
|
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 }
|
2026-08-18 09:05:36 +02:00
|
|
|
created_by:
|
|
|
|
|
type: string
|
|
|
|
|
description: |
|
|
|
|
|
Empty when the author's account has been deleted. The task outlives
|
|
|
|
|
the person who wrote it.
|
|
|
|
|
|
2026-08-17 10:40:54 +02:00
|
|
|
sort_order: { type: number }
|
|
|
|
|
created_at: { type: string, format: date-time }
|
|
|
|
|
updated_at: { type: string, format: date-time }
|
|
|
|
|
|
|
|
|
|
TaskInput:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
2026-08-17 16:22:36 +02:00
|
|
|
project_id:
|
|
|
|
|
type: string
|
|
|
|
|
description: |
|
|
|
|
|
Omitted on create, the task goes to the Inbox. On update it moves the
|
|
|
|
|
task — which needs editor rights on the destination.
|
|
|
|
|
|
2026-08-17 10:40:54 +02:00
|
|
|
section_id: { type: string }
|
|
|
|
|
parent_id: { type: string }
|
|
|
|
|
content: { type: string }
|
|
|
|
|
description: { type: string }
|
|
|
|
|
priority: { type: integer, minimum: 1, maximum: 4 }
|
|
|
|
|
due_date: { type: string, format: date }
|
|
|
|
|
due_time: { type: string, example: "10:00" }
|
|
|
|
|
duration_min: { type: integer }
|
|
|
|
|
recurrence_rule: { type: string }
|
|
|
|
|
assignee_id: { type: string }
|
|
|
|
|
labels:
|
|
|
|
|
type: array
|
|
|
|
|
items: { type: string }
|
|
|
|
|
sort_order: { type: number }
|
|
|
|
|
|
2026-08-17 16:22:36 +02:00
|
|
|
QuickAddResult:
|
|
|
|
|
allOf:
|
|
|
|
|
- $ref: "#/components/schemas/Task"
|
|
|
|
|
- type: object
|
|
|
|
|
properties:
|
|
|
|
|
unknown_project:
|
|
|
|
|
type: string
|
|
|
|
|
description: |
|
|
|
|
|
Present when the text named a `#project` that does not exist. The
|
|
|
|
|
task is still created, in the Inbox — a thought is not thrown away
|
|
|
|
|
over a typo — but the caller is told, because the alternative is
|
|
|
|
|
that the name vanishes from the title and nothing says why.
|
|
|
|
|
|
2026-08-17 10:40:54 +02:00
|
|
|
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 }
|
2026-08-17 21:50:20 +02:00
|
|
|
color: { $ref: "#/components/schemas/Color" }
|
2026-08-17 10:40:54 +02:00
|
|
|
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 }
|
2026-08-17 19:25:10 +02:00
|
|
|
group_id:
|
|
|
|
|
type: string
|
|
|
|
|
description: |
|
|
|
|
|
The sidebar group *you* have filed it under, absent for none. Always
|
|
|
|
|
absent on a project shared with you: grouping is a column on the
|
|
|
|
|
project row, so it is the owner's filing rather than yours.
|
2026-08-17 10:40:54 +02:00
|
|
|
|
|
|
|
|
ProjectInput:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
name: { type: string }
|
2026-08-17 21:50:20 +02:00
|
|
|
color: { $ref: "#/components/schemas/Color" }
|
2026-08-17 10:40:54 +02:00
|
|
|
icon: { type: string }
|
|
|
|
|
view_mode: { type: string, enum: [list, board, calendar] }
|
|
|
|
|
archived: { type: boolean }
|
|
|
|
|
sort_order: { type: number }
|
2026-08-17 19:25:10 +02:00
|
|
|
group_id:
|
|
|
|
|
type: string
|
|
|
|
|
description: |
|
|
|
|
|
Files the project under one of your groups. The empty string takes it
|
|
|
|
|
out of one — `null` cannot mean that, because absent and null are the
|
|
|
|
|
same thing once decoded, and telling "put it nowhere" apart from "I
|
|
|
|
|
did not mention it" is what makes this a PATCH.
|
|
|
|
|
|
2026-08-18 11:56:54 +02:00
|
|
|
Passkey:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
id: { type: string }
|
|
|
|
|
name:
|
|
|
|
|
type: string
|
|
|
|
|
description: What the owner called it. "min bærbare" is what makes a list reviewable.
|
|
|
|
|
aaguid:
|
|
|
|
|
type: string
|
|
|
|
|
description: |
|
|
|
|
|
Identifies the authenticator model, which is what lets a list say
|
|
|
|
|
something recognisable beside a key somebody named months ago.
|
|
|
|
|
discoverable:
|
|
|
|
|
type: boolean
|
|
|
|
|
description: |
|
|
|
|
|
Whether the credential lives on the device. Only these can start a login
|
|
|
|
|
with no email typed first.
|
|
|
|
|
user_verified:
|
|
|
|
|
type: boolean
|
|
|
|
|
description: |
|
|
|
|
|
Whether the authenticator verified the person — a PIN, a fingerprint, a
|
|
|
|
|
face. One that did is both factors at once; one that only proved
|
|
|
|
|
possession is a first factor and still meets the TOTP step.
|
|
|
|
|
created_at: { type: string, format: date-time }
|
|
|
|
|
last_used_at: { type: string, format: date-time }
|
|
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
ProjectGroup:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
id: { type: string }
|
|
|
|
|
name: { type: string }
|
2026-08-17 21:50:20 +02:00
|
|
|
color: { $ref: "#/components/schemas/Color" }
|
2026-08-18 09:20:03 +02:00
|
|
|
description:
|
|
|
|
|
type: string
|
|
|
|
|
description: |
|
|
|
|
|
What the group is, in the owner's words. A heading carries a name; a
|
|
|
|
|
page carries the sentence that says why these projects are one body of
|
|
|
|
|
work. Empty until somebody writes one.
|
2026-08-17 19:25:10 +02:00
|
|
|
collapsed: { type: boolean }
|
|
|
|
|
sort_order: { type: number }
|
|
|
|
|
|
|
|
|
|
ProjectGroupInput:
|
|
|
|
|
type: object
|
|
|
|
|
properties:
|
|
|
|
|
name: { type: string }
|
2026-08-17 21:50:20 +02:00
|
|
|
color: { $ref: "#/components/schemas/Color" }
|
2026-08-18 09:20:03 +02:00
|
|
|
description:
|
|
|
|
|
type: string
|
|
|
|
|
maxLength: 4000
|
|
|
|
|
description: What the group is, in the owner's words.
|
2026-08-17 19:25:10 +02:00
|
|
|
collapsed: { type: boolean }
|
2026-08-17 10:40:54 +02:00
|
|
|
|
2026-08-17 21:50:20 +02:00
|
|
|
Color:
|
|
|
|
|
type: string
|
|
|
|
|
default: graphite
|
|
|
|
|
enum: [graphite, tomato, amber, lime, green, teal, blue, indigo, violet, magenta]
|
|
|
|
|
description: |
|
|
|
|
|
A name, not a hex value. The interface resolves it when it paints, so the
|
|
|
|
|
palette can be retuned for a new theme or a new display without rewriting
|
|
|
|
|
every row — and a database that stores a fact rather than a rendering.
|
|
|
|
|
|
|
|
|
|
The set is closed: an unknown name is refused rather than stored and
|
|
|
|
|
painted as the default, which would look like a colour that did not save.
|
|
|
|
|
|
|
|
|
|
One palette for all five themes. These are small solid marks beside a
|
|
|
|
|
label that carries the meaning, not text or grounds, so what they owe is
|
|
|
|
|
to be told apart and to be visible on both a near-black and a white
|
|
|
|
|
ground — which is why they all sit in the mid-tone band.
|
|
|
|
|
|
2026-08-17 10:40:54 +02:00
|
|
|
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.
|