API Reference¶
Complete reference for the Tempo REST API.
Base URL¶
- Development:
http://localhost:5001 - Production: Configured per deployment
Authentication¶
Most endpoints require authentication. Tempo accepts Authorization: Bearer in two forms:
- JWT — Usually issued after login and sent automatically as the httpOnly cookie
authToken. The same JWT can be sent as a Bearer token when calling the API directly (e.g.curl). - API key — Admin-issued secret for machine clients (prefix
tmp_). Use for read-only automation and the planned CLI: set something likeTEMPO_API_KEYand sendAuthorization: Bearer <key>. The server distinguishes keys from JWTs by thetmp_prefix.
API keys work on normal protected routes (for example GET /auth/me, GET /workouts, stats, settings, shoes). Creating, listing, and revoking API keys requires a full interactive JWT session (POST/GET/DELETE /auth/api-keys); those routes do not accept an API key.
API keys (CLI and automation)¶
Create (session required — log in first so the authToken cookie is set, or use a cookie jar with POST /auth/login):
POST /auth/api-keys
Content-Type: application/json
Cookie: authToken=…
{
"label": "optional label, max 200 chars"
}
Response 201: includes id, label, keyPrefix, createdAt, and key (full secret) once. Store it immediately; later listing never returns the secret.
List (session only):
Returns metadata per key: id, label, keyPrefix, createdAt, revokedAt.
Revoke (session only):
Example: cookie jar (same pattern as the main README):
BASE=http://localhost:5001
curl -sS -c tempo-cookies.txt -X POST "$BASE/auth/login" \
-H 'Content-Type: application/json' \
-d '{"username":"your-user","password":"your-password","rememberMe":true}'
curl -sS -b tempo-cookies.txt -X POST "$BASE/auth/api-keys" \
-H 'Content-Type: application/json' \
-d '{"label":"cli"}'
Example: call GET /auth/me with an API key:
export TEMPO_API_KEY='tmp_…'
curl -sS -H "Authorization: Bearer $TEMPO_API_KEY" http://localhost:5001/auth/me
Do not log API keys or commit them to version control. Rotate by revoking the key and creating a new one.
Register¶
Register the first user (only available when no users exist):
POST /auth/register
Content-Type: application/json
{
"username": "your-username",
"password": "your-password",
"confirmPassword": "your-password"
}
New passwords must be 16–64 characters, with UTF-8 encoding at most 72 bytes. The API rejects common passwords, passwords that contain the username (when the username is at least 3 characters), and passwords with any character repeated five or more times consecutively. There is no mandatory character-class complexity.
Login¶
Authenticate and receive JWT token:
POST /auth/login
Content-Type: application/json
{
"username": "your-username",
"password": "your-password",
"rememberMe": false
}
Request Body:
- username (string, required) - Your username
- password (string, required) - Your password
- rememberMe (boolean, optional) - If true, token expires in 30 days (default: 7 days)
The JWT token is stored in an httpOnly cookie with expiration based on the rememberMe parameter.
Get Current User¶
Requires authentication via JWT (cookie or Bearer) or a valid API key (Authorization: Bearer tmp_…).
Response includes:
userId,username,createdAt,lastLoginAtonboardingCompleted(boolean) —falsefor new registrations until first-run setup finishes; existing users are backfilledtrueby migration
Complete Onboarding¶
Requires authentication. Marks first-run onboarding complete for the current user (onboardingCompleted: true). Idempotent; there is no API to set the flag back to false. Returns the same current-user payload as GET /auth/me.
Logout¶
Clears the authentication cookie.
Check Registration Availability¶
Returns whether registration is currently available.
Workouts¶
Import Workout¶
Import a single workout file (GPX, FIT, or CSV):
Outcomes per file: created, updated, or skipped (plus error). Incomplete FIT/GPX JSON or missing raw bytes on a duplicate can updated; complete duplicates are skipped. HTTP JSON is unchanged from before intake.
Import HealthKit Workout¶
Import one outdoor run from tempo-ios as schema-versioned JSON (feeds the same PersistAsync pipeline as file import):
POST /workouts/import/healthkit
Content-Type: application/json
{
"schemaVersion": 1,
"healthKitUuid": "…",
"sourceApp": { "name": "Apple Watch", "bundleId": "com.apple.health" },
"summary": {
"startedAt": "2024-06-15T10:00:00Z",
"durationS": 1800,
"distanceM": 5000,
"isIndoor": false,
"energyKcal": 420,
"avgHeartRateBpm": 150,
"maxHeartRateBpm": 175
},
"trackPoints": [
{ "t": "2024-06-15T10:00:00Z", "lat": 37.7749, "lon": -122.4194, "ele": 10, "hr": 140, "cad": 160, "pwr": 250, "distM": 0 }
],
"laps": [
{ "endedAt": "2024-06-15T10:10:00Z", "distanceM": 1609, "durationS": 600, "avgHeartRateBpm": 148 },
{ "endedAt": "2024-06-15T10:20:00Z", "distanceM": 1609, "durationS": 600, "avgHeartRateBpm": 152 }
]
}
Requires authentication. schemaVersion must be 1. Summary distance/duration are authoritative. Outdoor runs require at least two track points with lat, lon, and t. Indoor runs (isIndoor: true) may omit GPS; provide summary.distanceM > 0 and/or a cumulative distM stream for splits and time series. Summary-only indoor payloads persist stats without a route. A payload with neither summary distance nor a distM stream returns 400. Max 20,000 track points. Optional laps (schema v1 additive; omit on older clients): ordered lap ends with endedAt (ISO UTC), distanceM, durationS (timer; pauses excluded on device), optional avgHeartRateBpm. Tempo persists them as device_lap only when 2+ rows have distance > 0 or duration > 0. Response shape matches single-file import (created / updated / skipped plus workout id). Raw payload is stored in RawHealthKitData. Duplicate check: HealthKitUuid identity first (repeat POST → skipped without re-deriving geometry, except when the existing Workout has no device_lap and the body has 2+ kept laps → attach those rows, refresh RawHealthKitData, return updated); then start/distance/duration as a cross-source backstop (e.g. GPX already imported). Matching HealthKit imports stamp HealthKitUuid onto the existing row when it was null so list/detail can badge. GET /workouts and GET /workouts/{id} expose top-level healthKitUuid (nullable). Indoor imports leave route null when there are no GPS coordinates.
Bulk Import¶
The command center uploads a Strava ZIP in 512 KiB chunks during first-run onboarding or Settings → Migrate / restore (not the day-to-day Import page). Bruno/curl can still POST the whole ZIP.
POST /workouts/import/jobs
Content-Type: application/json
{ "kind": "strava_bulk", "filename": "export.zip", "byteSize": 12345, "unitPreference": "metric" }
Returns 201 (receiving). Then sequential:
Complete returns 202 when every chunk index is present and the assembled length equals byteSize. A size mismatch does not enqueue or run intake.
Whole-file adapter (Bruno/curl):
Returns 202 with the same job document. Poll GET /workouts/import/jobs/{id} until completed or failed. GET /workouts/import/jobs/current is 200 plus that document or 204. A second start while a job is receiving/queued/running is 409 with the active id. DELETE /workouts/import/jobs/{id} cancels; already imported Workouts stay.
Supports files up to 500MB. Per activity file uses the same intake outcomes (created / updated / skipped). ZIP extract, activities.csv, non-run skip, and Strava media copy stay in bulk, not in intake.
Import Tempo Export¶
Restore a Tempo export ZIP as an import job (same rails as Strava bulk):
POST /workouts/import/jobs
Content-Type: application/json
{ "kind": "tempo_export", "filename": "tempo-export.zip", "byteSize": 12345 }
Then the same chunk PUT + complete flow as Strava (unitPreference must not be sent — 400 if present). Command center uses this path.
Whole-file adapter (Bruno/curl):
Returns 202 with a job document (kind: tempo_export). Poll GET /workouts/import/jobs/{id} until completed or failed. Structural ZIP/manifest failure → failed; finished with per-item problems → completed with nested statistics / warnings / errorMessages on the job document. DELETE /workouts/import/jobs/{id} cancels; already restored data stays. At most one import job runs at a time across Strava and Tempo (409 if another is active).
Import job lifecycle¶
Statuses: receiving → queued → running → completed or failed. Cancel and API-startup interrupt end as failed with errorMessage cancelled or interrupted (no auto-resume; retry the ZIP). If the only active job is receiving and no chunk (or create) has happened for 15 minutes, a new create replaces it. At most one job in receiving | queued | running across kinds.
Example job document (fields present depend on kind and status):
{
"id": "…",
"kind": "strava_bulk",
"status": "running",
"filename": "export.zip",
"byteSize": 12345,
"bytesReceived": 12345,
"processed": 12,
"total": 40,
"successful": 10,
"updated": 1,
"skipped": 1,
"errors": 0,
"errorDetails": [],
"errorMessage": null
}
For tempo_export, flat counters are rollups; nested statistics / warnings / errorMessages come from ResultJson (errorDetails stays []; updated stays 0).
Export All Data¶
Export all user data in a portable ZIP format:
Authentication: Required
Response:
- Content-Type: application/zip
- Content-Disposition: attachment; filename="tempo-export-{timestamp}.zip"
- Body: ZIP file stream
The export includes: - All workouts with complete data (stats, metadata, JSONB fields) - Workout routes (GeoJSON) - Workout splits and time series data - Media files (photos and videos) as binary files - Raw workout files (GPX, FIT, CSV) as binary files - Shoes with GUIDs and relationships - User settings (heart rate zones, unit preferences, default shoe) - Best efforts
Export Format Structure:
tempo-export-{timestamp}.zip
├── manifest.json # Export metadata and version info
├── data/ # JSON files with all database records
│ ├── settings.json
│ ├── shoes.json
│ ├── workouts.json
│ ├── routes.json
│ ├── splits.json
│ ├── time-series.json
│ ├── media-metadata.json
│ └── best-efforts.json
├── workouts/ # Binary files organized by workout
│ ├── {workoutId}/
│ │ ├── raw/ # Original workout files
│ │ └── media/ # Photos and videos
│ │ └── {mediaId}/
│ │ └── {filename}
└── README.txt # Export format documentation
Manifest File (manifest.json):
{
"version": "1.0.0",
"tempoVersion": "2.0.0",
"exportDate": "2024-01-15T10:30:00Z",
"exportedBy": "username",
"statistics": {
"workouts": 150,
"shoes": 5,
"mediaFiles": 45,
"totalSizeBytes": 1234567890,
"settings": 1,
"routes": 150,
"splits": 1500,
"timeSeries": 45000,
"bestEfforts": 10
},
"dataFormat": {
"settings": "data/settings.json",
"shoes": "data/shoes.json",
"workouts": "data/workouts.json",
"routes": "data/routes.json",
"splits": "data/splits.json",
"timeSeries": "data/time-series.json",
"mediaMetadata": "data/media-metadata.json",
"bestEfforts": "data/best-efforts.json"
}
}
Notes: - Export may take several minutes for large datasets - Missing media files are logged as warnings but don't fail the export - GUIDs are preserved to maintain relationships - Export format version is 1.0.0 (compatible with future import feature)
List Workouts¶
Get all workouts with filtering and pagination:
Query parameters:
- startDate - Filter by start date (ISO 8601)
- endDate - Filter by end date (ISO 8601)
- page - Page number (default: 1)
- pageSize - Items per page (default: 20)
List and detail responses include nullable healthKitUuid for tempo-ios already-imported badging.
List and detail also include nullable timerTimeS (FIT timer seconds when present). durationS remains elapsed (wall clock); do not treat it as timer time.
List HealthKit UUIDs¶
Returns all non-null HealthKitUuid values stored on workouts (nulls omitted). Used by tempo-ios to badge already-imported runs without paging GET /workouts.
Requires authentication. Response:
Empty library or GPX/FIT-only library → { "uuids": [] }. Unpaginated; no Tempo workout ids.
Get Workout¶
Get detailed workout information:
Detail includes top-level healthKitUuid when the workout was imported (or stamped) from HealthKit.
Detail also includes nullable timerTimeS next to movingTimeS. durationS is always elapsed.
Detail also includes heartRateZoneTimes: five { zone, timeS } objects (zones 1–5) or JSON null, computed live from HR time series and current Settings zones (same buckets as Relative Effort). Not present on list GET.
Update Workout¶
Update workout details (e.g., activity name, shoe assignment):
PATCH /workouts/{id}
Content-Type: application/json
{
"activityName": "New Activity Name",
"shoeId": "guid-here"
}
Fields:
- activityName (string, optional) - New activity name
- shoeId (Guid, optional, nullable) - Shoe ID to assign to this workout. Set to null to remove shoe assignment.
Delete Workout¶
Permanently deletes the workout and all associated data.
Crop Workout¶
Remove time from the start and/or end:
POST /workouts/{id}/crop
Content-Type: application/json
{
"removeFromStartSeconds": 60,
"removeFromEndSeconds": 30
}
Recalculate Relative Effort (Single Workout)¶
Recalculate Splits (Single Workout)¶
Get Recalculation Count (Relative Effort)¶
Get count of workouts eligible for relative effort recalculation:
Recalculate All Relative Effort¶
Recalculate relative effort for all qualifying workouts:
Get Recalculation Count (Splits)¶
Get count of workouts eligible for split recalculation:
Recalculate All Splits¶
Recalculate splits for all workouts:
Get Workout Time Series¶
Paginated WorkoutTimeSeries samples for charts and analysis:
Query parameters:
- page - Page number (default: 1)
- pageSize - Items per page (default: 1000, maximum: 5000)
Each item includes elapsedSeconds plus optional sensors: distanceM, heartRateBpm, cadenceRpm (steps/min), powerWatts, speedMps, gradePercent, elevationM, temperatureC, verticalSpeedMps. Null fields mean that sensor was not recorded at that sample. Samples are sparse (not every elapsed second). The server does not interpolate. Ordering is ascending by elapsedSeconds, then by row id.
The command-center overview client follows pages until complete or 20,000 samples and still renders what it loaded.
Get Similar Routes¶
Find workouts with similar routes to a given workout:
Query Parameters:
- maxResults (int, optional) - Maximum number of results to return (default: 10)
- maxYears (int, optional) - Maximum number of years to look back (default: 2)
Returns a list of similar route matches with:
- workoutId - ID of the similar workout
- similarityScore - Route similarity score (0-100%)
- distanceDifferenceM - Distance difference in meters
- timeDifferenceS - Time difference in seconds
- paceDifferenceS - Pace difference in seconds per km
Statistics¶
Weekly Statistics¶
Yearly Statistics¶
Relative Effort Statistics¶
Combined Yearly and Weekly Stats¶
Available Periods¶
Available Years¶
Best Efforts¶
Get your fastest times for standard distances:
Returns your best effort times for all supported distances (400m, 1/2 mile, 1K, 1 mile, 2 mile, 5K, 10K, 15K, 10 mile, 20K, Half-Marathon, 30K, Marathon). Best efforts are calculated from any segment within any workout, not just workouts of that exact distance.
Response format:
{
"distances": [
{
"distance": "5K",
"distanceM": 5000,
"timeS": 1200,
"workoutId": "guid-here",
"workoutDate": "2025-01-15T10:30:00Z"
}
]
}
Recalculate Best Efforts¶
Recalculate all best efforts across all workouts:
Performs a full recalculation of all best efforts. This may take some time depending on the number of workouts and time series data.
Response format:
Media¶
Upload Media¶
Maximum file size: 50MB (configurable).
List Media¶
Get Media¶
Returns the media file.
Delete Media¶
Settings¶
Get Heart Rate Zones¶
Update Heart Rate Zones¶
PUT /settings/heart-rate-zones
Content-Type: application/json
{
"method": "Karvonen",
"age": 30,
"restingHeartRate": 60,
"zones": [...]
}
Update Zones with Recalculation¶
POST /settings/heart-rate-zones/update-with-recalc
Content-Type: application/json
{
"method": "Karvonen",
"age": 30,
"restingHeartRate": 60,
"recalculateAll": true
}
Get Unit Preference¶
Update Unit Preference¶
Get Default Shoe¶
Get the currently set default shoe:
Returns the default shoe object or null if no default is set.
Set Default Shoe¶
Set a shoe as the default for automatic assignment to new workouts:
Set shoeId to null to remove the default shoe.
Get intervals.icu connection¶
Always 200. Never returns the API key.
Disconnected:
Connected:
{
"connected": true,
"enabled": true,
"lastSuccessfulSyncAt": null,
"lastSyncAttemptAt": null,
"lastError": null
}
Connect intervals.icu¶
Probes intervals.icu as athlete 0 before persist. 400 if the key is blank or rejected (401/403). 503 if intervals.icu is unreachable. A second PUT while connected replaces the ciphertext only (connectedAt / lastSuccessfulSyncAt stay). 200 with the same status document as GET (no key).
Disconnect intervals.icu¶
Deletes the connection row. 204 if it was already gone. Workouts and Workout external identities stay.
Re-enable intervals.icu¶
Probes with the stored key and turns live sync back on. 404 if there is no connection. 400 if decrypt or probe fails. 503 if intervals.icu is unreachable. 200 with the status document.
Sync intervals.icu now¶
Wakes the live sync worker. 202 when connected and enabled (does not import on the request thread), including coalesced wakes while a tick is already in flight or pending. 204 when there is no connection or sync is disabled.
Shoes¶
List Shoes¶
Get all shoes with calculated mileage:
Returns a list of all shoes with their current total mileage (calculated from assigned workouts plus initial mileage).
Create Shoe¶
Create a new shoe:
POST /shoes
Content-Type: application/json
{
"brand": "Nike",
"model": "Pegasus 40",
"initialMileageM": 0.0
}
Fields:
- brand (string, required, max 100 chars) - Shoe manufacturer
- model (string, required, max 100 chars) - Shoe model name
- initialMileageM (double, optional) - Initial mileage in meters when adding the shoe
Update Shoe¶
Update shoe details:
PATCH /shoes/{id}
Content-Type: application/json
{
"brand": "Nike",
"model": "Pegasus 41",
"initialMileageM": 50.0
}
All fields are optional. Only provided fields will be updated.
Delete Shoe¶
Delete a shoe:
When a shoe is deleted, all workouts assigned to that shoe will have their shoeId set to null. The workouts themselves are not deleted.
Get Shoe Mileage¶
Get calculated total mileage for a specific shoe:
Query parameters:
- unitPreference (string, optional) - "metric" or "imperial" (defaults to user's preference)
Returns the total mileage in the requested units (sum of all assigned workout distances plus initial mileage).
System¶
Version¶
Returns application version, build date, and git commit.
Health (liveness)¶
Public endpoint (no authentication required). Returns 200 with { "status": "healthy" } while the API process is accepting HTTP. Does not check Postgres — this can be 200 while the database is down. Orchestrators that need to know whether this instance can serve should use /ready.
Ready (readiness)¶
Public endpoint (no authentication required). Returns 200 when Postgres is reachable within 2 seconds:
Returns 503 when the database check fails or times out:
Bodies never include connection strings, hostnames, or exception text. Compose API healthchecks and reverse-proxy “can I send traffic” probes should use this URL (curl -f already fails on 503).
Error Responses¶
All endpoints may return standard HTTP error codes:
400 Bad Request- Invalid request data401 Unauthorized- Missing, invalid, or revoked credentials on a protected route403 Forbidden- Insufficient permissions404 Not Found- Resource not found500 Internal Server Error- Server error
Many validation errors use a simple JSON shape such as:
401 authentication challenges (invalid JWT, invalid or revoked API key, or missing auth) typically return:
Other endpoints may include extra fields (for example details) where noted above.
OpenAPI contract¶
The canonical machine-readable API document is openapi.json in this repository (on the develop integration branch). Use it for client generation and to stay aligned with the read-only CLI.
Interactive Documentation¶
In development mode, interactive API documentation is available at /swagger. This provides:
- Complete endpoint documentation
- Request/response examples
- Interactive testing interface
- XML documentation comments
API Testing¶
A Bruno API testing collection is available in api/bruno/Tempo.Api/ with test requests for all endpoints.