You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Conventions for the T'Day REST API served by the Ktor backend. Keep this file aligned with shared/, backend routes, mobile Retrofit/URLSession clients, and DATA_MODEL.md.
Base URL
All API routes live under /api/. The web SPA consumes them via same-origin requests (Vite proxy in development, same container in production). Android and iOS clients target them at the user-configured server URL in Server Mode. Local Mode does not call the API.
Authentication
All routes require a valid JWE session unless listed as public.
Authentication is enforced by a Ktor pipeline intercept in Security.kt:
Reads a JWE token from Authorization: Bearer header or session cookies.
Decodes and validates claims (expiry, tokenVersion, role, approval status).
Attaches AuthUser to the call attributes.
Route handlers use call.withAuth { } to require an authenticated, approved user.
Admin routes additionally require role == ADMIN and approvalStatus == APPROVED via requireAdminAccess().
App-layer request throttling runs before handlers and may return 429 on /api/**, /health, /api/mobile/probe, POST /api/todo/summary, POST /api/user/change-password, and /ws.
Request Format
Content Type
Request bodies use application/json.
Serialization is handled by Ktor's ContentNegotiation plugin with kotlinx.serialization.
Validation
Validate incoming request bodies using Konform validators from domain/Validations.kt with the validateOrFail() helper.
Return typed AppError variants via Either.Left for invalid input (maps to 400 Bad Request).
Validate string-backed enum fields before service calls. Invalid enum values such as priority, list color, and preference sort/group/direction must return 400, not a generic 500.
Never trust client input without validation.
either {
val body = call.receive<TodoCreateRequest>()
validateCreateTodo.validateOrFail(body).bind()
todoService.create(user.id, body.title, ...).bind()
}
Always return a JSON object with a message field. Validation errors may include field; throttled responses include reason and retryAfterSeconds:
{
"code": 400,
"message": "priority is invalid",
"field": "priority"
}
Malformed JSON/request bodies return 400 with message: "Invalid request body". Error responses are produced by respondAppError() from the withAuth helper, or by StatusPages for malformed requests and unhandled exceptions. The legacy ApiException hierarchy is deprecated — new code should use Either<AppError, T> exclusively.
HTTP Status Codes
Success
Code
Usage
200 OK
Successful GET, PATCH, PUT, DELETE
201 Created
Successful POST that creates a resource
Client Errors
Code
Usage
400 Bad Request
Invalid input, malformed JSON, failed validation
401 Unauthorized
Missing or invalid session
403 Forbidden
Authenticated but lacking permission (pending approval, non-admin)
404 Not Found
Resource does not exist or does not belong to the user
409 Conflict
Duplicate resource or version conflict
429 Too Many Requests
Rate limit exceeded
Server Errors
Code
Usage
500 Internal Server Error
Unhandled exceptions (generic message returned to client)
HTTP Methods
Method
Purpose
Idempotent
GET
Retrieve resources
Yes
POST
Create a new resource
No
PATCH
Partial update of an existing resource
Yes
PUT
Full replacement of an existing resource
Yes
DELETE
Remove a resource
Yes
Prefer PATCH over PUT for updates.
DELETE should be idempotent — deleting a non-existent resource returns 200 or 404, never 500.
Route Handler Pattern
Route handlers delegate to services and use the withAuth helper for authentication:
route("/api/todo") {
post {
call.withAuth { user ->val request = call.receive<CreateTodoRequest>()
todoService.create(user.id, request)
.fold(
{ error -> call.respondError(error) },
{ todo -> call.respond(HttpStatusCode.Created, todo) }
)
}
}
get {
call.withAuth { user ->val todos = todoService.listForUser(user.id, call.request.queryParameters)
call.respond(HttpStatusCode.OK, todos)
}
}
}
Services return Either<AppError, T> (Arrow) for typed error handling. Routes fold the result into HTTP responses.
Shared route constants live in shared/src/commonMain/kotlin/com/ohmz/tday/shared/routes/ApiRoutes.kt. Add or update those constants with backend route changes so backend, Android, and iOS have one contract reference point.
Tenant Isolation
Every data query must filter by userID from the authenticated session.
Never return data belonging to other users.
Admin endpoints that access other users' data must be behind requireAdminAccess().
Existing API Surface
Health and Infrastructure
Method
Path
Purpose
Auth
GET
/health
Health check ({ status: "ok" })
Public
WS
/ws
WebSocket for real-time domain events
Required
Auth
Method
Path
Purpose
Auth
GET
/api/auth/csrf
Fetch CSRF token
Public
POST
/api/auth/register
User registration
Public
POST
/api/auth/login-challenge
Password-proof challenge
Public
GET
/api/auth/credentials-key
Public key for credential envelope encryption
Public
POST
/api/auth/callback/credentials
Login (plain or encrypted envelope)
Public
GET
/api/auth/session
Get current session; also returns apiKey: { scope, label, keyPreview } when an API key authenticated the request
Required
POST
/api/auth/logout
Logout (invalidate session)
Required
Todos
Method
Path
Purpose
GET
/api/todo
List todos (query: start/end, timeline, recurringFutureDays)
POST
/api/todo
Create a new todo
PATCH
/api/todo
Update a todo
DELETE
/api/todo
Delete a todo
PATCH
/api/todo/complete
Mark todo complete
PATCH
/api/todo/uncomplete
Mark todo incomplete
PATCH
/api/todo/prioritize
Change priority
PATCH
/api/todo/reorder
Reorder todos
PATCH
/api/todo/instance
Update a recurring instance
DELETE
/api/todo/instance
Delete a recurring instance
GET
/api/todo/overdue
List overdue todos
POST
/api/todo/nlp
Natural language date/title parsing
POST
/api/todo/summary
Task summary with optional AI and logic fallback
PATCH /api/todo/complete and /api/todo/uncomplete take { id, instanceDate? }, and
instanceDate is what decides the scope:
rrule
instanceDate
What is written
null
anything
todos.completed, plus a CompletedTodo row with a null instanceDate. A one-off has no occurrences, so an instanceDate sent anyway is ignored rather than keyed into history.
set
set
A todo_instances row for that occurrence, plus a CompletedTodo row keyed by the same date. The series keeps running.
set
null
The series: todos.completed, plus a CompletedTodo row with a null instanceDate.
The last row is the one to know about. The request names a todo, not an occurrence, and
todos.completed is the only thing the listing queries read — they never consult
todo_instances — so it is also the only outcome that takes the task off a client's
screen. A client that means one occurrence must send its instanceDate; see
docs/design/bulk-selection.md §4.1, which requires exactly that of bulk complete.
Uncomplete is the mirror in every case, so any completion reverses cleanly.
Older servers wrote that third row's history entry and then changed no state at all,
leaving the task on screen with a phantom entry in Completed. Clients that already guard
against that should keep doing so — a self-hosted server may predate the fix.
Floaters
Floaters are unscheduled Anytime tasks. They are not scheduled todos with a nullable due date.
Method
Path
Purpose
GET
/api/floater
List all active floaters
POST
/api/floater
Create a floater
PATCH
/api/floater
Update a floater
DELETE
/api/floater
Delete a floater
PATCH
/api/floater/complete
Complete a floater
PATCH
/api/floater/uncomplete
Restore a completed floater to active — recreates its list first if the list was deleted since completion (see docs/design/completed-floaters-durability.md)
PATCH
/api/floater/prioritize
Change floater priority
PATCH
/api/floater/reorder
Reorder floaters
Lists
Lists group scheduled tasks.
Method
Path
Purpose
GET
/api/list
List all lists
POST
/api/list
Create a list
PATCH
/api/list
Update a list
DELETE
/api/list
Delete a list
GET
/api/list/{id}
Get list with its todos
GET
/api/list/{id}/members
Get the list owner and members with roles
POST
/api/list/{id}/members
Add a member by username with role EDITOR or VIEWER (owner only)
PATCH
/api/list/{id}/members
Change a member's role (owner only)
DELETE
/api/list/{id}/members
Remove a member (owner only)
POST
/api/list/{id}/leave
Leave a shared list (non-owner members)
GET /api/list/{id} returns ListTodoDto rows, a narrower shape than the timeline's
TodoResponse. It carries rrule and listID (added alongside bulk selection) so a
client can tell a repeating task from a one-off on a list screen; it still has no
instanceDate, because these rows are recurring templates, not occurrences. A client
that must decide whether a row repeats has to treat a missing rrule as unknown rather
than as "does not repeat" — see docs/design/bulk-selection.md §4.1.
Floater Lists
Floater lists group floaters.
Method
Path
Purpose
GET
/api/floaterList
List all floater lists
POST
/api/floaterList
Create a floater list
PATCH
/api/floaterList
Update a floater list
DELETE
/api/floaterList
Delete one or many floater lists
GET
/api/floaterList/{id}
Get floater list with its floaters
GET
/api/floaterList/{id}/members
Get the list owner and members with roles
POST
/api/floaterList/{id}/members
Add a member by username with role EDITOR or VIEWER (owner only)
PATCH
/api/floaterList/{id}/members
Change a member's role (owner only)
DELETE
/api/floaterList/{id}/members
Remove a member (owner only)
POST
/api/floaterList/{id}/leave
Leave a shared floater list (non-owner members)
User
Method
Path
Purpose
GET
/api/user
Get current user profile
GET
/api/user/search?q=
Typeahead for the share-member picker: substring match on username or display name (min 2 chars, APPROVED users, excludes self, max 10). The query is matched literally — % and _ are escaped, not treated as wildcards
PATCH
/api/user
Update user (encryption settings)
PATCH
/api/user/profile
Update profile
POST
/api/user/change-password
Change password
GET
/api/user/security-questions
Get the user's chosen question ids + whether they still need to be set (requireSecurityQuestions)
POST
/api/user/security-questions
Set/replace the user's security questions (exactly 3 distinct). When the account already has questions configured (requireSecurityQuestions = false), the body must include a valid currentPassword — otherwise 400 "current password is required" / "current password is incorrect". The first-time setup gate (requireSecurityQuestions = true) omits the password.
Admin
Method
Path
Purpose
GET
/api/admin/settings
Get app configuration and Summary availability
PATCH
/api/admin/settings
Update app configuration
GET
/api/admin/users
List all users
PATCH
/api/admin/users/{id}
Update user (approve, change role)
DELETE
/api/admin/users/{id}
Delete user and related data. 409 when a row still references the account (the constraint is named in the server log)
Preferences
Method
Path
Purpose
GET
/api/preferences
Get user preferences
PATCH
/api/preferences
Update preferences
Completed Todos
Method
Path
Purpose
GET
/api/completedTodo
List completed todos
DELETE
/api/completedTodo
Delete all completed todos, or delete one when an id body is supplied
PATCH
/api/completedTodo
Update a completed todo, or remove it when no update fields are supplied
Completed Floaters
Method
Path
Purpose
GET
/api/completedFloater
List completed floaters
DELETE
/api/completedFloater
Delete all or one completed floater
PATCH
/api/completedFloater
Update or remove a completed floater
Timezone
Method
Path
Purpose
GET
/api/timezone
Get/detect timezone using timezone, X-Timezone, or X-User-Timezone
App Settings
Method
Path
Purpose
GET
/api/app-settings
Get public app settings (Summary enabled)
Integrations
Method
Path
Purpose
GET
/api/integration/context
One-call grounding for external integrations: key scope, user + timezone, server time, capabilities.canWrite, and both list namespaces
MCP
Method
Path
Purpose
Auth
POST
/mcp
Model Context Protocol endpoint (JSON-RPC 2.0, stateless Streamable HTTP)
Required
GET / DELETE
/mcp
405 Method Not Allowed — the server keeps no per-connection state
Required
/mcp is mounted outside/api, next to /calendar/{token}.ics, so the connector URL a user
pastes into an AI client is just <origin>/mcp. Two consequences are load-bearing:
It is the one path exempt from the API-key read-only method guard in Security.kt. MCP tunnels
every message — including initialize and tools/list — through a POST, so a READ key would
otherwise be rejected before routing. Scope is enforced per tool in McpToolDispatcher, which
fails closed.
It gets no rate limiting from the /api/ prefix rule, so RateLimiting.kt adds an explicit mcp
policy using the same window and budget as api_global.
Server discovery, compatibility/version metadata, optional encrypted probe payload
Public
GET /api/mobile/probe returns the public probe contract:
service: "tday", probe: "ok", version: "1", and serverTime.
appVersion with the backend's T'Day release version; mobile App Version screens use this to display the server version even when encrypted compatibility metadata is unavailable.
encryptedCompatibility when mobile compatibility enforcement is configured; Android and iOS decrypt it to enforce app/server version compatibility.
When exact compatibility is enabled, Android and iOS send X-Tday-Client and
X-Tday-App-Version on API requests. The backend leaves /api/mobile/probe,
web requests, and requests without mobile client headers unaffected, but rejects
mismatched mobile API requests with:
426 Upgrade Required + reason: "app_update_required" when the app is older than the server-compatible version.
409 Conflict + reason: "server_update_required" when the app is newer than the server-compatible version.
App Association Files
Method
Path
Purpose
Auth
GET
/.well-known/apple-app-site-association
iOS webcredentials/deep-link association
Public
GET
/apple-app-site-association
iOS association fallback path
Public
GET
/.well-known/assetlinks.json
Android app links association
Public
Cache Behavior
The web API client sends private API requests with cache: "no-store" so browser fetch caching does not serve stale authenticated data.
Routes that return compatibility or discovery metadata, such as /api/mobile/probe, should set explicit Cache-Control: no-store response headers.
Security headers (CSP, HSTS, etc.) are applied by the Ktor SecurityHeaders plugin.
Static SPA assets are served from the filesystem when STATIC_FILES_DIR is set.
Adding a New Endpoint
Add or update shared request/response models in shared/ when the endpoint is consumed outside the backend.
Add a route function in routes/<domain>.kt (or create a new file for a new domain).
Use call.withAuth { } for authenticated routes.
Validate input using Konform validators or shared model validation.
Delegate to a service in services/.
Filter data by userID for tenant isolation.
Use appropriate HTTP status codes.
Update Android Retrofit and iOS URLSession clients when mobile consumes it.
Update local cache/sync models if the route changes mobile persisted data.
Add tests in tday-backend/src/test/kotlin/ if the endpoint involves security or complex logic.