Wellness
Sleep, daily activity, HRV, stress and fitness summaries — what arrives, what it contains, and why corrections are a separate event.
Wellness is what your user's watch measured without being asked: sleep, steps, HRV, stress. It arrives like activities do — pushed to your endpoint, with a listing for anything you missed — with one difference: a summary can change after we tell you about it. Garmin sends a night's sleep provisionally and settles it hours later.
The kinds
kind | What it is | Grain |
|---|---|---|
daily | Steps, distance, calories, resting heart rate, intensity minutes | One per day |
sleep | One sleep: stages, duration, score | Several per day — a nap is its own record |
hrv | Heart-rate variability — overnight on Garmin (see the COROS and Apple notes below) | Usually one per day |
stress | All-day stress, and the body battery derived from it | One per day |
fitness | VO₂max and fitness age | One per day, no measurement window |
epoch | Fifteen minutes of one activity type | Many per day |
respiration | All-day breathing rate | One per window |
pulse_ox | Blood oxygen saturation, all-day or on demand | Several per day |
body_composition | A weigh-in: weight, body fat, muscle mass, BMI | One per weigh-in |
blood_pressure | One cuff reading: systolic, diastolic, pulse | One per reading |
skin_temperature | Overnight skin temperature, as a deviation from baseline | Usually one per day |
health_snapshot | A two-minute on-demand test reporting several metrics at once | One per test |
kind is our name, the same whichever watch sent it. Which provider sends which is
below.
- Don't assume one row per day. A nap is its own
sleeprecord, so key your storage onid, not on user, kind and date. fitnesshas no window.duration_secondsisnullandstart_timeis a placeholder at midnight UTC.vo2_maxis absent on most days;fitness_ageusually isn't.
What an event looks like
data.id is our id for this summary, and yours alone. Store against it.
metricsis normalized —total_sleep_secondsmeans the same on every provider. Only the keys this kind carries are present, so asleeprecord has nostepskey at all.summaryis the provider's own payload, minus its time series, with the provider's field names. Use it for anything we haven't promoted to a metric; it changes when the provider changes it.
{
"id": "3b8e1c94-7a52-4d06-b19f-5c2e07a4d831",
"type": "wellness.created",
"created": "2026-08-25T07:02:11Z",
"webhook_id": "2d7b45c1-8e0a-4f36-b512-9c7d3a6e04f8",
"nonce": "Kd4nWpLbEa9xTvRm2Cj7Lz0Bq2vNhCz7",
"user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6",
"provider": "garmin",
"data": {
"object": "wellness",
"id": "3f07d5b2-9e14-42a8-8c63-1d5a0be93f72",
"kind": "sleep",
"provider_summary_id": "x9f2c1e7-4b83-4d21",
"calendar_date": "2026-08-25",
"start_time": "2026-08-24T22:41:00Z",
"duration_seconds": 24908,
"tz_offset_seconds": 7200,
"revision": 1,
"finality": "tentative",
"metrics": {
"total_sleep_seconds": 24908,
"deep_sleep_seconds": 5820,
"light_sleep_seconds": 13140,
"rem_sleep_seconds": 4560,
"awake_seconds": 1388
},
"summary": {
"durationInSeconds": 24908,
"deepSleepDurationInSeconds": 5820,
"lightSleepDurationInSeconds": 13140,
"remSleepInSeconds": 4560,
"awakeDurationInSeconds": 1388,
"validation": "AUTO_TENTATIVE"
},
"series_url": "https://api.stridee.com/v1/wellness/3f07d5b2-9e14-42a8-8c63-1d5a0be93f72/series"
}
}Corrections, and the one way to get this wrong
When a summary changes you get wellness.updated with the same data.id and a higher
revision. Identical re-sends emit nothing.
Handle wellness.updated, or you keep Garmin's tentative sleep forever. Nothing
errors — the number is just wrong.
Upsert on data.id, keeping the higher revision:
finality is tentative, final, or null for kinds without the notion — only sleep
uses it, except on Apple Health, where every kind does and today's daily arrives tentative
while the day is still going. A tentative reading never overwrites a final one.
if (type === 'wellness.created' || type === 'wellness.updated') {
await db.wellness.upsert({
where: { id: data.id },
create: data,
// Deliveries can repeat and arrive out of order; an older revision is a no-op.
update: { ...data, where: { revision: { lt: data.revision } } },
});
}Time series are a separate call
Minute-by-minute data — heart-rate samples, the sleep-stage map, the stress curve — is most
of the bytes and rarely needed, so it is behind series_url:
Fetch the time series behind one summary.
The minute-level data the listing deliberately leaves out: Garmin's
heart-rate samples on a daily, the sleep-stage map and respiration and SpO₂
traces on a sleep, the stress curve and body-battery events on a stress,
the beat-to-beat values on an hrv. Returned as the provider wrote it,
unmodified — the keys are theirs.
It is a separate call because it is around ninety percent of the payload's
bytes and most integrations never draw a curve. A fitness summary has none
at all, and neither does anything from COROS; those rows carry
"series_url": null and this answers 404 for them.
One 404 covers every way this can fail — someone else's summary, an id that
never existed, and one with no series — for the reason
GET /v1/activities/{id}/file gives: these ids travel in webhook bodies and
a status code that confirms an id exists is an oracle somebody can walk.
idstring · uuidRequiredThe summary, as data.id on wellness.created
GET /v1/wellness/3f07d5b2-9e14-42a8-8c63-1d5a0be93f72/series HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:{}Listing what you were sent
If a delivery was missed, this is how you find it — and the one place the two date axes
matter: display by calendar_date, reconcile by since/until.
List the health summaries you have been granted.
Every sleep, daily, HRV, stress and fitness record one of your users was
connected to you for, newest first — the same rows that were announced to
your endpoint as wellness.created, in the same shape. This is the
reconciliation path: if a delivery was missed, this is how you find it.
Two different axes, and they answer different questions. calendar_date
(with from_date/to_date) is what the measurement is about, and it is
what you want for "show me last week's sleep". received_at (with
since/until) is when we told you, it is what this listing is ordered on,
and it is the only one that is monotonic — providers backfill and correct, so
a night from March can arrive today. Page and reconcile on received_at;
query and display on calendar_date.
Summaries change. Unlike an activity, a measurement can be revised after
you are told about it: Garmin sends a night tentatively and settles it hours
later. revision starts at 1 and increments whenever the payload actually
changed, and a revision arrives as wellness.updated rather than a second
wellness.created. Upsert on id and take the higher revision.
summary is the whole provider payload except its time series. The
minute-by-minute data — heart-rate samples, sleep stages, the stress curve —
is behind series_url, because it is around ninety percent of the bytes and
almost nothing needs it. A row whose series_url is null has none at all.
Access is the grant that was recorded when each summary arrived. Disconnecting stops new ones; it does not remove those from the period your user had agreed to share.
user_idstring · uuidNarrow to one athlete, by our id for them — what POST /v1/connect
returned and what events carry as user_id.
providerstringgarmin | coros | zepp | fitbit | suunto | whoop. An unknown provider is an empty
page, not a 400.
kindstringdaily | sleep | hrv | stress | fitness | epoch |
respiration | pulse_ox | body_composition | blood_pressure |
skin_temperature | health_snapshot. Unlike provider, an unknown
value here is a 400 — see the note on the parser.
from_datestringOnly summaries for this calendar day or later. Inclusive.
to_datestringOnly summaries for this calendar day or earlier. Inclusive, unlike
until — a developer asking for a month means its last day included.
sincestringOnly summaries granted at or after this RFC 3339 instant.
untilstringOnly summaries granted strictly before it. Half-open, so one window's
until is the next one's since and nothing is counted twice.
starting_afterstring · uuidThe id of the last summary on the previous page. Omit for the first one.
limitinteger · int64Defaults to 50, capped at 200.
GET /v1/wellness HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:{
"has_more": false,
"next_starting_after": null,
"total": 1,
"wellness": [
{
"calendar_date": "string",
"duration_seconds": null,
"external_user_id": "user_4821",
"finality": null,
"id": "3f07d5b2-9e14-42a8-8c63-1d5a0be93f72",
"kind": "string",
"metrics": {
"active_kilocalories": null,
"active_time_seconds": null,
"avg_hr": null,
"avg_stress_level": null,
"awake_seconds": null,
"bmr_kilocalories": null,
"body_battery": null,
"body_battery_charged": null,
"body_battery_drained": null,
"body_fat_percent": null,
"body_mass_index": null,
"body_water_percent": null,
"bone_mass_grams": null,
"deep_sleep_seconds": null,
"diastolic": null,
"distance_meters": null,
"epoch_activity_type": null,
"epoch_intensity": null,
"fitness_age": null,
"fitness_age_enhanced": null,
"floors_climbed": null,
"hrv_avg": null,
"hrv_rmssd": null,
"hrv_sdrr": null,
"light_sleep_seconds": null,
"max_hr": null,
"met": null,
"min_hr": null,
"moderate_intensity_seconds": null,
"muscle_mass_grams": null,
"nap_seconds": null,
"pulse": null,
"rem_sleep_seconds": null,
"respiration_avg": null,
"respiration_max": null,
"respiration_min": null,
"resting_hr": null,
"skin_temp_celsius": null,
"skin_temp_deviation_celsius": null,
"sleep_score": null,
"spo2_avg": null,
"spo2_min": null,
"spo2_on_demand": null,
"steps": null,
"systolic": null,
"total_sleep_seconds": null,
"vigorous_intensity_seconds": null,
"vo2_max": null,
"vo2_max_cycling": null,
"weight_grams": null
},
"provider": "coros",
"provider_summary_id": null,
"received_at": "2026-08-05T11:44:02Z",
"revised_at": null,
"revision": 0,
"series_url": null,
"start_time": "2026-08-05T11:44:02Z",
"summary": {},
"tz_offset_seconds": null,
"user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}
]
}What each provider sends
| Provider | Kinds | Notes |
|---|---|---|
| Garmin | All of them | Payloads passed through untouched. |
| COROS | daily, sleep, hrv | Built by us from one daily query. daily has steps and calories (resting HR is only in summary.rhr); sleep has duration only; hrv_avg is COROS's ppgHrv, method undocumented. No series. |
| Zepp | sleep, hrv | The same shape as Garmin. sleep has full stages, score, finality and series; hrv has hrv_avg (Zepp's lastNightAvg); its hrvValues series is always null. |
| Fitbit | daily, sleep, hrv, respiration, pulse_ox | Via the Google Health API; summary is Google's object, int64s as strings. daily has resting HR only; sleep has stages but no score; the others are nightly values anchored to midnight UTC. |
| Suunto | daily, sleep, hrv, stress | sleep is Suunto's own record: stages, score, average and minimum HR. hrv_avg is its AvgHRV, method undocumented. daily and stress are built by us from Suunto's ten-minute samples, one per day anchored to midnight UTC: daily has steps, active energy and average/min/max HR (no resting HR); stress has body_battery from Suunto's 0–1 Balance. A day's record updates as the watch syncs more of it. No series. |
| WHOOP | sleep, hrv, daily, body_composition | sleep is one per sleep, naps included: stages (WHOOP's slow-wave is deep), its sleep performance % as the score and respiratory rate; sleep need, consistency and efficiency stay in summary. It updates when WHOOP re-scores. hrv is WHOOP's daily Recovery: hrv_avg is RMSSD, with resting HR and, on a 4.0 or newer, SpO₂ and skin temperature beside it; the 0–100 recovery score is summary.score.recovery_score. daily is WHOOP's cycle, which runs sleep to sleep rather than midnight to midnight: steps, resting HR (from that cycle's Recovery, which is embedded as summary.recovery) and average/max HR, with day Strain (0–21) and kilojoules in summary; the open cycle updates until it closes. body_composition is the weight on the member's profile, read when they connect and not a weigh-in. No series. |
| Apple Health | daily, sleep, hrv, fitness, respiration, pulse_ox, body_composition, blood_pressure, skin_temperature | Pushed from your iOS app by the SDK; summary is the item it sent. HRV is SDNN, in hrv_sdrr — never hrv_avg, which is RMSSD everywhere else. skin_temp_celsius is absolute. finality is set on every kind. Apple Health has the mapping. |
| Polar, Wahoo, Hammerhead | None | No wellness records at all. |
Access follows the same rule as activities: granted when a summary arrives, never for a period your user was disconnected, and not taken back when they disconnect. History from before they connected is included, as far back as each provider allows.
Something wrong or missing on this page? Tell us in Discord. Need something the API doesn’t do yet? Request it on the roadmap.