The Vitals REST API is served by apps/api (Fastify). All routes are mounted
under the /api prefix and default to http://localhost:3001 (API_PORT).
Response envelope. Every response uses one envelope shape:
details is optional and present mainly on validation errors.
Common error codes: VALIDATION (400, bad request body/params/query),
NOT_FOUND (404), CONFLICT (409), UNAUTHORIZED (401), MISCONFIGURED (500,
a required env var is unset), INTERNAL (500). OAuth and upstream failures use
specific codes (WHOOP_EXCHANGE, GOOGLE_EXCHANGE, INVALID_STATE, etc.).
Ranges. Endpoints that accept a range query string take either a preset
(7d, 14d, 30d, 90d — any <n>d) or an explicit window
YYYY-MM-DD..YYYY-MM-DD. Defaults vary per route (7 or 30 days). The resolved
{ start, end, days } is echoed back in data.range.
Content type. All routes are application/json, except POST /api/ask which
streams Server-Sent Events (text/event-stream).
CORS. The API uses an explicit origin allowlist (CORS_ORIGINS, default
http://localhost:5173,http://localhost:3001); arbitrary origins are not reflected.
Service liveness. Returns status, uptime, timestamp, and the latest sync per source.
{ "ok": true, "data": { "status": "operational", "uptimeSec": 0, "timestamp": "…", "lastSyncs": { /* per source */ } } }Connection status for the enabled consensus devices only (fitbit, whoop,
oura, apple). A device the user has switched off in Settings is omitted
— it is never reported as "disconnected" (a dormant WHOOP/Oura shouldn't look
offline when you're simply not wearing it). Strava is an activity source, not
a consensus device, so it is excluded here; use GET /api/settings for the
full integration list (Strava included).
"Connected" means the integration is working (recent successful sync that produced rows, or recent data) — not necessarily that today's row exists yet.
{ "ok": true, "data": { "date": "YYYY-MM-DD", "statuses": [
{ "source": "fitbit", "connected": true, "hasTodayData": false,
"lastSeen": "…", "lastSyncOk": true, "message": "connected · today's data syncing" }
] } }What the stack has configured, so the UI can gate AI-backed actions. Reveals booleans + local file paths only — no secrets. Includes a ready-to-paste Claude Desktop MCP config snippet with absolute paths resolved.
{ "ok": true, "data": {
"claudeApiConfigured": true, "anthropicApiConfigured": false,
"whoopConfigured": true, "ouraConfigured": false, "appleIngestConfigured": true,
"googleConfigured": true, "stravaConfigured": true,
"mcp": { "serverName": "vitals-command-center", "transport": "stdio",
"paths": { /* … */ }, "claudeDesktopConfig": { "snippet": "…", "command": "…", "args": [] } }
} }googleConfigured is GOOGLE_CLIENT_ID (the Fitbit/bridge OAuth app);
stravaConfigured is STRAVA_CLIENT_ID. claudeApiConfigured is always true
(AI runs on the on-box CLI agent regardless of the Anthropic key); the field name
is retained for the existing web client. anthropicApiConfigured reflects the
optional direct-API key.
Daily summary rows over a range. Query: range (default 7d).
{ "ok": true, "data": { "range": { "start": "…", "end": "…", "days": 7 }, "rows": [ /* daily_summary */ ] } }A single day's summary. :date must be YYYY-MM-DD. 404 NOT_FOUND if absent.
Sleep sessions over a range. Query: range (default 7d).
{ "ok": true, "data": { "range": { … }, "sessions": [ /* per-source sessions */ ] } }Sleep sessions for one date.
{ "ok": true, "data": { "date": "YYYY-MM-DD", "sessions": [ … ] } }Workouts over a range. Query: range (default 30d), optional sport
(case-insensitive exact match filter).
{ "ok": true, "data": { "range": { … }, "workouts": [ … ] } }A single workout plus its rich detail (Strava splits/laps/segments and the
reconstructed run/walk intervals). 404 NOT_FOUND if the id is unknown.
{ "ok": true, "data": { "workout": { /* summary row */ }, "detail": { /* or null */ } } }detail is the WorkoutDetail shape — persisted as JSON on the workout row,
normally backfilled by the sync job:
{
"splits": [ { "index": 1, "distanceKm": 1.0, "elapsedSeconds": 312, "movingSeconds": 300,
"paceSecondsPerKm": 300, "avgHr": 162, "elevationGain": 4 } ],
"laps": [ { "index": 1, "name": null, "distanceKm": 2.0, "elapsedSeconds": 600,
"movingSeconds": 590, "avgHr": 165, "maxHr": 178, "avgPaceSecondsPerKm": 295 } ],
"segments": [ { "id": 12345, "name": "Track straight", "distanceKm": 0.4, "elapsedSeconds": 90,
"avgHr": 170, "maxHr": 180, "prRank": 1 } ],
"intervals": [ { "index": 1, "kind": "work", "distanceKm": 0.4, "durationSeconds": 88,
"avgPaceSecondsPerKm": 220, "avgHr": 172 },
{ "index": 2, "kind": "recovery", "distanceKm": 0.1, "durationSeconds": 60,
"avgPaceSecondsPerKm": null, "avgHr": 150 } ],
"avgCadence": 174, "totalElevationGain": 28, "avgWatts": null, "sufferScore": 64,
"gearName": "…", "deviceName": "…", "description": null, "fetchedAt": "…"
}intervals are run (work) / walk-or-stand (recovery) bouts detected from the
Strava velocity stream — they reconstruct interval structure that
distance-auto-splits and a single lap can't show (null when no stream exists).
On-demand backfill. If a Strava workout still lacks detail (e.g. it was seeded
before the integration), the route fetches it from Strava on demand, persists it
(and any newly-learned calories), and returns it — but only when
STRAVA_CLIENT_ID is set, Strava is enabled in integration_settings, and a
token file exists. A fetch failure degrades to detail: null rather than erroring.
A single metric's time series with a 7-day moving average and a trend delta.
Query:
metric(required) — one ofhrv,rhr,sleep_hours,recovery,strain,steps,readiness,temp_deviation,spo2,vo2max.range— default30d.
Returns per-device points (each tagged with its source), a consensus
series for metrics that have one (hrv, rhr, sleep_hours), the
movingAverage7d series, and a delta ({ pct, direction: up|down|flat }).
{ "ok": true, "data": {
"metric": "hrv", "range": { … },
"points": [ { "date": "…", "value": 62, "source": "oura" }, { "date": "…", "value": null, "source": "consensus" } ],
"movingAverage7d": [ { "date": "…", "value": 60 } ],
"delta": { "pct": 4.2, "direction": "up" }
} }Per-device readings for one date with a divergence-based confidence per metric
(HRV, RHR, sleep duration, SpO₂). Query: date (required, YYYY-MM-DD).
404 NOT_FOUND if no summary for that date.
{ "ok": true, "data": { "date": "YYYY-MM-DD", "comparison": [
{ "key": "hrv", "label": "HRV (rMSSD)", "unit": "ms", "toleranceAbs": 8,
"devices": [ { "device": "oura", "value": 61 }, { "device": "whoop", "value": 58 } ],
"confidence": "HIGH" }
] } }List active habits → { "ok": true, "data": { "habits": [ … ] } }.
Create a habit. Body (Zod createHabitSchema): name, category, type,
optional unit, targetValue, sortOrder. Returns the created habit.
Update a habit (Zod updateHabitSchema). 404 NOT_FOUND if the id is unknown.
Soft-delete a habit → { "ok": true, "data": { "deleted": "<id>" } }.
Habit log entries over a range. Query: range (default 30d).
Log a habit check-in. Body (Zod logHabitSchema): habitId, value, optional
date (defaults to today). Returns the stored log entry.
Current and longest streaks per habit → { "ok": true, "data": { "streaks": [ … ] } }.
Habit↔metric correlations. Currently returns an empty set with a note (correlation engine is a future phase).
Today's summary, the latest stored daily briefing, and computed insights.
{ "ok": true, "data": { "date": "…", "summary": { … }, "briefing": { … }, "insights": [ … ] } }A previously-stored daily briefing for a date. 404 NOT_FOUND if none.
Generate (and store) a daily briefing via the on-box AI provider chain. Body
(optional): { "date": "YYYY-MM-DD" }, defaults to today. Requires a
daily_summary for that date (else 400 NO_DATA); AI failures return 502 CLAUDE_FAILED. Returns the stored briefing.
Free-form question answered by the on-box AI provider chain over your recent
data. Body (Zod askSchema): question, optional context.date.
Response is Server-Sent Events (text/event-stream). The CLI providers are
non-streaming, so the answer arrives as a single event followed by [DONE]:
data: {"text":"…the answer…"}
data: [DONE]
On error: data: {"error":"…"} then the stream ends.
Trigger a sync. Body (optional): days (1–1095, the backfill window),
includeApple (boolean). Fire-and-forget — returns immediately. Returns
409 CONFLICT if a sync is already running.
{ "ok": true, "data": { "triggered": true, "rangeDays": 7 } }Last sync time, whether a sync is running, and per-device sync state.
{ "ok": true, "data": { "lastSyncAt": "…", "running": false,
"perDevice": [ { "source": "fitbit", "lastSyncAt": "…", "ok": true, "message": null } ] } }User-facing preferences for the web Settings modal. App-level prefs live in
app_settings; per-source toggles live in integration_settings. Every mutation
returns the full settings payload so the client can replace its state in one
round-trip.
App preferences plus the fully-resolved integration list (registry metadata + user settings + live connectivity, across all five integrations including Strava).
{ "ok": true, "data": {
"app": { "autoSyncEnabled": true, "aiEnabled": true, "aiAutoSummary": true },
"integrations": [
{ "id": "fitbit", "kind": "wearable", "enabled": true, "autoSync": true,
"syncIntervalMinutes": 240, "configured": true, "connected": true,
"hasTodayData": false, "lastSeen": "…", "lastSyncOk": true, "message": null, /* …meta */ },
{ "id": "strava", "kind": "activity", "enabled": true, /* … */ }
]
} }Update app-level prefs. Body (any subset, Zod-validated): autoSyncEnabled,
aiEnabled, aiAutoSummary (all booleans). aiEnabled is the master AI gate;
aiAutoSummary controls automatic daily-brief generation. Returns the full
settings payload.
Toggle a single integration. :id ∈ fitbit | apple | strava | whoop | oura
(400 VALIDATION otherwise). Body (any subset): enabled (bool), autoSync
(bool), syncIntervalMinutes (int 15–1440). Returns the full settings payload.
Receives a payload from the iOS Health Auto Export app and upserts it as
Apple daily rows, sleep sessions, and workouts (all tagged source: apple).
Auth (required): send the shared secret as either header
x-apple-ingest-secret: <APPLE_INGEST_SECRET> or Authorization: Bearer <APPLE_INGEST_SECRET>. The route fails closed: if APPLE_INGEST_SECRET is
unset, every request is rejected with 401 UNAUTHORIZED.
Body: { "data": { "metrics": [ … ], "workouts": [ … ] } } (Health Auto
Export format). Malformed bodies return 400 VALIDATION / 400 PARSE_ERROR.
{ "ok": true, "data": { "dailyUpserted": 3, "sleepUpserted": 3, "workoutUpserted": 2, "dates": [ "…" ] } }These are browser-redirect endpoints, not JSON APIs — open them in a browser to
connect a source. They are no-ops if the corresponding client id is unset
(returns 500 MISCONFIGURED).
GET /api/auth/whoop/authorize— redirects to WHOOP's consent screen.GET /api/auth/whoop/callback?code&state— exchanges the code, persists tokens, redirects toWHOOP_POST_AUTH_REDIRECT. Bad/expired state →400 INVALID_STATE; exchange failure →502 WHOOP_EXCHANGE.GET /api/auth/whoop/status→{ "ok": true, "data": { "connected": bool } }.
GET /api/auth/google/authorize— redirects to Google's consent screen.GET /api/auth/google/callback?code&state— exchanges the code, persists tokens, redirects toGOOGLE_POST_AUTH_REDIRECT.GET /api/auth/google/status→{ "ok": true, "data": { "connected": bool, "reauthNeeded": bool } }(reauthNeededistrueafter a token refresh hitinvalid_grant).
Strava is the activity source — runs/cardio that sync into workouts (with
rich detail), not a daily-vitals consensus device. OAuth 2.0, mirroring the
WHOOP flow.
GET /api/auth/strava/authorize— redirects to Strava's consent screen.500 MISCONFIGUREDifSTRAVA_CLIENT_IDis unset.GET /api/auth/strava/callback?code&state— exchanges the code, persists tokens, redirects toSTRAVA_POST_AUTH_REDIRECT. Bad/expired state →400 INVALID_STATE; user denial →400 STRAVA_DENIED; exchange failure →502 STRAVA_EXCHANGE.GET /api/auth/strava/status→{ "ok": true, "data": { "connected": bool } }.
Oura uses a personal access token (
OURA_PAT) and has no OAuth flow.
In production, the API also serves the built web dashboard from apps/web/dist
at /, with an SPA fallback (any non-/api GET that isn't a static file returns
index.html). In development the dashboard is served separately by Vite, so this
path no-ops until the web app is built.