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)
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.