Stridee
Stridee Platform Docs

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

kindWhat it isGrain
dailySteps, distance, calories, resting heart rate, intensity minutesOne per day
sleepOne sleep: stages, duration, scoreSeveral per day — a nap is its own record
hrvHeart-rate variability — overnight on Garmin (see the COROS and Apple notes below)Usually one per day
stressAll-day stress, and the body battery derived from itOne per day
fitnessVO₂max and fitness ageOne per day, no measurement window
epochFifteen minutes of one activity typeMany per day
respirationAll-day breathing rateOne per window
pulse_oxBlood oxygen saturation, all-day or on demandSeveral per day
body_compositionA weigh-in: weight, body fat, muscle mass, BMIOne per weigh-in
blood_pressureOne cuff reading: systolic, diastolic, pulseOne per reading
skin_temperatureOvernight skin temperature, as a deviation from baselineUsually one per day
health_snapshotA two-minute on-demand test reporting several metrics at onceOne 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 sleep record, so key your storage on id, not on user, kind and date.
  • fitness has no window. duration_seconds is null and start_time is a placeholder at midnight UTC. vo2_max is absent on most days; fitness_age usually isn't.

What an event looks like

data.id is our id for this summary, and yours alone. Store against it.

  • metrics is normalized — total_sleep_seconds means the same on every provider. Only the keys this kind carries are present, so a sleep record has no steps key at all.
  • summary is 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.
wellness.created
{
  "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.

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

get /v1/wellness/{id}/series
Reference

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.

Path parameters
idstring · uuidRequired

The summary, as data.id on wellness.created

GET /v1/wellness/{id}/series
GET /v1/wellness/3f07d5b2-9e14-42a8-8c63-1d5a0be93f72/series HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 200
{}

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.

get /v1/wellness
Reference

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.

Query parameters
user_idstring · uuid

Narrow to one athlete, by our id for them — what POST /v1/connect returned and what events carry as user_id.

providerstring

garmin | coros | zepp | fitbit | suunto | whoop. An unknown provider is an empty page, not a 400.

kindstring

daily | 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_datestring

Only summaries for this calendar day or later. Inclusive.

to_datestring

Only summaries for this calendar day or earlier. Inclusive, unlike until — a developer asking for a month means its last day included.

sincestring

Only summaries granted at or after this RFC 3339 instant.

untilstring

Only 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 · uuid

The id of the last summary on the previous page. Omit for the first one.

limitinteger · int64

Defaults to 50, capped at 200.

GET /v1/wellness
GET /v1/wellness HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 200
{
  "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

ProviderKindsNotes
GarminAll of themPayloads passed through untouched.
COROSdaily, sleep, hrvBuilt 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.
Zeppsleep, hrvThe 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.
Fitbitdaily, sleep, hrv, respiration, pulse_oxVia 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.
Suuntodaily, sleep, hrv, stresssleep 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.
WHOOPsleep, hrv, daily, body_compositionsleep 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 Healthdaily, sleep, hrv, fitness, respiration, pulse_ox, body_composition, blood_pressure, skin_temperaturePushed 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, HammerheadNoneNo 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.