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 note 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
{
"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"
}
}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.
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:
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 } } },
});
}finality is tentative, final, or null for kinds without the notion — only sleep
uses it. A tentative reading never overwrites a final one.
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, a signed call that returns the
provider's series unmodified:
GET /v1/wellness/3f07d5b2-9e14-42a8-8c63-1d5a0be93f72/seriesIt is null when there is nothing to fetch — every fitness record and everything from
COROS. That is not an error.
Listing what you were sent
GET /v1/wellness?kind=sleep&from_date=2026-08-01&to_date=2026-08-31&limit=50| Parameter | |
|---|---|
user_id | One athlete, by our id for them. |
provider | Any provider slug. An unknown one is an empty page, not an error. |
kind | One of the kinds above. An unknown one is a 400. |
from_date / to_date | Calendar days, inclusive both ends. What the measurement is about. |
since / until | RFC 3339 instants, half-open. When we told you. |
starting_after | The id of the last row on the previous page. See Paging. |
limit | Defaults to 50, capped at 200. |
Display by calendar_date, reconcile by since/until. calendar_date is what the
measurement is about; received_at is when you were granted it, and only that one moves
forward — a night from March can arrive today. Each row also has revised_at, or null.
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 | Full stages, score, finality and series — the same shape as Garmin. |
| 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. |
| 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.