garminconnect-js: TypeScript client for Garmin Connect [![npm][npm-shield]][npm-url] [![Node][node-shield]][node-url] [![License][license-shield]][license-url] [![Last Commit][activity-shield]][activity-url] [![Maintenance][maintenance-shield]][maintenance-url] [![Buy Me a Coffee][bmc-shield]][bmc-url] **Unofficial.** Not affiliated with, endorsed by, or supported by Garmin. Garmin and Garmin Connect are trademarks of Garmin Ltd. or its subsidiaries.
A zero-dependency TypeScript client for Garmin Connect, for **Node and Next.js server runtimes**. It talks to the same undocumented endpoints the mobile app uses, with a fully typed, promise-based API. It handles Garmin's undocumented SSO/OAuth flow, refreshes tokens for you, and gives you 200 typed methods over the endpoints the mobile app uses, plus two things that exist because Garmin's API is quietly hostile in specific places: - **A fluent workout builder.** Garmin's workout JSON has four traps that produce a silently wrong workout rather than an error: global `stepOrder` numbering that runs through repeat children, id/key triples that must agree, rests measured in a different field from times, and pace targets expressed as descending metres per second. `buildWorkout` makes all four unreachable (see [`WORKOUTS.md`](WORKOUTS.md)). - **The exercise catalogue, type-checked.** Garmin stores an unrecognised exercise name as an empty string and returns success, so a typo costs you the exercise and tells you nothing. `garminconnect-js/exercises` turns that into a compile error, with 1830 names verified one by one against a live account. Behind both: a verification habit. Every method carries a live-verification status in [`AGENTS.md`](AGENTS.md), and a write is only "verified" once the stored value has been read back: a 2xx on its own has twice hidden a real defect here. **Want a running app first?** [**garminconnect-nextjs-starter**](https://github.com/DynamicsNinja/garminconnect-nextjs-starter) is a Next.js template that signs in (MFA included) and charts your sleep and HRV. Click **Use this template**, and you're a `npm run dev` away from your own data. Or try the [live demo](https://garmin.ficdev.xyz) first. If you're an AI coding agent (or configuring one), read [`AGENTS.md`](AGENTS.md) first: it's a terser, higher-signal briefing than this README and calls out what does *not* exist here. ## โ„น๏ธ About `garminconnect-js` fetches your health, fitness, and activity data from [connect.garmin.com](https://connect.garmin.com) so you can use it in your own Node or Next.js server code, without scraping HTML or reverse-engineering the mobile app yourself. It covers wellness, activities, training metrics, workouts, gear, courses, devices, badges, body composition, women's health, golf, nutrition and training plans (see [API coverage](#-api-coverage) for the breakdown). For anything it doesn't wrap, `client.connectapi()` calls any Garmin Connect endpoint with the same auth. **Compatibility:** requires **Node.js 18+**. This library is server-only (see [Node runtime only](#node-runtime-only) below for why). It has no runtime dependencies and does not run in a browser or on an Edge runtime. **Status:** `0.x`, so the public API may still change between minor versions; breaking changes are listed in [`CHANGELOG.md`](CHANGELOG.md). The endpoints underneath are undocumented and belong to Garmin, who can change them at any time. That is why every method carries a verification date rather than an assurance. Garmin Connect has no public, documented API. Everything here talks to the same endpoints [connect.garmin.com](https://connect.garmin.com) and the Garmin Connect mobile app use, so Garmin can change or break any of it without notice. Don't build anything safety-critical on it, and expect to update when login stops working. ### Known limitation: EU upload consent (412) Garmin returns `412 PreconditionFailedException: "The user is from EU location, but upload consent is not yet granted or revoked"` for **every write** (`addWeighIn`, `importActivity`, `createManualActivity`, workout uploads, gear writes: all of them) on an EU-region account that has not clicked through Garmin Connect's upload-consent flow. This was hit live during this project's own development. It is an account-state precondition, not a library bug: the request shape and URL are correct, and Garmin's server is refusing the write until the account owner grants consent in Garmin Connect's own settings UI. No method in this library wraps Garmin's consent-status check, but you can read it directly: ```ts // Response shape below is LIVE-OBSERVED, not assumed. There is no `enabled` field: // the signal is `userOption`, which reads "opt-in" once consent has been granted. const consent = await client.connectapi<{ userOption?: string }>( "/gdprconsent-service/feature/UPLOAD", ); const uploadConsentGranted = consent?.userOption === "opt-in"; ``` If your first write against an EU account 412s, check this before assuming your request is wrong. ## ๐Ÿ“ฆ Installation & setup ```bash npm install garminconnect-js ``` ```ts import { GarminClient, Garmin, FileTokenStore } from "garminconnect-js"; const client = new GarminClient({ tokenStore: new FileTokenStore("./tokens") }); const result = await client.login(process.env.GARMIN_EMAIL!, process.env.GARMIN_PASSWORD!); if (result.state === "mfa_required") { // login() does not throw or block on MFA; finish it with the code Garmin sent. await client.resumeLogin(result.mfaState, await promptForCode()); } const garmin = new Garmin(client); console.log(await garmin.getUserProfile()); ``` (`promptForCode` is yours to supply: stdin in a script, a second HTTP request in a web app; see [MFA across two HTTP requests](#mfa-across-two-http-requests).) Tokens are now in `./tokens`, so a later process skips the password entirely: ```ts const client = new GarminClient({ tokenStore: new FileTokenStore("./tokens") }); if (!(await client.loadTokens())) throw new Error("Not connected to Garmin: log in first"); const garmin = new Garmin(client); ``` `loadTokens()` returns `false` rather than throwing when the store is empty. ### Client options `new GarminClient(options?)` accepts: | Option | Default | What it does | |---|---|---| | `tokenStore` | `new MemoryTokenStore()` | Where tokens are loaded from and saved to. See [Authentication](#-authentication). | | `isCn` | `false` | `true` routes every request to `garmin.cn` for accounts registered in China. | | `timeoutMs` | `10000` | Per-request timeout for ordinary JSON calls. `download()`/`upload()` default to 60s; every call can override it with `{ timeoutMs }`. | | `retries` | `3` | Retries after the first attempt, on a network error or a 408/500/502/503/504. **POST is never retried**, so a write cannot be duplicated. | | `backoffMs` | `500` | Base delay between retries, doubling each attempt (500, 1000, 2000 ms). | | `fetchImpl` | `globalThis.fetch` | Swap in your own `fetch`: for tests (see [Testing](#-testing)), a proxy, or instrumentation. | | `loginDelayMs` | random 3000โ€“8000 | Pause before the SSO widget's credential POST, used only when the mobile login is rate limited (see [Authentication](#-authentication)). Set `0` in tests. | ## ๐Ÿ” Authentication Login follows Garmin's SSO flow: `GarminClient.login(email, password)` exchanges credentials for an OAuth1 token, then exchanges that for a short-lived OAuth2 access token. Both are handed to your `TokenStore`. - **Rate-limited sign-in.** Garmin rate limits the mobile sign-in route, per client id and source IP, often after one or two sign-ins. When the mobile sign-in page or `/mobile/api/login` answers HTTP 429, or `/mobile/api/login` reports a 429 inside a 200 JSON reply, `login()` signs in once more through Garmin's SSO web widget (`/sso/embed` + `/sso/signin`), which sends no client id, and returns the same tokens. If the widget is rate limited too, `GarminRateLimitError` names the widget URL; other widget failures start with `Mobile login rate limited; `, and a rejected password keeps its `SSO error:` prefix. Tokens refresh for about 30 days without signing in, so keep them in a `TokenStore` rather than signing in again. - **Where tokens are stored:** wherever your `TokenStore` puts them. `FileTokenStore` writes `oauth1_token.json` and `oauth2_token.json` to a directory you choose (`./tokens` in the examples above), using garth's on-disk format, so tokens produced by Python `garth` load here unchanged. For serverless, implement the same three-method interface against your own database or cache; see [`TokenStore` example](#bring-your-own-token-storage) below. - **Auto-refresh:** the OAuth2 access token refreshes automatically, using the OAuth1 token (not an OAuth2 refresh token; Garmin's flow doesn't have one), before it expires. You never call refresh yourself; `connectapi()` calls do it transparently and persist the refreshed token back to your store. - **Observed lifetimes:** OAuth2 access tokens have lasted roughly 27 hours, and the OAuth1 token about 30 days. In practice a session keeps rolling forward as long as you use it (call any method) at least once every 30 days. Beyond that window, `GarminAuthError` is thrown and you need to log in again. - **Cached tokens:** once tokens exist in your store, `client.loadTokens()` reads them back and no further password prompt is needed until the refresh window above lapses. - **A third-party request at login.** The OAuth consumer key and secret are not bundled; like `garth`, the library fetches them from `https://thegarth.s3.amazonaws.com/oauth_consumer.json`, a bucket run by garth's author. That happens once per process, on the first login or token refresh, and the result is cached. If that URL is unreachable (an outage, or a server whose egress is firewalled), login and refresh fail with a `GarminConnectionError`. Allow-list it if you restrict outbound traffic. ### MFA across two HTTP requests `login()` returns `{ state: "mfa_required", mfaState }` instead of throwing. `mfaState` is plain JSON with **no password in it**, so it survives a round trip through a session store. That is what makes MFA work on serverless, where the code arrives in a different request than the one that started the login: ```ts // app/api/garmin/login/route.ts export const runtime = "nodejs"; export async function POST(req: Request) { const { email, password } = await req.json(); const client = new GarminClient({ tokenStore: myStore }); const result = await client.login(email, password); if (result.state === "mfa_required") { await session.set("garminMfa", result.mfaState); // encrypted session return Response.json({ mfaRequired: true, method: result.mfaState.mfaMethod }); } return Response.json({ mfaRequired: false }); } // app/api/garmin/mfa/route.ts export const runtime = "nodejs"; export async function POST(req: Request) { const { code } = await req.json(); const mfaState = await session.get("garminMfa"); const client = new GarminClient({ tokenStore: myStore }); await client.resumeLogin(mfaState, code); await session.delete("garminMfa"); return Response.json({ ok: true }); } ``` Treat `mfaState` as a short-lived secret: `mfaState.cookies` is a live, partially authenticated SSO session, so anyone holding it can finish the login with the code. Encrypt it at rest, scope it to the session that started the login, and delete it once used. The MFA path is exercised live by `npm run login` (see [See it run](#-see-it-run)) as well as unit-tested. `mfaState.flow` says which sign-in route produced it: `"mobile"` (the default; states saved by 0.1.0 or 0.2.0 have no `flow` and count as mobile) or `"widget"`. A widget state also holds the page's CSRF token and form parameters. Pass either kind straight back to `resumeLogin`; read `loginParams` only after checking `flow !== "widget"`. ## ๐Ÿ’ป Code examples ### Node runtime only This library uses `node:crypto` and `Buffer`. It does not run on the Edge runtime. In a Next.js route handler: ```ts export const runtime = "nodejs"; ``` Never import it into a Client Component: credentials and tokens must stay on the server. ### Reading data ```ts // lib/garmin.ts import { GarminClient, Garmin } from "garminconnect-js"; export async function getGarmin() { const client = new GarminClient({ tokenStore: myStore }); if (!(await client.loadTokens())) throw new Error("Not connected to Garmin"); return new Garmin(client); } // app/api/sleep/route.ts export const runtime = "nodejs"; export async function GET(req: Request) { const date = new URL(req.url).searchParams.get("date")!; const garmin = await getGarmin(); return Response.json(await garmin.getSleepData(date)); } ``` `Garmin` caches the resolved user profile (and user settings) for the lifetime of the instance. `getGarmin()` above builds a fresh `Garmin` per request, which is fine for a single call, but any date-scoped method (`getSleepData`, `getStepsData`, ...) resolves `displayName` first, so a new `Garmin` per request costs an extra `socialProfile` fetch on every call. If a request handler makes several Garmin calls, or you're calling from a long-lived process (a cron job, a worker), hoist and reuse one `Garmin` instance instead of building a new one per call. Dates are interpreted as **UTC calendar dates**, not local ones. Passing a `Date` object (instead of a `"YYYY-MM-DD"` string) is formatted with `toISOString().slice(0, 10)`, so a caller in a negative UTC-offset timezone (e.g. US Pacific) who calls `new Date()` late in their local day can get tomorrow's date, because it's already tomorrow in UTC. Pass an explicit `"YYYY-MM-DD"` string when you need the calendar date in the user's own timezone. `getSleepData` returns `SleepData | null` (and so does `getHrvData`) rather than throwing when Garmin has no data for the requested date. Check for `null` before using the result. ### Bring your own token storage `FileTokenStore` suits scripts and a single long-lived server. On serverless, implement the three-method interface against whatever you already run: ```ts import type { TokenStore, Tokens } from "garminconnect-js"; // `Redis` here is illustrative; bring your own client's type // (e.g. `import type { Redis } from "ioredis";`). type Redis = { get(key: string): Promise; set(key: string, value: string): Promise; del(key: string): Promise }; export class RedisTokenStore implements TokenStore { constructor(private redis: Redis, private userId: string) {} async load(): Promise { const raw = await this.redis.get(`garmin:${this.userId}`); return raw ? (JSON.parse(raw) as Tokens) : null; } async save(tokens: Tokens): Promise { await this.redis.set(`garmin:${this.userId}`, JSON.stringify(tokens)); } async clear(): Promise { await this.redis.del(`garmin:${this.userId}`); } } ``` **Persist the whole `Tokens` object, and keep `expires_at` and `refresh_token_expires_at` as numbers.** Every refresh decision reads them. Storing the whole JSON blob, as above, keeps them; a database schema or a field allowlist that drops them does not, and the client then treats the token as expired (it fails closed) and refreshes on every call. Round-trip your store once in a test and assert both numbers survive. ### Uploading an activity file ```ts import { readFile } from "node:fs/promises"; const file = new Blob([await readFile("ride.fit")]); // As an import (the extension picks the endpoint, and must be .fit, .gpx or .tcx): const imported = await garmin.importActivity(file, "ride.fit"); // Or as an ordinary device-sync-shaped upload: await garmin.uploadActivity(file, "ride.fit"); ``` Both are live-verified: a synthetic GPX was uploaded, found by polling `getActivities` once Garmin finished processing it asynchronously (a few seconds to ~20s), then deleted. A duplicate file makes `importActivity` throw a `GarminConnectionError` ("Activity already exists"). The two hit different endpoints with different headers; see [`AGENTS.md`](AGENTS.md) section 6 before swapping one for the other. Both are built on `client.upload(file, filename, path?, options?)`, which you can call directly for an endpoint no method wraps. It posts multipart form data (field name `file`) to `path` (default `/upload-service/upload`) with a 60s default timeout: ```ts await client.upload(file, "ride.fit", "/upload-service/upload", { timeoutMs: 120_000 }); ``` ### Courses A course is a saved route you can send to a device and follow. Creating one from a GPX file is two steps inside Garmin (parse, then save), and `createCourseFromGpx` does both: ```ts const course = await garmin.createCourseFromGpx(new Blob([gpxText]), "loop.gpx", { name: "Sunday loop", activityTypeId: 10, // a Garmin activity-type id; the default, 1, is running privacy: "private", }); await garmin.updateCourse(course!.courseId!, { name: "Sunday long loop", privacy: "public" }); const gpx = await garmin.downloadCourseGpx(course!.courseId!); ``` Right after creation Garmin is still processing the course, and an update or delete can fail with a 429 "not yet ready": a `GarminRateLimitError`, though it is not rate limiting. Retry after a few seconds. ### Errors | Error | Meaning | |---|---| | `GarminError` | Base class for everything below; also thrown directly for malformed responses. | | `GarminAuthError` | 401/403, failed SSO, or expired tokens. Log in again. | | `GarminRateLimitError` | 429. Carries `retryAfter` seconds when Garmin sends it. | | `GarminConnectionError` | Network failure or timeout (after retries), **and** a few semantic HTTP statuses that some services deliberately re-raise as this class: **every** HTTP error from `importActivity` (not just its 409 "Activity already exists": a 400 or 413 is wrapped the same way), the 404 ("gear not found (likely retired/removed)") from `addGearToActivity` and `removeGearFromActivity`, and a missing `deviceSolarInput` from `getDeviceSolarData`. Those are permanent, not transient. Do not blanket-retry on this class; check the message or the `cause`. | | `GarminHttpError` | Any other non-2xx. Carries `status`, `url`, `body`. | | `GarminConnectPlusRequiredError` | The account has no Garmin Connect+, which this method needs (food logging). Carries `method`. Signing in again won't help; check up front with `garmin.hasConnectPlus()`. | ## ๐ŸŠ Building workouts Creating a Garmin workout by hand means writing deeply nested JSON with several non-obvious rules: step numbering that runs across repeat blocks, enum references that must agree in three places, and pace targets expressed as descending metres-per-second. `buildWorkout` handles all of that: ```ts import { buildWorkout } from "garminconnect-js"; const workout = buildWorkout("4 x 1 km", { sport: "running" }) .warmup({ time: 600, target: { heartRateZone: 2 } }) .repeat(4, (set) => set .interval({ distance: 1000, target: { pace: { minPerKm: [4.5, 5] } }, secondaryTarget: { cadence: [176, 184] }, }) .recovery({ time: 120 }), ) .cooldown({ lapButton: true }) .build(); await garmin.uploadWorkout(workout); ``` It covers all twelve sports, every step and end-condition type, primary and secondary targets, nested and time-boxed repeats, swim strokes/drills/equipment, strength exercises and weights, and multi-sport bricks. `uploadWorkout` still accepts raw JSON, so the builder is optional. For strength work, Garmin accepts an unknown exercise `name` and silently stores it as an empty string: no error, just a step with no exercise. So the builder type-checks it: once you pick a `category`, `name` autocompletes to that category's names, and anything else is a compile error. All 1830 names were verified one by one against a live account: ```ts .interval({ reps: 8, exercise: { category: "SQUAT", name: "BARBELL_BACK_SQUAT" }, weightKg: 60 }) ``` The catalogue itself is a separate entry point, `garminconnect-js/exercises`, so its data never reaches the root bundle. Import it when you need the list or a runtime check: ```ts import { EXERCISES, exercise, isExerciseName } from "garminconnect-js/exercises"; EXERCISES.SQUAT; // all 106 squat names, e.g. for a picker exercise("SQUAT", "BARBELL_BACK_SQUAT"); // the same compile-time check, as a helper isExerciseName("SQUAT", nameFromYourDatabase); // for names that are only strings at runtime ``` **โ†’ [`WORKOUTS.md`](WORKOUTS.md) is the full guide**, with a worked example for every sport and a reference for every option. Runnable versions of those examples live in [`examples/workout-gallery.ts`](examples/workout-gallery.ts). ## ๐Ÿค– Use it from Claude [`@dynamicsninja/garminconnect-mcp`](mcp/README.md) puts this library behind an MCP server, so you can ask Claude Desktop to build a workout, schedule it and send it to your watch, or to read your sleep and training data. It ships from this repo, in lockstep with the library. Or use it without installing anything: add `https://garmin.ficdev.xyz/mcp` as a custom connector in claude.ai (see [mcp/README.md](mcp/README.md)). ## ๐Ÿ“Š API coverage **200 typed methods across 13 categories.** Each category links to a generated [`docs/api/`](docs/api/README.md) page with every method's signature, a call you can paste, and its live-verification status (confirmed against a real Garmin account, not merely unit-tested). | Category | Methods | Verified live | Covers | |---|---|---|---| | [Wellness](docs/api/wellness.md) | 35 | all | steps, heart rate, sleep, HRV, stress, SpO2, respiration, hydration, blood pressure, body battery, whole-day summaries | | [Activities](docs/api/activities.md) | 38 | all | list/filtered search/detail, splits, weather, workouts done, manual creation, import/upload, exercise sets, gear links, personal records, event type, RPE and feel | | [Training metrics](docs/api/metrics.md) | 18 | all | training status, race predictions, FTP, lactate threshold, HR/power zones (and setting HR zones), endurance and hill score | | [Workouts](docs/api/workouts.md) | 19 | all | CRUD, per-sport upload, scheduling, schedule summaries, training-plan and Garmin Coach workouts, device push | | [Gear](docs/api/gear.md) | 6 | all | CRUD, activity defaults, stats | | [Courses](docs/api/courses.md) | 8 | all | import a GPX, create, rename, privacy, export as GPX, delete | | [Calendar events](docs/api/calendar-events.md) | 6 | all | races and other events: list, read, create, update, delete; races from Garmin's catalogue | | [Devices](docs/api/devices.md) | 6 | all | devices, settings, alarms, solar, last used | | [Badges & challenges](docs/api/badges-challenges.md) | 9 | all | earned/available badges, badge detail, challenges | | [Body composition & weight](docs/api/body-composition-weight.md) | 9 | 8 of 9 | weigh-ins, weight goals, body composition (FIT upload) | | [Women's health](docs/api/womens-health.md) | 11 | all | menstrual cycle, pregnancy | | [Golf](docs/api/golf.md) | 5 | 3 of 5 | summary, scorecards, shots, clubs, stats | | [Profile, goals, nutrition, plans & misc](docs/api/profile-and-misc.md) | 30 | allยน | profile, settings, goals, nutrition and food loggingยฒ, training plans, GraphQL, logout | ยน `logout()` makes no HTTP call, so there is nothing to verify against Garmin. ยฒ Food logging (search, custom foods, logging, deleting) needs Garmin Connect+, which needs a paired Garmin device; verified on such an account. `garmin.hasConnectPlus()` tells you up front, `CONNECT_PLUS_METHODS` lists the methods, and without it they throw `GarminConnectPlusRequiredError`. **Still unverified:** `getGolfScorecard` and `getGolfShotData`, because no available account has a recorded round. `getGolfShotData` also returns an unexplained **410** against a made-up id, and one real scorecard would show whether Garmin has retired that endpoint. `getWeightGoals` is partial: it answers on both accounts, but neither has a weight goal, so only the empty response is confirmed. **Worth knowing before you call:** - `addHydrationData` is permanent: Garmin has no delete for it. - The badge-challenge endpoints reject `start=0` server-side; pass `start >= 1`. - Women's-health writes need cycle-tracking settings that only Garmin's own first-run wizard creates. Run it once in the web UI first. - `getActivity(id)` is not shaped like a list row: distance, duration and the rest are under `summaryDTO`, the type under `activityTypeDTO`. - `searchActivities` returns an empty list, not an error, for an unknown `sortBy` or `requiredFields` name (Garmin's names, not always the response's: `averageHR` is unknown). - Many response types are filled in from Garmin's official Personal API spec, also exported on their own as the `PersonalApi` type namespace. Every field there is optional: Garmin leaves out what does not apply. The per-method gotchas and the evidence behind every verification are in [`AGENTS.md`](AGENTS.md) section 3. The standing rule behind all of this: a live *write* probe only runs when the value can be read back and the change undone, and a 2xx is never accepted as evidence on its own. ## ๐Ÿ› ๏ธ Development setup ```bash git clone https://github.com/DynamicsNinja/garminconnect-js.git cd garminconnect-js npm install ``` | Command | What it does | |---|---| | `npm run check` | Typecheck, lint, build and test, in that order: what CI and `prepublishOnly` run. Building first means the tests also cover the built package. | | `npm run build` | Bundles `src` to `dist` with tsup (ESM + CJS + types). | | `npm run typecheck` | `tsc --noEmit` over `src`, `tests`, `scripts`, `examples`. | | `npm run lint` | ESLint over `src`, `tests`, `scripts` and `examples`. | | `npm run format` | Prettier, writing in place. | | `npm test` | Runs the unit/integration suite against mocked HTTP (see [Testing](#-testing)). | | `npm run test:watch` | Same, in watch mode. | | `npm run test:live` | Runs the live suite against the real Garmin API; requires `./tokens` from `npm run login`. | | `npm run docs:api` | Regenerates [`docs/api/`](docs/api/README.md) from the code and `AGENTS.md`. A test fails if it is stale. | | `npm run login` | One-time interactive login; writes tokens to `./tokens`. | | `npm run demo` | Runs [`examples/demo.ts`](examples/demo.ts) against the tokens in `./tokens`. | | `npm run record` | Refreshes the scrubbed fixtures under `tests/fixtures/` from a live account (requires tokens). | | `npm run smoke` | Live read probes against the test account, by category (`npm run smoke -- misc`). | | `npm run smoke:write` / `smoke:gaps` | Live write probes: create, read back, delete. | | `npm run smoke:builder` | Builds, uploads, reads back and deletes a workout for each of the twelve sports. | | `npm run smoke:matrix` | The builder's full option cross-product for swim, bike and run, compared field by field on read-back. | | `npm run verify:exercises -- ` | Verifies candidate exercise names by upload and read-back, keeping only those Garmin echoes back. | | `npm run login:real` / `smoke:real` | Login to, and **read-only** probe of, a real personal account, on a transport that refuses any non-GET. Uses its own `./tokens-real`. See `AGENTS.md` section 8. | | `npm run smoke:personal-api` | Read-only, real account: calls every path in Garmin's Personal API spec on our host and diffs the field names against the spec. Fetches the spec from developer.garmin.com. | | `npm run personal-api-types` | Regenerates `src/generated/personal-api.ts` (the `PersonalApi` types) from the live spec. Needs network. | The four write commands (`smoke:write`, `smoke:gaps`, `smoke:builder`, `smoke:matrix`) and `verify:exercises` refuse to run unless the logged-in profile matches `GARMIN_TEST_PROFILE_ID`: they are for a disposable test account, never a real one. `smoke:real` has the inverse gate: it refuses if the profile DOES match. Release: `npm run bump -- X.Y.Z` bumps the library and `@dynamicsninja/garminconnect-mcp` together; pushing tag `vX.Y.Z` publishes both. **Credentials from a `.env` file:** the dev scripts (`npm run login`, `npm run demo`, `npm run record`) load `GARMIN_EMAIL` and `GARMIN_PASSWORD` from a `.env` file in the repo root, if one exists, so credentials stay out of shell history. `npm run login:real` reads `GARMIN_REAL_EMAIL`/`GARMIN_REAL_PASSWORD` instead, so both accounts can share one `.env`. Real environment variables take precedence, and `.env` is gitignored. ### What running it locally does `npm run login` and `npm run demo` talk to the real `connectapi.garmin.com` / `sso.garmin.com` endpoints using **your real Garmin credentials and your real account data**. Concretely: - `npm run login` sends your email and password to Garmin's SSO service over HTTPS, the same request the mobile app makes, and writes real OAuth tokens to `./tokens` (gitignored). - `npm run demo` and `npm run record` then use those tokens to fetch **your own** steps, heart rate, sleep, HRV, activities, and body-composition history. - `npm run demo`'s "Export all to JSON" option writes a file under `./export/` (gitignored) that has been passed through the same `scrub()` helper the test fixtures use. But scrubbing redacts by field name, not by content, so free-text fields (activity titles, notes) are not scanned. Treat any export as containing real personal data. - None of your data leaves your machine except in requests to Garmin's own servers. The only other request is the OAuth consumer-key fetch described under [Authentication](#-authentication), which carries no account data. ## ๐Ÿ“Ÿ See it run Two files show it working end to end: - [`scripts/login.ts`](scripts/login.ts): one-time interactive login. Prompts for an MFA code on stdin if Garmin asks for one, then writes tokens to `./tokens`. - [`examples/demo.ts`](examples/demo.ts): an interactive, numbered-menu demo (`npm run demo`) that loads those tokens and walks through the categories this library implements: profile, daily health, activities, body composition, and a JSON export. ```text $ npm run login MFA code (sent via email): Tokens written to ./tokens $ npm run demo garminconnect-js demo ===================== 1) User & Profile 2) Daily Health 3) Activities 4) Body Composition 5) Export all to JSON 0) Exit > 1 --- User & Profile --- Display name : jdoe-display Full name : Jamie Doe Profile ID : 1234567 Unit system : metric garminconnect-js demo ===================== 1) User & Profile 2) Daily Health 3) Activities 4) Body Composition 5) Export all to JSON 0) Exit > 2 --- Daily Health (2026-09-21) --- Steps: 8742 Distance: 6.3 km Active kcal: 512 Resting HR: 58 bpm Min/Max: 54/142 bpm Sleep data: available HRV: available Body Battery entries: 24 garminconnect-js demo ===================== 1) User & Profile 2) Daily Health 3) Activities 4) Body Composition 5) Export all to JSON 0) Exit > 0 Goodbye. ``` (Numbers above are synthetic: a demo fixture, not a real account.) ## โœ… Testing ```bash npm test ``` 874 tests across 60 files, all against mocked HTTP (via `msw`): no network access and no credentials required. Covers auth/SSO/MFA, token storage and refresh, the HTTP fetcher's retry and error handling, every service method, and the public build output. ```bash npm run test:live ``` Runs a separate suite against the real Garmin API. Requires tokens from `npm run login` and is not part of CI; run it locally when you want to confirm live behavior. **Testing your own code against this library:** mock at the `fetch` layer rather than stubbing `Garmin` methods. Pass a mock (or an `msw` handler) as `GarminClientOptions.fetchImpl`, and the real request construction, auth headers, retries and response parsing all still run. ## ๐Ÿค Contributing Before opening a PR: - [ ] `npm run check` passes: typecheck, lint, build and tests, in CI's exact order. CI runs this same script, so a green local run cannot diverge from a green pipeline by omission. - [ ] `npm run docs:api` has been re-run if you changed a method or its `AGENTS.md` row, and the regenerated `docs/api/` files are committed. A test fails if they are stale. - [ ] New endpoints follow the existing `services/*` + `Garmin` method pattern, with a typed response interface in `types/`. - [ ] Commits follow [Conventional Commits](https://www.conventionalcommits.org/). - [ ] No real credentials, tokens, personal names, emails, or GPS coordinates in anything committed. Recorded fixtures must go through `scrub()` and be manually inspected before staging, per the reminder `npm run record` prints. - [ ] If you're changing what's implemented, update the [API coverage](#-api-coverage) table above rather than leaving it stale. - [ ] Note anything user-visible in [`CHANGELOG.md`](CHANGELOG.md). Security issues go through [`SECURITY.md`](SECURITY.md), privately, not a public issue: this library holds live Garmin credentials on people's behalf. ## โ˜• Support If this library saves you time, you can support its development: [![Buy Me a Coffee][bmc-shield]][bmc-url] [![PayPal][paypal-shield]][paypal-url] ## ๐Ÿ“š Additional resources & acknowledgements - [connect.garmin.com](https://connect.garmin.com): the service this library talks to. - [`CHANGELOG.md`](CHANGELOG.md): what changed in each release. - The SSO/OAuth flow was originally derived from [garth][garth-url] and [python-garminconnect][python-garminconnect-url]; attribution is in [`NOTICE`](NOTICE). ## License MIT. See [`NOTICE`](NOTICE) for third-party attribution. [python-garminconnect-url]: https://github.com/cyberjunky/python-garminconnect [garth-url]: https://github.com/matin/garth [npm-shield]: https://img.shields.io/npm/v/garminconnect-js?style=flat [npm-url]: https://www.npmjs.com/package/garminconnect-js [node-shield]: https://img.shields.io/node/v/garminconnect-js?style=flat [node-url]: https://nodejs.org/ [activity-shield]: https://img.shields.io/github/last-commit/DynamicsNinja/garminconnect-js?style=flat [activity-url]: https://github.com/DynamicsNinja/garminconnect-js/commits/main [license-shield]: https://img.shields.io/github/license/DynamicsNinja/garminconnect-js?style=flat [license-url]: https://github.com/DynamicsNinja/garminconnect-js/blob/main/LICENSE [maintenance-shield]: https://img.shields.io/badge/Maintained%3F-yes-green.svg?style=flat [maintenance-url]: https://github.com/DynamicsNinja/garminconnect-js/graphs/commit-activity [bmc-shield]: https://img.shields.io/badge/Buy%20Me%20a%20Coffee-support-FFDD00?style=flat&logo=buymeacoffee&logoColor=black [bmc-url]: https://buymeacoffee.com/dynamicsninja [paypal-shield]: https://img.shields.io/badge/PayPal-donate-00457C?style=flat&logo=paypal&logoColor=white [paypal-url]: https://paypal.me/ivanficko