Skip to content

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 like TEMPO_API_KEY and send Authorization: Bearer <key>. The server distinguishes keys from JWTs by the tmp_ 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):

GET /auth/api-keys

Returns metadata per key: id, label, keyPrefix, createdAt, revokedAt.

Revoke (session only):

DELETE /auth/api-keys/{id}

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

GET /auth/me

Requires authentication via JWT (cookie or Bearer) or a valid API key (Authorization: Bearer tmp_…).

Response includes:

  • userId, username, createdAt, lastLoginAt
  • onboardingCompleted (boolean) — false for new registrations until first-run setup finishes; existing users are backfilled true by migration

Complete Onboarding

POST /auth/onboarding/complete

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

POST /auth/logout

Clears the authentication cookie.

Check Registration Availability

GET /auth/registration-available

Returns whether registration is currently available.

Workouts

Import Workout

Import a single workout file (GPX, FIT, or CSV):

POST /workouts/import
Content-Type: multipart/form-data

file: [workout file]

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:

PUT /workouts/import/jobs/{id}/chunks/{index}?total={n}
Content-Type: application/octet-stream
POST /workouts/import/jobs/{id}/complete

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):

POST /workouts/import/bulk
Content-Type: multipart/form-data

file: [ZIP file]

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):

POST /workouts/import/export
Content-Type: multipart/form-data

file: [Tempo export ZIP]

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:

POST /workouts/export

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:

GET /workouts?startDate=2025-01-01&endDate=2025-12-31&page=1&pageSize=20

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.

GET /workouts/healthkit-uuids

Requires authentication. Response:

{
  "uuids": [
    "A1B2C3D4-E5F6-7890-ABCD-EF1234567890",
    "B2C3D4E5-F6A7-8901-BCDE-F12345678901"
  ]
}

Empty library or GPX/FIT-only library → { "uuids": [] }. Unpaginated; no Tempo workout ids.

Get Workout

Get detailed workout information:

GET /workouts/{id}

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

DELETE /workouts/{id}

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)

POST /workouts/{id}/recalculate-effort

Recalculate Splits (Single Workout)

POST /workouts/{id}/recalculate-splits

Get Recalculation Count (Relative Effort)

Get count of workouts eligible for relative effort recalculation:

GET /workouts/recalculate-relative-effort/count

Recalculate All Relative Effort

Recalculate relative effort for all qualifying workouts:

POST /workouts/recalculate-relative-effort

Get Recalculation Count (Splits)

Get count of workouts eligible for split recalculation:

GET /workouts/recalculate-splits/count

Recalculate All Splits

Recalculate splits for all workouts:

POST /workouts/recalculate-splits

Get Workout Time Series

Paginated WorkoutTimeSeries samples for charts and analysis:

GET /workouts/{id}/time-series?page=1&pageSize=1000

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:

GET /workouts/{id}/similar-routes?maxResults=10&maxYears=2

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

GET /stats/weekly?startDate=2025-01-01&endDate=2025-01-07

Yearly Statistics

GET /stats/yearly?year=2025

Relative Effort Statistics

GET /stats/relative-effort?startDate=2025-01-01&endDate=2025-12-31

Combined Yearly and Weekly Stats

GET /stats/yearly-weekly?year=2025&weekStartDate=2025-01-01

Available Periods

GET /stats/available-periods

Available Years

GET /stats/available-years

Best Efforts

Get your fastest times for standard distances:

GET /stats/best-efforts

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:

POST /stats/best-efforts/recalculate

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:

{
  "message": "Best efforts recalculated successfully",
  "count": 13
}

Media

Upload Media

POST /workouts/{id}/media
Content-Type: multipart/form-data

file: [image or video file]

Maximum file size: 50MB (configurable).

List Media

GET /workouts/{id}/media

Get Media

GET /workouts/{id}/media/{mediaId}

Returns the media file.

Delete Media

DELETE /workouts/{id}/media/{mediaId}

Settings

Get Heart Rate Zones

GET /settings/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

GET /settings/unit-preference

Update Unit Preference

PUT /settings/unit-preference
Content-Type: application/json

{
  "unit": "metric"
}

Get Default Shoe

Get the currently set default shoe:

GET /settings/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:

PUT /settings/default-shoe
Content-Type: application/json

{
  "shoeId": "guid-here"
}

Set shoeId to null to remove the default shoe.

Get intervals.icu connection

GET /settings/intervals-icu

Always 200. Never returns the API key.

Disconnected:

{ "connected": false }

Connected:

{
  "connected": true,
  "enabled": true,
  "lastSuccessfulSyncAt": null,
  "lastSyncAttemptAt": null,
  "lastError": null
}

Connect intervals.icu

PUT /settings/intervals-icu
Content-Type: application/json

{
  "apiKey": "personal-key"
}

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

DELETE /settings/intervals-icu

Deletes the connection row. 204 if it was already gone. Workouts and Workout external identities stay.

Re-enable intervals.icu

POST /settings/intervals-icu/enable

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

POST /settings/intervals-icu/sync

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:

GET /shoes

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:

DELETE /shoes/{id}

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:

GET /shoes/{id}/mileage?unitPreference=metric

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

GET /version

Returns application version, build date, and git commit.

Health (liveness)

GET /health

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)

GET /ready

Public endpoint (no authentication required). Returns 200 when Postgres is reachable within 2 seconds:

{ "status": "ready", "checks": { "database": "ok" } }

Returns 503 when the database check fails or times out:

{ "status": "not_ready", "checks": { "database": "fail" } }

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 data
  • 401 Unauthorized - Missing, invalid, or revoked credentials on a protected route
  • 403 Forbidden - Insufficient permissions
  • 404 Not Found - Resource not found
  • 500 Internal Server Error - Server error

Many validation errors use a simple JSON shape such as:

{
  "error": "Error message"
}

401 authentication challenges (invalid JWT, invalid or revoked API key, or missing auth) typically return:

{
  "error": "Invalid or expired credentials"
}

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.

Next Steps