API routes
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).
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
| Method | Route | Description |
|---|---|---|
| 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/logout | Clears 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
| Method | Route | Description |
|---|---|---|
| GET | /api/me | Fresh 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.
| Method | Route | Description |
|---|---|---|
| GET | /api/timers | TimerState[] — 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/:id | Edit 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
| Method | Route | Description |
|---|---|---|
| GET | /api/entries?from&to | EntryDto[] in [from, to), newest first; excludes running + trashed |
| POST | /api/entries | Manual entry: {name, refType?, refId?, billable?, rateOverride?, start, end, tags?} |
| PATCH | /api/entries/:id | Partial update, same fields |
| DELETE | /api/entries/:id | Soft 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.
| Method | Route | Description |
|---|---|---|
| GET / POST | /api/clients | List ClientDto[] / create {name, rate?} |
| PATCH / DELETE | /api/clients/:id | Update / cascade delete (body {cascadeProjects, cascadeTasks, cascadeEntries}) → CascadeDeleteResult |
| GET | /api/clients/:id/cascade | CascadeCounts for the dialog |
| GET / POST | /api/projects | List ProjectDto[] / create |
| PATCH / DELETE | /api/projects/:id | Update / cascade delete (body {cascadeTasks, cascadeEntries}) |
| GET | /api/projects/:id/cascade | CascadeCounts |
| GET / POST | /api/tasks | List TaskDto[] (carries rate, resolvedRate, rateSource per Rule 2) / create {name, projectId?, rate?, estimateMinutes?, done?} |
| PATCH / DELETE | /api/tasks/:id | Update (same fields, partial; rate: null clears) / delete |
| GET / POST | /api/tags | List TagDto[] / create (lowercased, leading # stripped; an existing name — even a trashed one — is revived and returned instead of duplicated) |
| DELETE | /api/tags/:id | Strips labels from entries; never touches time |
| POST | /api/restore | Undo, 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
| Method | Route | Description |
|---|---|---|
| GET | /api/summary/dashboard | DashboardSummary — today/week stats, 16-week activity, week bars, unbilled amount |
| GET | /api/summary/reports?from&to&billable&groupBy | ReportSummary — stacked days, regroupable breakdown, fan-out-safe totals |
| GET | /api/export/csv?from&to&billable | The 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&groupBy | The 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
| Method | Route | Description |
|---|---|---|
| POST | /api/import/preview | {source: 'toggl' | 'clockify' | 'generic', csv} → ImportPreview (dry run, writes nothing) |
| POST | /api/import/commit | Same body → ImportCommitResult (creates catalog rows + inserts entries). 403 in demo mode |
Organization & team
| Method | Route | Description |
|---|---|---|
| GET / PATCH | /api/org | OrgInfoDto (id, name, memberCount, createdAt) / rename (owner/admin) |
| GET | /api/org/members | OrgMemberDto[] — 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/:userId | Remove member (owner/admin; admins can't remove owners, last owner protected) |
| GET / POST | /api/invites | List 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/:id | Revoke an invite |
| GET | /api/invites/lookup/:token | Public — {email, role, orgName} for the register page; 404 unknown, 410 expired or used |
Trash
| Method | Route | Description |
|---|---|---|
| GET | /api/trash | TrashDto — 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
| Status | When |
|---|---|
400 | Validation failure. Routes reachable without a session answer with a terse {statusMessage: 'Invalid input', data: {fields: […]}} — field names only, never zod internals |
401 | No session, a revoked session, or a membership that no longer exists |
403 | Role not allowed, wrong current password, or the action is blocked in demo mode |
404 / 409 / 410 | Missing row / conflict (duplicate email, the running-timer cap, last owner) / expired-or-used token |
411 / 413 | Body without a Content-Length on an API mutation / body over the cap |
429 | Auth rate limit — see the Retry-After header |