Connect your tools
The API uses JSON and the same permissions as the web app. Every application record uses a UUID. Dates use YYYY-MM-DD; timestamps use RFC 3339 with seconds and an explicit offset. Exact durations are stored as integer seconds. There is no automatic rounding or minimum duration.
Authentication
Create a personal token in Settings. Send it in the Authorization header. Tokens are shown once, stored as hashes, and can be revoked immediately. A token belongs to its user and organization. A member token only accesses that person’s time. Manager and admin tokens can manage team time; only admins can manage invitations and roles.
export UTT_TOKEN='your-personal-token'
curl http://unnamed-time-tracker.ddev.site/api/me \
-H "Authorization: Bearer $UTT_TOKEN"
Each personal token is tied to the team where it was created. It cannot switch teams, and X-Team-ID never overrides a token’s team. Create another token after switching teams in the web app.
Browser login uses a separate HttpOnly session cookie. Browser pages send X-Team-ID to pin requests to their loaded team; the server verifies membership on every request, so switching in another tab cannot redirect an old form’s writes. GET /api/csrf returns a CSRF token; browser mutations send X-CSRF-Token. API tokens do not need CSRF headers and never create a browser session. Use HTTPS when deploying beyond local development.
Add a completed entry
List /api/projects to find a project UUID and its client. Submit either a duration or a pair of timestamps.
{
"project_id": "019a0000-0000-7000-8000-000000000001",
"date": "2026-09-09",
"duration": "1h 15m",
"description": "Implement project navigation",
"source": "burrowtime",
"external_id": "stable-local-frame-id"
}
duration also accepts decimal hours, such as 1.25, or 75m. For exact machine input, replace duration with "duration_seconds": 4500. Never send both forms. Fractional seconds are rejected rather than silently rounded. Duration entries support one second through one year.
curl http://unnamed-time-tracker.ddev.site/api/entries \
-H "Authorization: Bearer $UTT_TOKEN" \
-H 'Content-Type: application/json' \
--data-binary @entry.json
For timestamps, omit date and duration:
{
"project_id": "019a0000-0000-7000-8000-000000000001",
"started_at": "2026-09-09T09:00:00-04:00",
"ended_at": "2026-09-09T10:15:00-04:00",
"description": "Implement project navigation"
}
Managers and admins may add user_id to create an entry for someone in their organization. Entries cannot be reassigned to another person after creation.
Retry safely
The combination of organization, user, source, and external_id identifies an imported entry. Submit the same body again and the API returns the existing entry with duplicate: true and HTTP 200. New entries return HTTP 201. If the external ID is reused with different data, the API returns HTTP 409. PATCH the existing entry’s UUID to intentionally change it. Keep your source name stable and external IDs case-sensitive. Send a source name of at most 64 characters and an external ID of at most 128 characters.
{"entry": {"id": "019a0000-0000-7000-8000-000000000002"}, "duplicate": false}
The actual response includes all entry fields. Timestamp fields in responses are Unix seconds; running and elapsed_seconds are also included. Lists of entries include total, limit, and offset.
Timers
POST /api/timers/start accepts project_id, optional description, optional started_at, and optional keep_running. Starting a timer stops your other timers unless keep_running is true. Multiple timers and overlapping completed entries count in full.
POST /api/entries/{uuid}/stop stops a timer. Repeating it is safe. PATCH /api/entries/{uuid} can edit a running timer’s start, description, or project. A running timer cannot start in the future. DELETE /api/entries/{uuid} removes either a running timer or a completed entry.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /api/health |
Service health, public |
| POST | /api/login |
Email/password login; email, password |
| POST | /api/logout |
End browser session |
| GET | /api/csrf |
Browser CSRF token |
| GET, PATCH | /api/me |
Profile; update name, timezone, or password with current_password |
| GET, POST | /api/clients |
List or create clients with name |
| PATCH | /api/clients/{uuid} |
Change name or archived |
| GET, POST | /api/projects |
List or create with name, client_id, optional hex color |
| PATCH | /api/projects/{uuid} |
Change name, client_id, color, or archived |
| GET, POST | /api/entries |
List or create entries |
| GET, PATCH, PUT, DELETE | /api/entries/{uuid} |
Read, update, or delete an entry; PUT currently has partial-update semantics |
| POST | /api/timers/start |
Start a timer |
| POST | /api/entries/{uuid}/stop |
Stop a timer |
| GET | /api/reports |
Daily totals and client/project/user breakdowns |
| GET | /api/reports/forecast |
Monthly individual/team forecasts |
| GET | /api/reports/insights |
Previous-period comparisons, client/person changes, and recorded work patterns |
| GET, POST | /api/reports/saved |
List personal/team saved definitions or create one |
| PATCH, DELETE | /api/reports/saved/{uuid} |
Update or delete a permitted saved report |
| GET | /api/reports/export |
Download CSV |
| GET | /api/organizations |
List active team memberships; API tokens see only their own team |
| POST | /api/organizations |
Session only: create a team with name and become its admin |
| PATCH | /api/organization |
Admin: rename the current team with name |
| POST | /api/organizations/{uuid}/switch |
Session only: select an active membership |
| GET | /api/users |
Own profile, or team directory for managers/admins |
| PATCH | /api/users/{uuid} |
Admin changes role or boolean active |
| GET, POST | /api/invitations |
Admin lists or creates invitations with email, role |
| DELETE | /api/invitations/{uuid} |
Revoke an invitation |
| GET | /api/invitations/lookup?token=... |
Validate invitation, public |
| POST | /api/invitations/accept |
Set token, name, timezone, password, public |
| GET, POST | /api/tokens |
List own tokens or create one with name |
| DELETE | /api/tokens/{uuid} |
Revoke own token |
| POST | /api/imports/clockify/connect |
Verify api_key, optional region; return remote identity/workspaces |
| POST | /api/imports/clockify/prepare |
Read personal history with api_key, region, workspace_id; return preview UUID and destinations |
| GET | /api/imports/clockify |
List your completed syncs |
| POST | /api/imports/clockify/{uuid}/review |
Validate mappings and return a dry-run summary |
| POST | /api/imports/clockify/{uuid}/apply |
Apply the last reviewed mappings; retry-safe |
| DELETE | /api/imports/clockify/{uuid} |
Cancel your pending preview |
Inviting an email already registered in a different team is supported. Invitation lookup returns existing_account; those users accept with the invitation token and their current password. Their profile is retained. New accounts still provide name, timezone, and a new password. Deactivation removes only that team’s membership access.
Clockify mapping API
The Clockify endpoints use your normal local session or personal API token. api_key is a separate Clockify credential, used only to read Clockify. Supported regions are global (default), eu, us, uk, and au. The prepare endpoint reads only the Clockify key owner’s history; there is no remote user override. Previews and results are scoped to the signed-in local user, even for managers/admins.
Prepare may return a warnings array with unavailable project/company IDs, placeholder names, and affected entry counts. No time has been imported. Show these warnings and ask whether to continue. If the user agrees, map or skip the placeholder destinations and include "acknowledge_warnings": true alongside clients and projects in the review body. Review rejects missing, false, or non-boolean acknowledgement. Cancel by deleting the preview.
Use the remote IDs returned by prepare as keys in the review body. Existing destination IDs are local UUIDs:
{
"clients": {
"CLOCKIFY_CLIENT_ID": {"mode": "existing", "id": "LOCAL_CLIENT_UUID"},
"ANOTHER_CLOCKIFY_CLIENT": {"mode": "skip"}
},
"projects": {
"CLOCKIFY_PROJECT_ID": {"mode": "new", "name": "Imported project"}
}
}
Every client and every project under a non-skipped client requires a choice. A project may use existing, new, or skip too. Existing projects must belong to the selected client. Creating destinations requires manager/admin permissions; members must choose existing records or skip. New names default to the Clockify names when omitted. Unassigned time appears as explicit client/project choices in the preview.
Review saves validated mapping choices and rolls back destination/time writes. Apply takes {} and uses those saved choices; it checks permissions and destinations again and commits atomically. Results include entries_created, clients_created, projects_created, duplicates, changed_entries, running_skipped, skipped_entries, and exact seconds. Applying the same completed preview again returns the saved result. To fetch additional time, prepare a new preview. Previously imported external entry IDs are preserved across the command and web importer.
Previews expire after one hour. Completed previews retain resolved mappings and summary counts; their raw history snapshot is removed. API keys are never persisted. Remote failures return HTTP 502 with a sanitized error; invalid mappings use 422 and expired/unreviewed previews use 409.
Filters and reporting
Entries and reports accept from, to, user_id, client_id, and project_id. Use user_id=me for your own time. Members cannot query someone else’s time. When passing dates, supply both from and to. Reports support ranges of up to one year. Entry lists accept limit from 1 through 1,000, offset, and running=1.
Saved reports store a view and validated filters with personal or team visibility. Team sharing does not grant access to other people’s time. Period insights compare the chosen range with the immediately preceding range of equal length and include per-person daily activity. See report definitions, permissions, and insights for the full contract.
Timed entries are split across local midnights in the user’s current time zone, including daylight-saving changes. Duration-only entries retain their chosen local date. Team totals sum those local-day totals. The CSV emits one row per entry per local day and escapes spreadsheet formula prefixes. The entry list shows each full entry once.
Forecasts accept month=YYYY-MM and the user/client/project filters. They use recent, weighted weekday averages and explicitly include zero-hour days after the first entry. Today counts as recorded. Estimates begin on the following day. No-history users have a null estimate; users_without_history flags incomplete team estimates. Forecasts do not model planned leave or holidays.
Errors
Errors return {"error":"A message explaining what to fix."}. Common statuses are 401 for sign-in/token failures, 403 for insufficient permissions or CSRF failure, 404 for missing or inaccessible records, 409 for duplicate/conflicting imports, and 422 for invalid input. Login attempts are rate-limited.
Burrow Time
This repository includes scripts/burrowtime-push.py, which reads the documented output of burrowtime log --json. It previews by default and only uploads with --apply.
{"my-local-project": "019a0000-0000-7000-8000-000000000001"}
Save that mapping as project-map.json, substituting a real UUID, then:
burrowtime log --today --json > /tmp/time-entries.json
python3 scripts/burrowtime-push.py --map project-map.json --entries /tmp/time-entries.json
python3 scripts/burrowtime-push.py --map project-map.json --entries /tmp/time-entries.json --apply
Descriptions preserve sorted Burrow Time tags. Existing Burrow Time configuration is unchanged. The helper validates all mappings before sending entries and makes retries safe through each frame’s stable ID. See docs/IMPORTS.md for the separate Clockify importer.
Agent integration and Google sign-in
The agent setup guide provides a local MCP connector and companion skill. The connector calls this API with a personal team token and uses source=mcp plus a stable UUID external_id for completed entries and timer starts. There is no separate hosted /mcp endpoint in this release.
Google uses browser sessions. GET /api/auth/providers is public and returns { "google": true|false }. GET /api/auth/google/start begins sign-in, and GET /api/auth/google/callback handles Google’s response. Authenticated GET /api/auth/google returns availability and the connected email. POST /api/auth/google/link and /api/auth/google/unlink require a browser session, CSRF token, and current_password; personal API tokens cannot manage these connections. Linking returns a Google authorization url. The Google identity connects to the shared account rather than a team membership. See Google setup for deployment details.
Stripe invoicing
Optional admin billing endpoints, customer mappings, project rates, draft exports, retry behavior, and status reconciliation are documented in the Stripe guide.
Profile photos and team icons
POST /api/me/photo uploads the current account’s profile photo; DELETE /api/me/photo removes it. Profile photos follow the account across teams. Team admins use POST /api/organization/icon and DELETE /api/organization/icon to manage the current team’s icon.
Uploads use multipart/form-data with one file field named image. Send the usual CSRF header for cookie sessions. Accepts PNG, JPEG, or WebP up to 5 MB and 16 megapixels. The server normalizes images to 512 × 512 PNG, cropping photos centrally and preserving full team icons. Responses are { "url": "/api/media/..." } or { "url": null } after removal. Invalid files return 422; storage outages return 503 without replacing the previous image.
GET /api/media/status returns { "enabled": true|false } for signed-in users. Uploads require backend R2 configuration. /api/me includes avatar_url and organization_icon_url; /api/users includes avatar_url for each visible person. These URLs require an authenticated session or API token and return private PNG responses, not JSON. Membership and current image version are checked on every request.