All routes are same-origin under /api. Auth is the same session cookie the web app uses. Responses are always { ok: true, data } or { ok: false, error }.
/api/auth/[...all]Better Auth handler — sign in, sign up, session, social callbacks. POST /api/auth/demo-login is handled here too (and as its own route) so the catch-all cannot 404 the demo buttons.Public (signed)/api/onboarding/create-orgCreate the first org for a user who signed up but has none yet. Promotes them to admin.Any/api/orgsList organisations you belong to, with the current (last-used) org marked.Signed in/api/orgsCreate another organisation and switch into it as admin. Existing orgs stay on the account.Signed in/api/orgs/switchSwitch the active organisation. Login lands on the last-used org.Signed in/api/clientsList active clients in your org.Any/api/clientsCreate a client.Any/api/clients/:idGet one client.Any/api/clients/:idUpdate a client (name, contact, invoice defaults).Any/api/clients/:idArchive a client (soft delete).Any/api/clients/:id/membersList people on this client's team. They are copied onto the client's projects.Any/api/clients/:id/membersAdd a person to the client team and every active project for that client.Admin/api/clients/:id/members/:userIdRemove a person from the client team. Project assignments stay unless removed there.Admin/api/projectsList projects. Members only see projects they're assigned to; $ budget hidden for members.Any/api/projectsCreate a project.Any/api/projects/:idGet one project.Any/api/projects/:idUpdate a project.Any/api/projects/:idArchive a project.Any/api/projects/:id/membersList people assigned to a project.Any/api/projects/:id/membersAssign a person to a project.Any/api/projects/:id/members/:userIdRemove a person from a project.Any/api/projects/:id/tasksList tasks assigned to a project.Any/api/projects/:id/tasksAssign an existing catalog task (taskId) or create a catalog task and assign it here. Members: assigned projects only; rates are ignored.Any/api/projects/:id/tasks/:taskIdUpdate this project's assignment (billable / hourly rate). Name edits update the shared catalog task.Admin/api/projects/:id/tasks/:taskIdUnassign the task from this project, or archive it if it is not used elsewhere.Admin/api/tasksList the org task catalog. Default rates are hidden from members.Any/api/tasksCreate a catalog task. Optional common (future projects). addToExistingProjects is admin-only; members cannot set a default rate.Any/api/tasks/:idGet one catalog task.Any/api/tasks/:idUpdate name, default rate, billable, or common. Default rate changes apply to future assignments only.Admin/api/tasks/:idArchive a catalog task, or hard-delete it with ?hard=true when it has no time logged. 409 if ?hard=true and time exists. Archive is the only option once time has been logged.Admin/api/tasks/:id/add-to-projectsAssign this task to every active project that does not already have it.Admin/api/timeList a week's time entries. ?userId= opens another person's week (any role). ?unbilled=true&clientId= (admin) spans the whole org for invoice drafting.Signed in/api/timeCreate an entry. Accepts startedAt/endedAt (exact times, hours computed) or date+hours. Optional reference { source, id, label, url } attaches one ticket; the label is stored as it reads now. Members: own week and assigned projects only. Admins may pass userId to create for someone else.Any/api/time/:idUpdate an entry, including startedAt/endedAt to move or resize. A running timer can retarget startedAt without endedAt — the clock keeps running. A start still in the future is stored as yesterday. Omitting reference keeps the ticket; reference: null detaches it. Members cannot edit others. 409 if locked after a Xero push.Own record/api/time/:idHard-delete an unlocked entry after writing an audit snapshot of the old row. 409 if locked by a Xero push.Own record/api/time/referenceEvery hour booked against one ticket: ?source=groupchat&id=… (add &mine=1 for your own). Org-wide and all-time — no date window. Totals are summed in SQL, so the capped entry list never changes them. Hours only, no money for any role.Signed in/api/timerGet your currently running timer, or null. There is at most one. leftover is true once it has run 10 hours — a day already at capacity never flags it. Includes leftoverReasons, honestEndedAt, reference (the ticket it is for, or null), and reminder settings.Own record/api/timerEdit the running timer: projectId, taskId, notes, startedAt (clock keeps running), optional reference. A stopped entry is PATCH /api/time/:id.Own record/api/timerDiscard the running timer without saving hours. Same as stop with resolution discard.Own record/api/timer/startStart a timer. Stops every previous running timer first (end is now). Starting the same project/task/notes again returns the live timer — it does not create a 0:00 duplicate. Accepts reference { source, id, label, url } to say which ticket it is for.Any/api/timer/continueContinue after idle: clip the running timer at proposedEndedAt (last activity) and start a new running row for the same project/task/notes/ticket. Never reuses the live row. Returns { parked, running }. A last-active at or before the original start deletes the idle-only row.Own record/api/timer/stopStop your running timer. Accepts resolution clip_to | ended_at | discard | keep_all and optional endedAt / proposedEndedAt (Mac idle). A timer left running stops with no resolution clip, it does not keep_all.Own record/api/timer/reminders/sendSend the still-running email for your timer (any role). Never auto-stops. { force: true } fires without waiting for the reminder hours. Missing RESEND_API_KEY fails the request.Own record/api/cron/timer-remindersSend due still-running emails on the same path. Authorized by CRON_SECRET or Vercel Cron. Once per timer stretch.Cron/api/v1Index for a developer wiring up an app: the endpoints, the docs link, and where to authenticate. No credentials needed.Public/api/v1/meWho the token belongs to, which organisation it acts in, and how it authenticated (token or session).Token or cookie/api/v1/projectsProjects this person can log time to, each with its tasks, so a picker needs one request. Members only see assigned projects. Each project and task includes this person's usage { lastUsed, recentUses, yearUses } so a picker can fetch once and re-rank locally while typing. Query: q (name search; pickers should not send this per keystroke), sort=relevance|name (default relevance), archived=true. The dump is already in relevance order. Relevance is this person's use in the last 30 days, then the last year, then last-used date. With q, name match ranks first — exact, start of the title (a leading #id is ignored), start of a later word, then contains — then that same usage order. sort=name is A–Z. Usage comes from a per-person daily rollup, not a year of time entries. No rates or $ budgets, for any role.Token or cookie/api/v1/projects/:id/tasksOne project's tasks, for refreshing a task picker. Same q and sort as GET /api/v1/projects (default sort=relevance for this person). Each task includes this person's usage so the client can re-rank locally.Token or cookie/api/v1/timer{ running, timer } — the one running timer, or running: false. timer.reference is the ticket the start sent ({ source, id, label, url }), or null when the timer was started in RealTime with no ticket. Do not guess ownership from project/task — two tickets often share one RealTime task. Hours for that ticket: GET /api/v1/time/reference?source=&id=.Token or cookie/api/v1/timerEdit the running timer (projectId, taskId, notes, startedAt, reference). Same helper as PATCH /api/timer.Token or cookie/api/v1/timerDiscard the running timer. No running timer is a 404.Token or cookie/api/v1/timer/startStart a timer (projectId, taskId, optional notes). Optional reference { source, id, label?, url? } stores the ticket on the running timer and the completed entry. source + id is enough; label defaults to id. RealTime does not infer a ticket from project/task. Stops every running timer first (end is now). The same project/task/notes while one is already running returns that timer. An unknown project or task is a 403/404, never a 500.Token or cookie/api/v1/timer/continueContinue after idle. Body: { proposedEndedAt }. Clips the running timer at last activity and starts a new row for the same work. Same helper as POST /api/timer/continue. Returns { discarded, parked, timer }.Token or cookie/api/v1/timer/stopStop the running timer. Same resolutions as /api/timer/stop; no running timer is a 404.Token or cookie/api/v1/timeYour recent time with totalHours. No dates returns the trailing 30 days; an explicit from (and to) is honoured with no rolling cap. Filters: projectId, limit (max 500). Pass source+id for one ticket: dates are ignored and totalHours is the all-time SQL sum — same as /api/v1/time/reference.Token or cookie/api/v1/time/referenceHours on one ticket: ?source=groupchat&id=… — the whole team's total, all-time, no date window. The listed entries are your own, because a team total is hours but an entry carries someone's notes.Token or cookie/api/expensesList expenses. Defaults to the signed-in person. Admins can pass userId, omit it for the whole org, and filter with from, to, projectId, clientId, unbilled, or billed. Same admin-only org-wide flags as /api/time.Own record/api/expensesLog an expense.Any/api/expenses/:idUpdate an expense. Fails if locked.Own record/api/expenses/:idHard-delete an unlocked expense (and its R2 receipt) after writing an audit snapshot of the old row. 409 if locked by a Xero push.Own record/api/receipts/upload-urlGet a presigned R2 upload URL scoped to an org project you can access. Size/type/ext checked. Unguessable key. 409 if replacing a locked invoiced receipt. 503 if R2 isn't fully configured (fail closed — paste a URL instead).Any/api/ratesList billable rates. Admin-only — members never see rates, not even their own.Admin/api/ratesCreate a rate: default, client override, or project override.Admin/api/rates/:idUpdate a rate's amount.Admin/api/rates/:idDelete a rate.Admin/api/budgets/alertsList budget alert thresholds, optionally filtered by project.Admin/api/budgets/alertsAdd an alert threshold (any %, multiple allowed).Admin/api/budgets/alerts/:idUpdate an alert threshold.Admin/api/budgets/alerts/:idRemove an alert threshold.Admin/api/invoicesList invoices, newest first, one page at a time (default 50, max 100). Pass page (1-based) or cursor; the response is { invoices, page, pageSize, totalCount, totalPages, nextCursor }.Admin/api/invoices/missing-ratesPeople, tasks, and projects that have unbilled billable time with no rate. Harvest-style notice data — members never see this.Admin/api/invoicesCreate a draft from selected unbilled time entries + expenses. Attaches them; does not lock. 422 if any selected billable time has no rate, listing the missing person or task.Admin/api/invoices/:idGet an invoice with its line items, including Xero status after webhook sync.Admin/api/invoices/:idDelete an invoice. Unlinks and unlocks its time entries and expenses. Invoices sent to Xero can be removed from RealTime after they are voided or deleted there — or to unlock the time if you already deleted them in Xero.Admin/api/invoices/:id/push-xeroPush a draft to Xero and lock its time/expenses. If that invoice number already exists in Xero, link it (no second invoice, no date change). A new invoice dated in a locked period is posted today and the list explains the lock. 400 if Xero env is unset or Xero rejects the invoice (toast is one reason). 422 if attached billable time has no rate, so a forgotten rate cannot produce a $0 Xero draft.Admin/api/reportsBreakdown by project/client/person/task/reference/billable/entries. Optional filters: repeatable userId, clientId, projectId, taskId, reference (source:id, or none for time with no ticket), billable (billable|non-billable), plus userOp/clientOp/projectOp/taskOp/referenceOp/billableOp (any|all|none), from, to. Org-wide hours for everyone. Admins additionally get revenue. Members never receive money keys, including on entries. type=slots is accepted as a legacy alias.Any/api/reports/filter-optionsPeople, clients, projects, tasks, and recently used tickets for report filter pills.Any/api/reports/uninvoicedUnbilled time + expense value grouped by client, last 6 months.Admin/api/meYour profile: name, email, photo, sign-in methods. Never another person's record.Signed in/api/meUpdate your name, avatar URL, or still-running reminder settings. Weekly capacity cannot be set on your own profile.Signed in/api/me/emailChange your sign-in email. Requires current password. Marks the new address unverified.Signed in/api/me/passwordChange your password (or set one if you only signed in with Google). Requires current password when one exists. Other sessions are revoked.Signed in/api/me/avatar/upload-urlPresigned R2 PUT URL for a profile photo (JPEG/PNG/WebP, max 2 MB). 503 if R2 is not configured — paste a URL instead.Signed in/api/usersList org members.Any/api/users/:idGet one user.Any/api/users/:idUpdate name/photo (self) or role (admin only, any user). Admins can set weekly capacity for other team members — not their own profile. Email and password are not changed here.Own record/api/users/:idArchive a team member. Cannot archive yourself.Admin/api/invitesList pending team invites.Admin/api/invitesEmail an invite with the org name and a join button. 503 if RESEND_API_KEY is missing.Admin/api/invites/acceptAccept an invite token, set a password, and join the org.Public (signed)/api/settings/orgGet org settings: name, currency, formats, tax rate. Not billing money — same info the Settings page already shows every member (read-only).Any/api/settings/orgUpdate org settings: currency, formats, fiscal year, tax rate.Admin/api/settings/orgPermanently delete the current organisation. Body must include the organisation name. 422 if the name does not match. Cascaded data (clients, projects, time, invoices, memberships) is removed. User accounts stay; last-used org is cleared so they land on another org or onboarding.Admin/api/support/reportFile a GroupChat ticket on the main RealTime board. Body: { surface: macos|web, title?, message?, entryIds?, debugReport?, appVersion? }. Description includes person/org, realtime:entry refs, activity links, and the Mac debug dump. 503 if GROUPCHAT_PAT is missing.Signed in/api/auditOrg activity log. Filter with ?entity=&actor=&from=&to=&q=. q matches anything on a row (ids, names, notes, ticket refs). A pasted realtime:entry:<uuid> or raw uuid searches that entity id exactly, and falls back to the live row when audit_events is empty. Members get 403 and never receive $ fields in before/after.Admin/api/xero/connectRedirects to Xero's OAuth consent screen. 400 if XERO_CLIENT_ID isn't set.Admin/api/xero/connectDisconnect Xero. Tokens are deleted and not written to the audit log.Admin/api/xero/callbackOAuth callback — exchanges the code, stores the connection.Admin/api/xero/webhookXero invoice events (HMAC). Unsigned/forged → 401. Unset signing secret → 503 fail closed. Unlock-on-void never runs without a valid signature.Public (signed)/api/harvest/connectRedirects to Harvest's OAuth consent screen. 400 if Harvest connect isn't available.Admin/api/harvest/connectDisconnect Harvest. Tokens are deleted and not written to the audit log.Admin/api/harvest/callbackOAuth callback — exchanges the code, stores the connection.Admin/api/harvest/importLatest import job: idle|queued|running|cancelled|failed|succeeded plus phase/counts. Fresh jobs stay queued/running; leftover dead runs become failed. Cancelled jobs stay cancelled — GET does not resume them.Admin/api/harvest/importPersist queued then running and start a durable Harvest import (resumes the last saved page from failed or cancelled). 409 if one is already queued/running. 400 if Harvest is not connected.Admin/api/harvest/importCancel that import (`{ importId }`). A request without importId does not cancel anything. Already-cancelled is 200. 409 if that job is not queued or running. Resume with POST.AdminExample
curl https://www.realtime.fyi/api/timer \ -H "Cookie: better-auth.session_token=<token from your browser>"
Copy your session token from the browser: DevTools → Application → Cookies → better-auth.session_token.
Connecting an app instead of copying a cookie
Another product can run your timer with an OAuth access token. It is the same Authorization Code + PKCE flow the MCP server uses — /api/mcp/register, /mcp/authorize, /api/mcp/token — asking for scope=timer. Endpoints and discovery URLs are listed at /api/v1.
curl https://www.realtime.fyi/api/v1/timer \
-H "Authorization: Bearer <access token>"
curl -X POST https://www.realtime.fyi/api/v1/timer/start \
-H "Authorization: Bearer <access token>" \
-H "Content-Type: application/json" \
-d '{"projectId":"...","taskId":"...","notes":"Ticket triage","reference":{"source":"groupchat","id":"TICKET-1"}}'
curl "https://www.realtime.fyi/api/v1/time/reference?source=groupchat&id=TICKET-1" \
-H "Authorization: Bearer <access token>"A token acts with exactly the role you have in the organisation you approved, so members still never see rates or invoice amounts. Access tokens last an hour; refresh tokens rotate for 90 days. Revoke a connection from Settings and it stops working on the next call. Timestamps are UTC.