How to integrate Garmin data into your app: activities, sleep and HRV

A step-by-step guide to linking a Garmin user, receiving activities and FIT files, handling sleep corrections and loading history.

Your users train with a Garmin watch and you want their runs, their sleep and their HRV in your product. This guide walks through the whole integration: linking a user, receiving activities, fetching the FIT file, handling sleep corrections and loading history. It uses the [Stridee Platform](https://platform.stridee.com), so there is no application to the Garmin Connect Developer Program involved. That matters more than usual right now, because [Garmin has paused new applications](https://stridee.com/blog/garmin-developer-program-paused). ## Two ways to integrate Garmin **Directly.** You apply to the Garmin Connect Developer Program, wait for review, sign the agreement, then build an OAuth 2.0 PKCE flow, a public webhook endpoint, a queue, a retry policy and a backfill job. Garmin's notifications are pointers, so each one means going back to fetch the data. Applications are closed at the moment. **Through an API that is already approved.** The registration, the callback and the token handling belong to the provider. You make one call to link a user and receive webhooks afterwards. That is what the rest of this guide does. ## Before you start You need a [Stridee account](https://platform.stridee.com/pricing). Every plan has a 14-day free trial. Install the CLI and sign in. It also registers a signing key for your machine: ```bash stridee login stridee listen --forward-to localhost:3000/webhooks ``` `stridee listen` forwards real webhook deliveries to your local server, so you do not need a tunnel or a public URL while you build. The [quickstart](https://platform.stridee.com/docs/quickstart) covers installing it. ## 1. Sign your requests There is no API key. Each request carries an Ed25519 signature made with a private key that stays on your server, following RFC 9421. A leaked log or proxy therefore holds nothing that can be replayed as you. The [signing guide](https://platform.stridee.com/docs/signing) has a `signRequest` helper of about thirty lines for Node and Python. Check it works against `GET /v1/whoami` before anything else, because every signing mistake looks like the same `401`. ## 2. Link a user to Garmin Call this from your backend with your own id for the user: ```js const body = JSON.stringify({ provider: 'garmin', external_user_id: 'user_4821', return_uri: 'https://app.yourapp.com/settings/devices', }); const url = 'https://api.stridee.com/v1/connect'; const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...signRequest({ method: 'POST', url, body }) }, body, }); const { connect_url, user_id } = await res.json(); ``` Redirect the user's browser to `connect_url`. They see a page naming your product, click once, and land on Garmin's own consent screen. When they approve, they come back to your `return_uri` with `?status=success&user_id=…`. Three details that save time later: - **`external_user_id` is your id**, whatever your database already calls this person. Calling `connect` twice with the same value returns the same user, so retries are safe. - **`connect_url` is single use.** Mint a new one each time instead of storing it. - **Handle `status=denied`.** A user declining is a normal outcome, not an error. Register your `return_uri` in the console first. Mobile apps can use a custom scheme such as `com.yourapp.ios:/stridee-callback`. See [Connect a device](https://platform.stridee.com/docs/connect). ## 3. Know when the link exists Do not rely on the redirect. A user who closes the tab halfway through is still connected. The source of truth is the `account.connected` event: ```json { "type": "account.connected", "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6", "provider": "garmin", "data": { "connection_id": "a2f81b60-3c47-49d5-b8e2-70a4c9f13d85" } } ``` ## 4. Receive activities Each finished activity arrives as `activity.created`: ```json { "type": "activity.created", "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6", "provider": "garmin", "data": { "object": "activity", "id": "0f31a8c4-59d2-4e07-b6a1-8c74e2f95d30", "sport": "run", "start_time": "2026-08-04T06:12:00Z", "device": "Forerunner 970", "name": "Morning Run", "file": { "format": "fit", "url": "https://api.stridee.com/v1/activities/0f31a8c4-59d2-4e07-b6a1-8c74e2f95d30/file" } } } ``` The event is small on purpose. The recording is one signed `GET` away, at `data.file.url`. Laps, heart rate and GPS samples are inside that FIT file. Parsed laps and streams as JSON are not live yet, so use a FIT library for now. Deliveries are encrypted to a key only you hold, and your handler answers with the `nonce` from inside the body to prove it opened it. The [webhooks guide](https://platform.stridee.com/docs/webhooks) has the handler in full. ## 5. Receive sleep, HRV and daily health data Health data arrives as `wellness.created`, one event per summary. Garmin sends every kind we support: | `kind` | What it holds | | --- | --- | | `daily` | Steps, distance, calories, resting heart rate, intensity minutes | | `sleep` | Stages, duration and score. A nap is its own record | | `hrv` | Overnight heart-rate variability | | `stress` | All-day stress and Body Battery | | `fitness` | VO₂max and fitness age | | `respiration`, `pulse_ox`, `skin_temperature` | Overnight and all-day readings | | `body_composition`, `blood_pressure` | Weigh-ins and cuff readings | Each event has two blocks. `metrics` is normalized, so `total_sleep_seconds` means the same thing for a Garmin user and a Fitbit user. `summary` is Garmin's own payload with Garmin's field names, for anything we have not promoted to a metric. **The one way to get this wrong:** Garmin sends a night's sleep provisionally and corrects it hours later. The correction arrives as `wellness.updated` with the same `data.id` and a higher `revision`. If you only handle `created`, you keep the tentative number forever and nothing tells you. Upsert on `data.id` and keep the higher revision: ```js if (type === 'wellness.created' || type === 'wellness.updated') { await db.wellness.upsert({ where: { id: data.id }, create: data, update: { ...data, where: { revision: { lt: data.revision } } }, }); } ``` Minute-by-minute series such as the sleep-stage timeline and the stress curve are behind `data.series_url`, because they are most of the bytes and most products never read them. Details are in the [wellness docs](https://platform.stridee.com/docs/wellness). ## 6. Load history When a user connects, past data is replayed as ordinary deliveries, so your handler needs no second code path. - **Activities** go back up to five years, as far as Garmin still holds them. - **Wellness** goes back four weeks, which is all Garmin serves. - **Both depend on what the user ticked.** Without the historical export permission on Garmin's consent screen nothing is replayed, and there is no error to catch. The first delivery is simply their next activity. A deep history fills in over minutes, not at once, and old workouts can arrive after new ones. Treat each event as a signal to reconcile, not a transaction to apply in order. ## 7. Catch what you missed A delivery is sent once and is not retried automatically. If your endpoint was down, list what you were sent: ``` GET /v1/activities?since=<last run>&until=<this run> GET /v1/wellness?since=<last run>&until=<this run> ``` Run it nightly and dedupe on `id`. Each row has the same shape as the event, so it goes to the code you already wrote. ## Before you ship - **Handle `account.reauth_required`.** It means Garmin stopped accepting the credentials. Start a new link for the same `external_user_id`. - **Give users a way out.** `POST /v1/connections/manage-link` returns a page where they can disconnect themselves. - **Credit Garmin.** Garmin's [API Brand Guidelines](https://developer.garmin.com/downloads/brand/Garmin-Developer-API-Brand-Guidelines.pdf) require attribution wherever its data is shown, including exports and derived figures. - **Adding COROS, Polar or Wahoo is the same call** with a different `provider` and the same `external_user_id`. Your handler does not change. ## Next - [Quickstart](https://platform.stridee.com/docs/quickstart): first webhook on localhost in fifteen minutes - [Garmin integration](https://platform.stridee.com/integrations/garmin): what is live and what is not - [How to send workouts to a Garmin watch](https://stridee.com/blog/push-workouts-to-garmin-watch) - [Pricing](https://platform.stridee.com/pricing)