API routes

Every server route under /api — JSON, zod-validated, session-guarded.

All routes live under /api, speak JSON, validate bodies and queries with zod, and require a session cookie — except /api/auth/* and the public invite lookup. Auth uses nuxt-auth-utils sealed session cookies; log in once with POST /api/auth/login and reuse the cookie.

# log in, keep the cookie, then use it
curl -sc tick.cookies -X POST http://localhost:3000/api/auth/login \
  -H 'content-type: application/json' \
  -d '{"email":"mara@example.com","password":"tick-demo"}'
curl -sb tick.cookies http://localhost:3000/api/me

The session cookie is Secure by default, so curl's jar won't send it back to anything but localhost/127.0.0.1 over plain HTTP — use HTTPS, or NUXT_SESSION_COOKIE_SECURE=false on a trusted network (see Security).

Multi-user scoping: catalog and reporting routes operate on the whole org; the timer and entry mutations operate on the current user. Every request re-reads the caller's membership and role from the database, so removing or demoting a member takes effect immediately (see Security).

Cross-cutting behavior: POST /api/auth/{login,register,forgot,reset} are rate-limited (429 + Retry-After), API bodies are capped at 1 MB (413) except /api/import/* at 5 MB, and demo mode turns a set of destructive routes into 403.

Auth

MethodRouteDescription
POST/api/auth/register{name, email, password, inviteToken?} → creates the user, sets the session. Without a token: new personal org, role owner. With one: joins that org with the invite's role. 403 when registration is closed, or invite without a token; 409 duplicate email; 410 unknown/expired/used token
POST/api/auth/login{email, password} → SessionUser, sets the session. Generic 401 on bad credentials (never says which half was wrong)
POST/api/auth/logoutClears the session
POST/api/auth/forgot{email} → always {ok: true}, even for unknown addresses (no account enumeration). Known address: a 1-hour single-use token is created and the reset link emailed — or logged to the server console when SMTP is unset
POST/api/auth/reset{token, password} → {ok: true}. 410 when the token is unknown, used or expired. Sets the new password, voids the user's other reset tokens and revokes every existing session

Current user

MethodRouteDescription
GET/api/meFresh SessionUser + saved theme
PATCH/api/me/profile{name?, email?, defaultRate?} → SessionUser; 409 on duplicate email
PATCH/api/me/password{currentPassword, newPassword} → {ok: true}. Generic 403 when the current password is wrong. Revokes your other sessions and keeps this one
PATCH/api/me/theme{theme} → persists the theme editor state to users.theme (jsonb). Known keys only, payload capped at 2 KB

Timers

A user can run up to 10 timers at once. Every timer is named by its entry id, so stopping or editing one leaves the others running.

MethodRouteDescription
GET/api/timersTimerState[] — every running timer, ordered start ascending (ties by id). [] when nothing is running, never a 204
POST/api/timers{name?, refType?, refId?, billable?} → TimerState. Stops nothing. 409 Timer limit reached (10 running). at the cap
PATCH/api/timers/:idEdit one running timer (name, reference, billable) → TimerState
POST/api/timers/:id/stop→ EntryDto, or null when under 1 second (discarded)

PATCH and stop answer 404 No timer running. whenever the id is unknown, already stopped, trashed, or someone else's — the message is deliberately identical in all four cases, so it can't be used to prove that an id exists in another org.

Entries

MethodRouteDescription
GET/api/entries?from&toEntryDto[] in [from, to), newest first; excludes running + trashed
POST/api/entriesManual entry: {name, refType?, refId?, billable?, rateOverride?, start, end, tags?}
PATCH/api/entries/:idPartial update, same fields
DELETE/api/entries/:idSoft delete → DeleteResult
POST/api/entries/bulk{ids, action: 'delete' | 'billable' | 'restore' | 'reassign', billable?, refType?, refId?} — up to 500 ids. delete → DeleteResult, billable/restore → {count}, reassign → EntryDto[] (null refType clears the reference)

An EntryDto carries the resolved chain and money, so clients never compute either:

{
  "id": "70f73e13-…",
  "name": "Intake form validation",
  "ref": {
    "refType": "task",
    "refId": "4e197f26-…",
    "taskName": "Intake form validation",
    "projectName": "Intake form",
    "clientName": "Northwind Legal",
    "clientColor": "primary-400"
  },
  "billable": true,
  "resolvedRate": 95,
  "rateOverridden": false,
  "start": "2026-09-13T13:00:00.000Z",
  "end": "2026-09-13T14:45:00.000Z",
  "durationSec": 6300,
  "amount": 166.25,
  "tags": []
}

Catalog

Clients, projects, tasks and tags share the same CRUD shape. Cascade endpoints return the counts the cascade dialog shows.

MethodRouteDescription
GET / POST/api/clientsList ClientDto[] / create {name, rate?}
PATCH / DELETE/api/clients/:idUpdate / cascade delete (body {cascadeProjects, cascadeTasks, cascadeEntries}) → CascadeDeleteResult
GET/api/clients/:id/cascadeCascadeCounts for the dialog
GET / POST/api/projectsList ProjectDto[] / create
PATCH / DELETE/api/projects/:idUpdate / cascade delete (body {cascadeTasks, cascadeEntries})
GET/api/projects/:id/cascadeCascadeCounts
GET / POST/api/tasksList TaskDto[] (carries rate, resolvedRate, rateSource per Rule 2) / create {name, projectId?, rate?, estimateMinutes?, done?}
PATCH / DELETE/api/tasks/:idUpdate (same fields, partial; rate: null clears) / delete
GET / POST/api/tagsList TagDto[] / create (lowercased, leading # stripped; an existing name — even a trashed one — is revived and returned instead of duplicated)
DELETE/api/tags/:idStrips labels from entries; never touches time
POST/api/restoreUndo, in one of two shapes. {batchId} — from a CascadeDeleteResult; the server un-trashes every row that cascade stamped and re-applies the references it cleared, whatever the size. {deleted, relinked?} — an explicit id snapshot for small, known sets (the trash list, a tag, one entry); capped at 10k entries / 5k tasks / 1k each of clients, projects and tags

Summaries & export

MethodRouteDescription
GET/api/summary/dashboardDashboardSummary — today/week stats, 16-week activity, week bars, unbilled amount
GET/api/summary/reports?from&to&billable&groupByReportSummary — stacked days, regroupable breakdown, fan-out-safe totals
GET/api/export/csv?from&to&billableThe range as a text/csv attachment (date, start, end, duration_h, name, task, project, client, tags, billable, rate, amount)
GET/api/export/pdf?from&to&billable&groupByThe same report as an application/pdf attachment, rendered server-side

from/to are ISO datetimes and the range is half-open on entry start — [from, to). billable is all (default) / billable / nonbillable; groupBy is client / project (default) / task / tag. Both exports set Content-Disposition: attachment with a dated filename (tick-export-2026-09-07--2026-09-13.csv, tick-report-2026-09-07_2026-09-13.pdf).

Import

MethodRouteDescription
POST/api/import/preview{source: 'toggl' | 'clockify' | 'generic', csv} → ImportPreview (dry run, writes nothing)
POST/api/import/commitSame body → ImportCommitResult (creates catalog rows + inserts entries). 403 in demo mode

Organization & team

MethodRouteDescription
GET / PATCH/api/orgOrgInfoDto (id, name, memberCount, createdAt) / rename (owner/admin)
GET/api/org/membersOrgMemberDto[] — name, email, role, rate override, defaultRate
PATCH/api/org/members/:userId{role?, rate?} (owner/admin). Admins cannot touch owners or grant the owner role (403); the last owner can't be demoted (409)
DELETE/api/org/members/:userIdRemove member (owner/admin; admins can't remove owners, last owner protected)
GET / POST/api/invitesList pending (unaccepted, unexpired) / create {email, role} → InviteCreateDto with the token, a 7-day expiresAt and emailSent. Owner/admin only; only owners may invite owners; 409 if the address is already a member
DELETE/api/invites/:idRevoke an invite
GET/api/invites/lookup/:tokenPublic — {email, role, orgName} for the register page; 404 unknown, 410 expired or used

Trash

MethodRouteDescription
GET/api/trashTrashDto — trashed rows per entity with daysLeft; auto-purges anything older than 30 days first
POST/api/trash/purge{all: true} or {items: {clients, projects, tasks, tags, entries}} — hard delete ("Delete forever"). 403 in demo mode

Errors

StatusWhen
400Validation failure. Routes reachable without a session answer with a terse {statusMessage: 'Invalid input', data: {fields: […]}} — field names only, never zod internals
401No session, a revoked session, or a membership that no longer exists
403Role not allowed, wrong current password, or the action is blocked in demo mode
404 / 409 / 410Missing row / conflict (duplicate email, the running-timer cap, last owner) / expired-or-used token
411 / 413Body without a Content-Length on an API mutation / body over the cap
429Auth rate limit — see the Retry-After header