REST API v1
The VitalTrends API lets you query your own health data programmatically. Build scripts, notebooks, automations, or pipe your data into any tool you like.
Authentication
All API requests must include your API key in the Authorization header:
Authorization: Bearer YOUR_API_KEY
Keep your API key secret. Regenerate it from the Developer settings page if it is ever exposed.
Base URL
https://vitaltrends.net/api/v1
All public REST endpoints are versioned by URL path. The current stable version is v1.
All endpoints return JSON. Dates are ISO 8601 strings (YYYY-MM-DD). Timestamps are UTC unless a field is explicitly documented as a local date.
Versioning
| Field | Value |
|---|---|
2026-07-21 | Training Log now imports original MacroFactor .xlsx workbooks as well as CSV exports. Workbook dates, pounds, warm-up sets, RIR, timed sets, and distance sets are normalized automatically. Standard MacroFactor exercise names are assigned to the Training Log muscle map even though the export has no muscle-group column. |
| API version | v1 |
| Docs version | 2026-07-21 |
| Base path | /api/v1 |
| Stability | Additive fields may be added to v1; breaking response or behavior changes require a new versioned path such as /api/v2. |
v1 changelog
| Date | Change |
|---|---|
2026-07-21 | dedup_mode=preview is now strictly read-only for new, fuzzy-match, and exact-identity requests. Preview responses distinguish create, merge, and update and include complete normalized timing. Agent-row imports now validate dedup mode and include behavior options in idempotency checks. |
2026-07-21 | Workout-log exercise responses now include the resolved muscle_group. Unknown exercise names are retained for review, and mappings selected in Training Log apply to historical analytics and future matching inputs. |
2026-07-19 | Agent-added training sets now join strongly overlapping unified device workouts, and either source link opens the complete grouped session. |
2026-07-19 | Workout create, agent-row import, and PATCH requests now accept ended_at, local end_time, or duration_seconds. Responses add local_end_time and duration_minutes. |
2026-07-19 | Added GET /training/muscle-groups/usage for chart-ready muscle group training volume over a date range or all time. |
2026-07-19 | Workout uploads accept an optional muscle_group per exercise. Canonical values and common aliases are validated and normalized for Training Log muscle analytics. |
2026-07-08 | Workout write API added: upload strength workouts with a workout:write token via /workout-logs (structured JSON) and /workout-logs/import (flat agent rows or a MacroFactor export), with idempotency, deduplication, editing, and media. See Uploading workouts. |
2026-07-01 | /apple-health?type=<type> responses are cached per user and exact query for 5 minutes and include Cache-Control: private, max-age=300. |
2026-06-30 | Apple Health wrist temperature is now documented for /apple-health?type=sleeping_wrist_temp, /apple-health/daily-summary, and /apple-health/daily. |
2026-06-23 | Withings Sleep Analyzer data added at /withings/sleeps. Withings measurement responses now document body composition, pulse, blood pressure, and source group fields. |
2026-06-11 | Glucose endpoints added: /glucose returns CGM analytics, time in range, AGP, daily aggregates, and day explorer context; /glucose/readings returns paginated raw blood_glucose readings when the user has Apple Health glucose samples. |
2026-05-19 | /workouts/unified now includes stable session_id, source_records, and field_sources fields so clients can show one physical session while preserving raw provider provenance. Add include=heart_rate_series to return per-source HR overlays on session rows. |
2026-05-11 | /whoop/daily now includes the current local-day open cycle when present and adds an additive is_partial flag. Closed-cycle fields keep their existing meaning. |
2026-05-09 | Hevy workout endpoints added: /workouts/unified now includes Hevy, and /workouts/hevy returns Hevy workouts with exercises and sets. |
2026-05-09 | /whoop/recovery-status added recovery freshness timestamps for agents and automations; /whoop/daily now includes recovery sync/change timestamps. |
2026-05-08 | Oura data endpoints added for daily sleep, sleep sessions, readiness, activity, workouts, SpO2, stress, resilience, and VO2 max. |
2026-05-05 | Date-only start and end filters on timestamp-backed list endpoints now cover the full calendar day in the user's profile timezone. |
2026-05-05 | /whoop/daily added cycle_start_date and cycle_end_date while keeping the legacy date field unchanged. |
2026-05-05 | Rate limit documentation corrected to match the active API limiter: 120 requests per minute per API key. |
Endpoints
Workouts
| Method | Path | Description |
|---|---|---|
| GET | /workouts/unified |
Cross-source workout feed with WHOOP, Apple Health, Oura, and Hevy deduplicated into sessions, while preserving previously imported archived activities. Each row includes session_id, canonical provider fields, source_records, and field_sources; append include=heart_rate_series for per-source HR overlay samples. |
| GET | /workouts/hevy |
Hevy workout records with nested exercises and sets |
| GET | /training/muscle-groups/usage |
Chart-ready training volume, percentage, and workout count by muscle group |
curl -s "https://vitaltrends.net/api/v1/workouts/hevy?start=2026-05-01&per_page=10" \
-H "Authorization: Bearer YOUR_API_KEY" | jq .
Muscle group usage
GET /training/muscle-groups/usage summarizes your canonical Training Log across Hevy, API uploads, and imports. amount is lifted training volume in kilograms, calculated as weight_kg × reps; percentage is that group's share of the returned total_amount, and workout_count counts distinct sessions containing the group.
Provide either both start_date and end_date as inclusive local calendar dates, or all_time=true. Date ranges use your profile timezone. Unclassified exercises appear as Other, and a range with no data returns total_amount: 0 with an empty muscle_groups array.
curl -s "https://vitaltrends.net/api/v1/training/muscle-groups/usage?start_date=2026-06-01&end_date=2026-06-30" \
-H "Authorization: Bearer YOUR_API_KEY" | jq .
{
"range": {
"start_date": "2026-06-01",
"end_date": "2026-06-30",
"all_time": false
},
"metric": "volume_kg",
"total_amount": 1234.0,
"muscle_groups": [
{
"muscle_group": "Back",
"amount": 740.4,
"workout_count": 8,
"percentage": 60.0
}
]
}
For full history, call:
curl -s "https://vitaltrends.net/api/v1/training/muscle-groups/usage?all_time=true" \
-H "Authorization: Bearer YOUR_API_KEY" | jq .
Uploading workouts (write API)
Push completed strength workouts into VitalTrends from an agent, script, or another app. Anything you upload appears in your Training Log, in your stats and muscle map, and in the unified workout feed, deduplicated against Hevy and everything else.
workout:write ability. Turn it on under Settings → Developer → Write access. Read-only keys get 403 missing_ability. Your Hevy key is never accepted here; only your VitalTrends API key authenticates writes.
| Method | Path | Description |
|---|---|---|
| POST | /workout-logs/import | Create one session from flat agent rows, or many sessions from a MacroFactor CSV/XLSX export. |
| POST | /workout-logs | Create one session from canonical structured JSON |
| GET | /workout-logs | List your logged sessions (filters: start, end, source, exercise_key) |
| GET | /workout-logs/{id} | One session with exercises, sets, and media |
| PATCH | /workout-logs/{id} | Edit session title, notes, date, start time, end time, duration, or timezone |
| PUT | /workout-logs/{id}/exercises | Replace the whole exercise/set tree |
| DELETE | /workout-logs/{id} | Delete a session you created |
| POST | /workout-media | Upload a machine photo; returns a media_id to attach to an exercise |
Quick start: one push, one session
The simplest path is /workout-logs/import with format: "agent-rows-v1": send one row per exercise and the whole request becomes one session. Set columns (set_1, set_2, …) are read in order, so any number of sets works. The optional muscle_group classifies the exercise, the source column is treated as a per-exercise image path, and notes is preserved verbatim.
Send the real workout start time whenever you want uploaded sets to join a matching WHOOP, Apple Health, or other device session. Also send duration_seconds when you know it. A precise start without a duration can still join a strongly overlapping strength session, but a date-only upload uses noon in the profile timezone and is intentionally not attached by a loose same-day guess.
curl -s -X POST "https://vitaltrends.net/api/v1/workout-logs/import" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6d1f0e60-6f2a-4a1e-9d1a-2f3b4c5d6e7f" \
-d '{
"format": "agent-rows-v1",
"title": "Pull day",
"date": "2026-07-06",
"time": "18:08 WEST",
"end_time": "19:00 WEST",
"timezone": "Europe/Lisbon",
"rows": [
{
"date": "2026-07-06", "time_lisbon": "18:08 WEST",
"exercise": "Lat pulldown",
"equipment": "Hoist Fitness ROC-IT Lat Pulldown",
"muscle_group": "back",
"set_1": "12x52kg", "set_2": "12x52kg", "set_3": "12x61kg",
"total_volume_kg": "1980",
"source": "attachments/2026-07-06-lat-pulldown.jpg",
"notes": "User-provided weights treated as kg."
},
{
"date": "2026-07-06", "time_lisbon": "18:44 WEST",
"exercise": "Overhead press",
"equipment": "Cybex Eagle Overhead Press",
"set_1": "12x31", "set_2": "12x40", "set_3": "12x49",
"total_volume_kg": "1440",
"notes": "User omitted kg, treated as kg by context."
}
]
}' | jq .
Top-level timing is optional. Without it, the earliest valid row time remains the session start. Top-level started_at has highest priority, followed by date plus time, date plus time_lisbon, and then the earliest row. End time is never inferred from exercise rows.
The response is the created session (status 201) with server-derived exercise_key / equipment_key, recomputed total_volume_kg, normalized session timing, and an import summary plus any warnings (for example when a unit was inferred). A CSV body works too: send Content-Type: text/csv with the raw rows and the same ?format=agent-rows-v1.
Import a MacroFactor export
The Training Log import control accepts the original .xlsx workbook downloaded from MacroFactor, plus MacroFactor CSV exports. One file can contain many workouts. To upload through the API, send multipart form data with a maximum file size of 10 MB:
curl -s -X POST "https://vitaltrends.net/api/v1/workout-logs/import" \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "format=macrofactor-xlsx-v1" \
-F "[email protected]" | jq .
Use macrofactor-csv-v1 for a .csv file. Raw CSV is also accepted as the request body with Content-Type: text/csv and ?format=macrofactor-csv-v1.
VitalTrends groups rows by workout date and name, converts the workbook's Excel dates, converts pounds to kilograms for canonical storage, and preserves warm-up/standard set type, reps, RIR-derived RPE, duration, and distance. A repeated import uses deterministic session identities, so it does not create duplicate workouts.
MacroFactor exports do not include a muscle-group column. VitalTrends classifies standard MacroFactor exercise names into the canonical muscle groups below. An unfamiliar exercise remains visible and enters the Training Log review queue instead of receiving a guessed classification.
MacroFactor does not include the workout start time in this export format. Imported sessions therefore use 12:00 in the profile timezone. They remain complete Training Log sessions, but they are not loosely joined to a device workout using date alone.
Canonical structured JSON
For full control, POST /workout-logs with a structured body. Each set is either a "{reps}x{weight}{unit?}" string or an object {"reps": 12, "weight": {"value": 52, "unit": "kg"}}.
curl -s -X POST "https://vitaltrends.net/api/v1/workout-logs" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"external_id": "hermes-2026-07-06-pull",
"title": "Pull day",
"date": "2026-07-06", "time": "18:08 WEST", "end_time": "19:00 WEST",
"timezone": "Europe/Lisbon",
"default_unit": "kg", "dedup_mode": "merge",
"exercises": [
{
"name": "Lat pulldown",
"equipment": "Hoist Fitness ROC-IT Lat Pulldown (RS-2201 visible)",
"muscle_group": "back",
"sets": ["12x52kg", "12x52kg", "12x61kg"],
"media": [{"external_ref": "attachments/2026-07-06-lat-pulldown.jpg"}]
}
]
}' | jq .
Muscle groups
Add the optional muscle_group field to each structured exercise or each agent-rows-v1 row. CSV imports can use a muscle_group column. The value classifies the canonical exercise definition used by Training Log volume analytics and the muscle map.
Use one of these canonical lower-case values:
| Canonical value | Accepted aliases normalized to it |
|---|---|
chest | pec, pecs, pectoral, pectorals |
shoulders | delts, deltoid, deltoids |
back | lat, lats, traps, trapezius, upper_back, lower_back |
arms | arm |
biceps | bicep |
triceps | tricep |
forearms | forearm |
core | abs, abdominals, oblique, obliques |
quadriceps | quad, quads, legs, adductor, adductors |
hamstrings | hamstring |
glutes | glute, abductor, abductors |
calves | calf |
Values are case-insensitive, and spaces or hyphens are normalized like underscores. A supplied value fills a definition that does not already have a muscle classification. Existing classifications, including Hevy catalog data and a user's manual correction, are not overwritten by later uploads. If muscle_group is omitted, VitalTrends uses an existing exercise definition when available; otherwise the exercise remains in Other and is added to the Needs muscle group review queue in Training Log.
Selecting a muscle group in the Training Log review queue, exercise editor, or workout detail page creates a user-specific reusable mapping. The normalized exercise name will resolve automatically on future uploads, while historical muscle maps, percentages, and GET /training/muscle-groups/usage results use the correction immediately. Workout-log detail responses expose the resolved title-case value as exercises[].muscle_group, or null while it remains unmatched.
Sets, units, and time
- Set grammar
{reps}x{weight}{unit?}:12x52kg,12x115lb(converted to kg), or12x52(unit missing, inferred as kg and flagged with a warning). Bodyweight sets can be just12. - Weights are normalized to kilograms; the raw value and unit you sent are preserved, and
unit_inferredrecords whether the unit was assumed. - Time: send an ISO
started_atwith atimezone, or adateplus a localtimewith a zone abbreviation like18:08 WEST. To store workout duration, also send ISOended_at, localend_time, or integerduration_seconds. An end time computes duration; duration computes the end. If both are sent, they must agree within one second. - Overnight sessions: a local
end_timeat or before the start clock time is treated as the next local day. Durations must be from 1 second through 24 hours. - Response timing: responses include UTC
started_atandended_at,duration_seconds, the IANAtimezone, locallocal_timeandlocal_end_time, andduration_minutes. Workouts without an end remain valid and return null end/duration fields. - total_volume_kg is always recomputed server-side from the sets; a value you send is cross-checked and, if it disagrees, surfaced as a warning rather than trusted.
Use ISO timestamps when the agent already knows the exact instants:
{
"started_at": "2026-07-15T16:55:00Z",
"ended_at": "2026-07-15T17:35:00Z",
"timezone": "Europe/Lisbon"
}
Or send a duration and let VitalTrends derive the end:
{
"date": "2026-07-15",
"time": "17:55 WEST",
"timezone": "Europe/Lisbon",
"duration_seconds": 2400
}
Idempotency
Send an Idempotency-Key: <uuid> header on any create. The same key with the same payload and behavior options replays the stored response (safe retries); changing rows, timing, dedup_mode, or allow_partial with the same key returns 409 idempotency_conflict. Keys are remembered for 7 days.
Deduplication
Uploads dedupe so the same physical session arriving from Hevy and from your agent collapse into one. Agent sets also appear inside a matching unified device workout when activity and time overlap strongly; links from either source open the complete grouped session. Control write-time dedup per request with dedup_mode:
| dedup_mode | Behaviour |
|---|---|
merge (default) | Merge into the existing session; response is 200 and lists both sources |
create | Skip fuzzy matching; always create a new session |
reject | Return 409 duplicate with the matched session id; nothing is written |
preview | Strict dry run returning would: create, merge, or update; no workout, identity definition, or muscle mapping is modified |
An invalid mode returns 422. Preview always returns outcome: "preview"; candidate is present when an existing session would be merged or updated, and session contains the fully normalized timing that would be used:
{
"outcome": "preview",
"would": "update",
"candidate": {
"id": "019f7b0b-7083-708e-b180-21c1f5041704",
"source": "agent"
},
"warnings": [],
"session": {
"started_at": "2026-07-15T16:55:00+00:00",
"ended_at": "2026-07-15T17:35:00+00:00",
"timezone": "Europe/Lisbon",
"time_provenance": "explicit_iana",
"local_time": "2026-07-15 17:55",
"local_end_time": "2026-07-15 18:35",
"duration_seconds": 2400,
"duration_minutes": 40
}
}
Reading, editing, and media
GET /workout-logs lists your sessions and GET /workout-logs/{id} returns one with its exercises, sets, and media. Edit scalar fields with PATCH /workout-logs/{id}, replace the whole exercise tree with PUT /workout-logs/{id}/exercises, and remove a session with DELETE /workout-logs/{id}.
curl -s -X PATCH "https://vitaltrends.net/api/v1/workout-logs/WORKOUT_ID" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Shoulders and triceps",
"date": "2026-07-15",
"time": "17:55 WEST",
"end_time": "18:35 WEST",
"timezone": "Europe/Lisbon"
}' | jq .
PATCH keeps timing consistent. Updating only duration_seconds moves the end. Updating only an end recomputes duration. Updating only the start preserves an existing duration and moves the end. Set ended_at, end_time, or duration_seconds to null to clear a known end; an explicit non-null end still wins when duration is null.
{ "duration_seconds": 2700 }
The example above preserves the current start and sets the end 45 minutes later. To return a workout to unknown/open-ended timing, send any one of these explicit clears:
{ "ended_at": null }
{ "end_time": null }
{ "duration_seconds": null }
Sessions synced from Hevy are read-only and return 409 source_managed on any edit. To attach a photo, POST /workout-media (multipart image), then reference the returned media_id in an exercise's media array.
Errors
| Status | error | When |
|---|---|---|
401 | unauthenticated | Missing or unknown Bearer token |
403 | missing_ability | Token lacks the workout:write ability |
409 | idempotency_conflict | Same Idempotency-Key, different payload |
409 | duplicate | dedup_mode=reject and a matching session exists |
409 | source_managed | Editing a Hevy-synced session |
422 | validation_failed / unparseable_set / unresolvable_timezone | A field, muscle group, set string, or timezone is invalid |
Timing validation uses 422 validation_failed with a specific message:
| Invalid input | Exact message |
|---|---|
| malformed ISO end | ended_at must be a valid ISO-8601 timestamp. |
| malformed local end | end_time must contain a local time such as 18:35. |
| ISO and local ends disagree | ended_at and end_time do not match. |
| end and duration disagree | ended_at/end_time and duration_seconds do not match. |
| zero, negative, backwards, or over 24 hours | Workout duration must be between 1 second and 24 hours. |
| cleared end plus a non-null duration | A cleared end time cannot be combined with duration_seconds. |
WHOOP
| Method | Path | Description |
|---|---|---|
| GET | /whoop/daily |
Recovery, HRV, RHR, sleep score, and strain per day |
| GET | /whoop/recovery-status |
Latest recovery freshness, sync, and upstream update timestamps |
| GET | /whoop/workouts |
Workout records with sport, duration, and heart rate zones |
| GET | /whoop/sleep |
Sleep sessions with stages and performance scores |
Oura
| Method | Path | Description |
|---|---|---|
| GET | /oura/daily-sleep |
Daily sleep scores and sleep score contributors |
| GET | /oura/sleep |
Sleep sessions with stages, duration, HRV, heart rate, and respiratory rate |
| GET | /oura/daily-readiness |
Daily readiness scores, temperature deviation, and readiness contributors |
| GET | /oura/daily-activity |
Activity scores, calories, steps, distance, active minutes, and activity contributors |
| GET | /oura/workouts |
Workout sessions with activity, intensity, source, calories, distance, and timestamps |
| GET | /oura/daily-spo2 |
Daily SpO2 averages and breathing disturbance index |
| GET | /oura/daily-stress |
Daily stress, recovery, and Oura day summary |
| GET | /oura/daily-resilience |
Daily resilience level and resilience contributors |
| GET | /oura/vo2-max |
VO2 max estimates by day |
Withings
| Method | Path | Description |
|---|---|---|
| GET | /withings |
Alias for paginated Withings measurements. |
| GET | /withings/measurements |
Weight, body composition, pulse, and blood pressure readings from Withings devices. |
| GET | /withings/sleeps |
Sleep Analyzer summaries with sleep score, stages, heart rate, respiratory rate, snoring, breathing disturbances, and apnea-hypopnea index. |
Withings list endpoints accept the shared pagination and date filters: start, end, page, and per_page. Measurement filters apply to measured_at. Sleep filters apply to start_at.
Withings measurement fields
| Field | Type | Description |
|---|---|---|
withings_group_id | integer | Withings measurement group identifier for the source reading. |
measured_at | datetime | UTC time when Withings recorded the reading. |
weight_kg | number|null | Body weight in kilograms. |
fat_ratio_pct | number|null | Body fat percentage. |
fat_mass_kg | number|null | Estimated fat mass in kilograms. |
fat_free_mass_kg | number|null | Estimated mass excluding fat in kilograms. |
muscle_mass_kg | number|null | Estimated muscle mass in kilograms. |
bone_mass_kg | number|null | Estimated bone mass in kilograms. |
hydration_kg | number|null | Estimated body water mass in kilograms. |
heart_pulse | integer|null | Pulse from the Withings reading, when available. |
systolic_bp | integer|null | Systolic blood pressure, when available. |
diastolic_bp | integer|null | Diastolic blood pressure, when available. |
Withings Sleep Analyzer fields
| Field | Type | Description |
|---|---|---|
withings_sleep_id | string | Stable Withings sleep summary identifier. |
date | date|null | Local sleep date reported by Withings. |
start_at | datetime | UTC sleep start timestamp. |
end_at | datetime|null | UTC sleep end timestamp. |
modified_at | datetime|null | Withings upstream modification timestamp. |
timezone | string|null | Timezone reported by Withings for the sleep summary. |
model | integer|null | Withings device model code. |
model_id | string|null | Withings model identifier, for example sleep-analyzer. |
sleep_score | integer|null | Withings sleep score. |
wakeup_duration_seconds | integer|null | Total awake time during the sleep period. |
light_sleep_duration_seconds | integer|null | Light sleep duration. |
deep_sleep_duration_seconds | integer|null | Deep sleep duration. |
rem_sleep_duration_seconds | integer|null | REM sleep duration. |
asleep_duration_seconds | integer|null | Total asleep duration. |
duration_to_sleep_seconds | integer|null | Time from bed entry to sleep. |
duration_to_wakeup_seconds | integer|null | Time from final wake to bed exit. |
wakeup_count | integer|null | Number of wakeups. |
hr_average | number|null | Average sleeping heart rate. |
hr_min | integer|null | Minimum sleeping heart rate. |
hr_max | integer|null | Maximum sleeping heart rate. |
rr_average | number|null | Average sleeping respiratory rate. |
rr_min | integer|null | Minimum sleeping respiratory rate. |
rr_max | integer|null | Maximum sleeping respiratory rate. |
breathing_disturbances_intensity | integer|null | Withings breathing disturbances intensity. |
snoring_seconds | integer|null | Total snoring duration. |
snoring_episode_count | integer|null | Number of snoring episodes. |
apnea_hypopnea_index | number|null | Apnea-hypopnea index reported by compatible Withings devices. |
curl -s "https://vitaltrends.net/api/v1/withings/sleeps?start=2026-06-01&per_page=10" \
-H "Authorization: Bearer YOUR_API_KEY" | jq .
Apple Health
| Method | Path | Description |
|---|---|---|
| GET | /apple-health/daily-summary |
Everything for a single day in one call: activity, heart and vitals including wrist temperature, sleep stages, body, workouts |
| GET | /apple-health/daily |
Paginated daily totals across a date range, including wrist temperature fields |
| GET | /apple-health?type=<type> |
Per-type time series, including type=sleeping_wrist_temp for wrist temperature. Append &include=metadata to surface sleep stages and workout details. Responses are privately cached for 5 minutes per user and exact query. |
Daily summary, one request for a full picture
Use this when you want the morning-dashboard shape: every metric for a given date, including wrist temperature, a sleep stage breakdown (deep, rem, core, awake, in_bed) and full workout metadata (sport, duration, HR min/avg/max, energy, flights climbed).
| Parameter | Type | Default | Description |
|---|---|---|---|
date | date | today (UTC) | Single day, YYYY-MM-DD |
types | csv | all | Subset to include. Any of: activity, heart, sleep, body, workouts |
curl -s "https://vitaltrends.net/api/v1/apple-health/daily-summary?date=2026-04-21" \
-H "Authorization: Bearer YOUR_API_KEY" | jq .
{
"data": {
"date": "2026-04-21",
"activity": {
"steps": 12500,
"distance_km": 8.234,
"active_energy_kcal": 487.2,
"basal_energy_kcal": 1789.4,
"apple_move_time_min": 45,
"apple_exercise_time_min": 28,
"apple_stand_time_min": 180,
"flights_climbed": 12,
"time_in_daylight_min": 87,
"apple_stand_hours": 12
},
"heart": {
"avg_heart_rate": 68,
"min_heart_rate": 52,
"max_heart_rate": 134,
"resting_heart_rate": 56,
"hrv_ms": 45.2,
"respiratory_rate": 14.2,
"spo2_pct": 97.8,
"vo2_max": 42.5,
"wrist_temperature_c": 36.42,
"wrist_temperature_min_c": 36.10,
"wrist_temperature_max_c": 36.70
},
"sleep": {
"total_minutes": 452,
"start_time": "2026-04-20T23:14:00+00:00",
"end_time": "2026-04-21T06:46:00+00:00",
"stages": {
"deep_min": 68,
"rem_min": 92,
"core_min": 256,
"light_min": 0,
"awake_min": 36,
"in_bed_min": 480,
"unknown_min": 0
},
"sources": ["Apple Watch"]
},
"body": {
"weight_kg": 75.20,
"body_fat_pct": 18.40,
"lean_body_mass_kg": 61.30,
"bmi": 23.4,
"height_m": 1.79,
"body_comp_date": "2026-04-18"
},
"workouts": [
{
"uuid": "018f0a3b-1c7e-7d9a-8a2b-8c5c47b3d401",
"type": "Functional Strength Training",
"sport_key": "functional_strength_training",
"start_time": "2026-04-21T07:02:00+00:00",
"end_time": "2026-04-21T07:47:00+00:00",
"duration_min": 45,
"active_energy_kcal": 320,
"distance_m": null,
"avg_heart_rate": 132,
"min_heart_rate": 85,
"max_heart_rate": 165,
"flights_climbed": 3,
"elevation_gain_m": null,
"source": "Apple Watch"
}
]
}
}
/apple-health/daily-summary and /apple-health/daily expose wrist temperature as wrist_temperature_c, wrist_temperature_min_c, and wrist_temperature_max_c. The average uses sample_count weighting when multiple processed rows exist for the same day.
Per-type time series, with optional metadata
Use this when you want processed per-type rows, a date range, or per-segment sleep stages. Use type=sleeping_wrist_temp for Apple Health wrist temperature rows. Pass include=metadata to reveal the metadata field (sleep stage, workout type, labels). For individual CGM glucose readings, use /glucose/readings.
/apple-health?type=<type> responses are cached for 5 minutes per user and exact query string. Responses include Cache-Control: private, max-age=300, so clients should avoid rapid repeat polling and reuse identical responses during that window.
curl -s "https://vitaltrends.net/api/v1/apple-health?type=sleep&start=2026-04-20&end=2026-04-21&include=metadata" \
-H "Authorization: Bearer YOUR_API_KEY" | jq .
{
"data": [
{
"data_type": "sleep",
"date": "2026-04-20",
"start_time": "2026-04-21T01:45:00+00:00",
"end_time": "2026-04-21T05:45:00+00:00",
"value": 0,
"value_min": null,
"value_max": null,
"sample_count": 1,
"unit": "hr",
"source": "Apple Watch",
"metadata": { "stage": "core" }
},
{
"data_type": "sleep",
"date": "2026-04-20",
"start_time": "2026-04-20T23:15:00+00:00",
"end_time": "2026-04-21T00:15:00+00:00",
"value": 0,
"value_min": null,
"value_max": null,
"sample_count": 1,
"unit": "hr",
"source": "Apple Watch",
"metadata": { "stage": "deep" }
}
],
"links": { "first": "...", "last": null, "prev": null, "next": null },
"meta": { "current_page": 1, "from": 1, "path": "...", "per_page": 50, "to": 2 }
}
Valid type values
| Category | Values |
|---|---|
| Activity | steps, distance_walking_running, active_energy_burned, basal_energy_burned, apple_move_time, apple_stand_hour, time_in_daylight, physical_effort, swimming_stroke_count_qty, workout_effort_score, estimated_workout_effort_score |
| Heart & vitals | heart_rate, resting_heart_rate, heart_rate_variability, heart_rate_recovery, respiratory_rate, oxygen_saturation, sleeping_wrist_temp, blood_pressure_systolic, blood_pressure_diastolic, blood_glucose, body_temperature, afib_burden |
| Sleep | sleep, apple_sleeping_breathing_disturbances, sleep_apnea_event |
| Workouts | workouts |
| Body composition | body_mass, body_fat_percentage, lean_body_mass, body_mass_index, height |
| Mobility | walking_double_support, walking_steadiness, stair_ascent_speed, stair_descent_speed, six_minute_walk_distance |
| Running & cycling | running_power, cycling_power, cycling_speed |
| Water sports | underwater_depth, water_temperature, distance_paddle_sports, paddle_sports_speed, distance_rowing, rowing_speed |
| Winter sports | distance_cross_country_skiing, cross_country_skiing_speed, distance_skating_sports |
| Fitness events | low_cardio_fitness_event |
Availability depends on which HealthKit sensors and iOS version your device supports. Types that are defined but not yet ingested by the VitalTrends iOS companion app are listed in meta.unavailable_types on the daily summary response.
Glucose / CGM
Glucose data is available when a CGM or glucose app writes blood_glucose samples into Apple Health and the VitalTrends iOS companion app syncs them.
| Method | Path | Description |
|---|---|---|
| GET | /glucose |
Glucose analytics payload with weighted average, GMI, time in range, variability, AGP, daily rows, and day explorer context |
| GET | /glucose/readings |
Paginated observation-level CGM glucose readings from the user's active Apple Health pipeline |
Glucose analytics
Use this when you want the same glucose analysis that powers the web dashboard.
| Parameter | Type | Default | Description |
|---|---|---|---|
range | string | none | Use 24h for the trailing 24-hour raw window |
days | integer | 30 | Rolling window, 1-365 days |
start | date | none | Custom range start, interpreted in the user's profile timezone |
end | date | now | Custom range end, interpreted in the user's profile timezone |
selected_date | date | latest raw date | Day explorer date, YYYY-MM-DD |
curl -s "https://vitaltrends.net/api/v1/glucose?days=30" \
-H "Authorization: Bearer YOUR_API_KEY" | jq .
{
"data": {
"config": {
"targetLow": 70,
"targetHigh": 140,
"clinicalHigh": 180,
"unit": "mg/dL"
},
"stats": {
"average_glucose": { "value": 112.4, "source": "daily_weighted", "sample_count": 8064 },
"gmi": { "value": 6.0, "available": true, "reason": null, "days_with_data": 28 },
"time_in_range": { "source": "raw", "range_70_140_pct": 82.1, "range_70_180_pct": 96.5, "total": 8064 },
"variability": { "value": 18.9, "source": "raw_cv", "proxy_value": null, "stable_threshold": 36 },
"min_max": { "min": 64.0, "max": 184.0 }
},
"daily": [
{
"date": "2026-05-31",
"value": 111.8,
"value_min": 82.0,
"value_max": 154.0,
"sample_count": 288,
"unit": "mg/dL"
}
],
"time_in_range": {
"source": "raw",
"bands": []
},
"agp": {
"available": true,
"bucket_minutes": 30,
"points": []
},
"correlations": {
"available": true,
"day_explorer": {}
},
"empty": false,
"has_any_data": true
}
}
When no glucose data exists, /glucose returns empty: true and has_any_data: false instead of an error.
Raw glucose readings
Use this when you need individual CGM readings rather than daily aggregates.
curl -s "https://vitaltrends.net/api/v1/glucose/readings?start=2026-05-01&end=2026-05-07&per_page=200" \
-H "Authorization: Bearer YOUR_API_KEY" | jq .
{
"data": [
{
"sample_id": 214242011,
"data_type": "blood_glucose",
"recorded_at": "2026-05-07T23:36:13+00:00",
"start_time": "2026-05-07T23:36:13+00:00",
"end_time": "2026-05-07T23:36:13+00:00",
"value": 99.1,
"unit": "mg/dL",
"source": "CGM"
}
],
"links": { "first": "...", "last": null, "prev": null, "next": null },
"meta": { "current_page": 1, "from": 1, "path": "...", "per_page": 200, "to": 1 }
}
/glucose/readings supports the common list parameters start, end, per_page, and page. Pass include=metadata to include HealthKit metadata or include=hk_device to include HealthKit device details. Raw readings exclude samples deleted from Apple Health and samples superseded by deduplication.
Query parameters
All list endpoints support the following parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
start | date or datetime | none | Inclusive lower bound. Date-only values use YYYY-MM-DD. |
end | date or datetime | none | Inclusive upper bound. Date-only values use YYYY-MM-DD. |
per_page | integer | 50 | Records per page (max 200) |
page | integer | 1 | Page number (1-indexed) |
For timestamp-backed endpoints, date-only start and end values are interpreted as full calendar-day boundaries in your profile timezone. Date-backed Apple Health aggregate endpoints and Oura daily endpoints compare against the stored date value directly.
On /whoop/daily, date remains the legacy cycle-end date for closed cycles; use cycle_start_date and cycle_end_date when you need explicit local cycle dates. If the user's current local-day cycle is still open, the endpoint may include it with is_partial: true and cycle_end_date: null. For morning automations, /whoop/daily is the canonical endpoint for deciding whether today's WHOOP recovery and sleep row is usable: select the row where cycle_start_date equals the user's local date and require both recovery_score and sleep_duration_minutes.
WHOOP recovery freshness
Use /whoop/recovery-status as advisory sync metadata only. It is useful when an automation needs to know whether VitalTrends recently checked, changed, or received upstream recovery data, but it is not a current-day completeness gate. Its latest_recovery and latest_cycle summaries use raw WHOOP cycle timestamps and do not apply the /whoop/daily sleep wake-day normalization for open cycles. last_recovery_data_changed_at advances only when stored recovery values change. last_recovery_synced_at advances when VitalTrends checks/applies recovery data, even if values are unchanged.
curl -s "https://vitaltrends.net/api/v1/whoop/recovery-status" \
-H "Authorization: Bearer YOUR_API_KEY" | jq .
{
"data": {
"connected": true,
"needs_reconnection": false,
"latest_recovery": {
"cycle_id": "123e4567-e89b-12d3-a456-426614174000",
"cycle_start_date": "2026-05-08",
"cycle_end_date": "2026-05-09",
"recovery_score": 82,
"hrv_rmssd_milli": 68.4,
"resting_heart_rate": 48,
"recovery_synced_at": "2026-05-09T07:06:00+00:00",
"recovery_data_changed_at": "2026-05-09T07:06:00+00:00",
"whoop_recovery_updated_at": "2026-05-09T07:05:00+00:00"
},
"latest_cycle": {
"cycle_id": "123e4567-e89b-12d3-a456-426614174000",
"cycle_start_date": "2026-05-08",
"cycle_end_date": "2026-05-09",
"recovery_score": 82,
"hrv_rmssd_milli": 68.4,
"resting_heart_rate": 48,
"recovery_synced_at": "2026-05-09T07:06:00+00:00",
"recovery_data_changed_at": "2026-05-09T07:06:00+00:00",
"whoop_recovery_updated_at": "2026-05-09T07:05:00+00:00"
},
"last_recovery_synced_at": "2026-05-09T07:06:00+00:00",
"last_recovery_data_changed_at": "2026-05-09T07:06:00+00:00",
"last_whoop_recovery_updated_at": "2026-05-09T07:05:00+00:00",
"is_recovery_data_stale": false,
"stale_after_hours": 24
}
}
If is_recovery_data_stale is true or last_recovery_data_changed_at is more than 24 hours old, treat recovery freshness as uncertain and try again later. Do not conclude that today's recovery or sleep row is missing until you have checked /whoop/daily for the user's current local cycle_start_date. Opening the VitalTrends WHOOP dashboard also triggers a small recent-window WHOOP refresh when the latest recovery appears stale.
WHOOP daily date is the legacy cycle-end date for closed cycles; use cycle_start_date, cycle_end_date, and is_partial when you need explicit local cycle boundaries. WHOOP workout fields ending in _milli are durations in milliseconds, and kilojoule is energy in kilojoules.
Oura duration fields are returned in the units provided by Oura. Sleep durations are seconds; workout distance is meters; calorie fields are kilocalories.
On /oura/workouts, source is Oura's own workout source value. For example, confirmed means the workout was saved in Oura after confirmation in the Oura app. It is not a VitalTrends confidence score or a cross-device verification status. Other common values include autodetected, manual, and workout_heart_rate.
Withings measurement fields ending in _kg are stored in kilograms, and fat_ratio_pct is a percentage. Empty Withings fields mean the connected device did not provide that metric for the measurement, not that VitalTrends rejected it.
Apple Health source is the HealthKit device or app label that wrote the sample, not a confidence or verification status. Pass include=metadata on /apple-health?type=<type> to see sleep stages and workout details. For wrist temperature, request /apple-health?type=sleeping_wrist_temp. For cumulative daily metrics such as steps, distance, and energy, VitalTrends avoids double-counting by choosing the largest per-source daily total by default.
Glucose data currently comes from Apple Health blood_glucose samples. Use /glucose for dashboard-ready analytics and /glucose/readings for individual raw CGM readings. Use /apple-health?type=blood_glucose only when you want processed Apple Health aggregate rows in the generic Apple Health response shape.
Example request
curl -s "https://vitaltrends.net/api/v1/whoop/daily?start=2024-01-01&per_page=7" \
-H "Authorization: Bearer YOUR_API_KEY" | jq .
curl -s "https://vitaltrends.net/api/v1/oura/daily-readiness?start=2026-05-01&per_page=7" \
-H "Authorization: Bearer YOUR_API_KEY" | jq .
Example response
{
"data": [
{
"date": "2024-01-07",
"cycle_start_date": "2024-01-06",
"cycle_end_date": "2024-01-07",
"is_partial": false,
"recovery_score": 82,
"hrv_rmssd_milli": 68.4,
"resting_heart_rate": 48,
"sleep_performance_pct": 89,
"sleep_duration_minutes": 450,
"strain": 12.4,
"whoop_recovery_updated_at": "2024-01-07T07:05:00+00:00",
"recovery_synced_at": "2024-01-07T07:06:00+00:00",
"recovery_data_changed_at": "2024-01-07T07:06:00+00:00"
}
],
"meta": {
"current_page": 1,
"last_page": 1,
"per_page": 7,
"total": 7,
"from": 1,
"to": 7
}
}
Rate limits
The API allows 120 requests per minute per API key. If you exceed the limit, requests return 429 Too Many Requests with a Retry-After header.
Response caching
GET /apple-health?type=<type> is cached for 5 minutes per user and exact query string, including filters such as start, end, page, per_page, and include. The response includes Cache-Control: private, max-age=300. This cache is intended for repeated API reads and does not change the API rate limit.
Error responses
| Status | Meaning |
|---|---|
401 | Missing or invalid API key |
403 | Valid key but subscription required |
422 | Invalid query parameters |
429 | Rate limit exceeded |
500 | Server error, try again shortly |
Need an endpoint that is not listed here? Get in touch and we will consider adding it.