DEV Community

Cover image for From zero to your first Garmin data in Node
Ivan Ficko
Ivan Ficko

Posted on

From zero to your first Garmin data in Node

For developers. Just want to ask Claude about your training, with no code? Start with Your Garmin data in Claude instead.

In my first post I introduced garminconnect-js, a typed, zero-dependency Garmin Connect client for Node. This series is the hands-on version: four posts that take you from an empty folder to an app that reads and writes Garmin data.

This first part covers the foundation everything else stands on:

  1. Signing in, including two-factor (MFA) codes
  2. Keeping the session, so you sign in once and not on every run
  3. Reading data, and what "no data" looks like
  4. Handling errors properly

By the end you'll have a small command-line script that prints last night's sleep and HRV.

Written against garminconnect-js 0.10.0. Every snippet here type-checks against that version.

What you need

  • Node 18 or newer (node --version)
  • A Garmin Connect account with some data in it. A watch that records sleep makes this post more fun, but everything works without one.

A quick note on what this library is: Garmin has no public API for your own Connect data, so garminconnect-js talks to the same endpoints Garmin's web and mobile apps use. It's unofficial, those endpoints can change, and it's server-side only. Your Garmin password and tokens should never reach a browser.

Set up the project

mkdir garmin-sleep && cd garmin-sleep
npm init -y
npm install garminconnect-js
npm install -D tsx typescript @types/node
Enter fullscreen mode Exit fullscreen mode

On Deno or Bun, the same code is published on JSR as @dynamicsninja/garminconnect (npx jsr add @dynamicsninja/garminconnect).

tsx runs TypeScript files directly, so there's no build step for a script like this. Add "type": "module" to package.json so top-level await works:

{
  "type": "module"
}
Enter fullscreen mode Exit fullscreen mode

And keep your secrets out of git from the start:

echo "tokens/" >> .gitignore
echo ".env" >> .gitignore
Enter fullscreen mode Exit fullscreen mode

Step 1: sign in once

Create login.ts:

import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { GarminClient, 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") {
  const rl = createInterface({ input: stdin, output: stdout });
  const code = await rl.question("Garmin sent you a code. Enter it: ");
  rl.close();
  await client.resumeLogin(result.mfaState, code.trim());
}

console.log("Signed in. Your session is saved in ./tokens");
Enter fullscreen mode Exit fullscreen mode

Run it with your credentials in the environment:

GARMIN_EMAIL=you@example.com GARMIN_PASSWORD='...' npx tsx login.ts
Enter fullscreen mode Exit fullscreen mode

(On Windows PowerShell, set them first with $env:GARMIN_EMAIL="you@example.com", or use a .env file and node --env-file=.env --import tsx login.ts.)

If you use a .env file and your password contains a #, put the value in quotes: GARMIN_PASSWORD="...". Without them, Node reads everything from the # on as a comment, Garmin gets a shortened password, and you get a GarminAuthError that looks like a wrong password. It happened to me while testing this post.

Three things are worth understanding here.

login() doesn't throw or block on MFA. If your account has two-factor turned on, it returns { state: "mfa_required", mfaState } and you finish with resumeLogin(mfaState, code). In a script, that's a prompt. In a web app, the two halves can happen in two separate HTTP requests, which is exactly what Part 2 builds on.

FileTokenStore keeps your session on disk. After a successful login, the library writes two small JSON files to ./tokens. Those files are your Garmin session, so treat them like a password: keep them out of git, and off any machine you don't trust.

Sign in as rarely as possible. Garmin rate-limits sign-ins, sometimes after only one or two attempts in a row. The library already falls back to Garmin's web sign-in when the mobile one is rate-limited, but the real fix is not to sign in repeatedly. Sign in once, then reuse the tokens.

Step 2: reuse the session

Every other script starts like this instead:

import { GarminClient, Garmin, FileTokenStore } from "garminconnect-js";

const client = new GarminClient({ tokenStore: new FileTokenStore("./tokens") });

if (!(await client.loadTokens())) {
  console.error("Not signed in yet. Run login.ts first.");
  process.exit(1);
}

const garmin = new Garmin(client);
Enter fullscreen mode Exit fullscreen mode

loadTokens() returns false rather than throwing when there's nothing saved, so the "not signed in" case is just an if.

You never refresh tokens yourself. The short-lived access token lasts about a day, and the library renews it automatically before any request that needs it, then saves the new one back through your token store. The underlying session keeps rolling forward as long as you make a call at least once every 30 days. Go longer than that and you'll get a GarminAuthError, which means "sign in again".

Garmin is the class with all the methods (about 200 of them). Create one per signed-in user and reuse it: it caches your profile, which most date-based calls need.

Step 3: read last night's sleep

Here's the full script, sleep.ts:

import { GarminClient, Garmin, FileTokenStore, GarminAuthError, GarminError } from "garminconnect-js";
import type { PersonalApi } from "garminconnect-js";

const client = new GarminClient({ tokenStore: new FileTokenStore("./tokens") });
if (!(await client.loadTokens())) {
  console.error("Not signed in yet. Run login.ts first.");
  process.exit(1);
}
const garmin = new Garmin(client);

// Garmin files a night's sleep under the date you woke up.
const date = process.argv[2] ?? new Date().toISOString().slice(0, 10);

const hours = (seconds?: number | null) =>
  seconds == null ? "?" : `${Math.floor(seconds / 3600)}h ${Math.round((seconds % 3600) / 60)}m`;

try {
  const [sleep, hrv] = await Promise.all([garmin.getSleepData(date), garmin.getHrvData(date)]);

  const night = sleep?.dailySleepDTO as PersonalApi.DailySleepDTO | undefined;
  if (!night?.sleepTimeSeconds) {
    console.log(`No sleep recorded for ${date}.`);
  } else {
    console.log(`Sleep on ${date}: ${hours(night.sleepTimeSeconds)}`);
    console.log(`  Score: ${night.sleepScores?.overall?.value ?? "n/a"}`);
    console.log(`  Deep ${hours(night.deepSleepSeconds)}, REM ${hours(night.remSleepSeconds)}`);
  }

  const summary = hrv?.hrvSummary;
  if (summary) {
    console.log(`HRV: ${summary.lastNightAvg ?? "?"} ms last night, ${summary.weeklyAvg ?? "?"} ms weekly average (${summary.status ?? "no status"})`);
  }
} catch (error) {
  if (error instanceof GarminAuthError) {
    console.error("Your Garmin session has expired. Run login.ts again.");
  } else if (error instanceof GarminError) {
    console.error(`Garmin error: ${error.message}`);
  } else {
    throw error;
  }
  process.exit(1);
}
Enter fullscreen mode Exit fullscreen mode

Run it for today, or for any date:

npx tsx sleep.ts
npx tsx sleep.ts 2026-10-20
Enter fullscreen mode Exit fullscreen mode

The output looks like this (your numbers will differ):

Sleep on 2026-10-20: 7h 34m
  Score: 81
  Deep 1h 22m, REM 1h 51m
HRV: 52 ms last night, 49 ms weekly average (BALANCED)
Enter fullscreen mode Exit fullscreen mode

"No data" is not an error

Notice the checks for null and undefined. For most Garmin endpoints, an empty answer is normal: no watch worn that night, no HRV feature on your device, or simply a date before you owned the watch. getSleepData and getHrvData return null in that case; they don't throw.

A few methods do throw on an empty answer, for example getActivity when the id doesn't exist. The library's reference says which is which, method by method, so check there rather than assuming one rule for all.

Dates are UTC calendar dates

Every method takes a date either as "YYYY-MM-DD" or as a Date. A Date is converted with toISOString(), which is UTC. If you're in New York and pass new Date() at 9 p.m., you're asking for tomorrow. When the day matters, pass the string you mean.

Typed, but honest about it

Most responses have real types. Many are generated from Garmin's own Personal API spec and exported as PersonalApi.*, which is where DailySleepDTO above comes from. A few raw responses are deliberately loose (Record<string, unknown>) because Garmin's shape varies too much to promise more. That's why the script narrows dailySleepDTO to the spec's type in one place.

Handling errors

Every error the library throws is a GarminError or a subclass, so you can handle them by type:

Error Means What to do
GarminAuthError The session is gone or invalid (401/403, expired tokens) Sign in again
GarminRateLimitError Garmin said 429 Back off. It carries retryAfter (seconds) when Garmin sends one
GarminConnectionError Network failure or timeout, after retries Retry later, but read the note below
GarminHttpError Any other non-2xx Inspect status and body

The library already retries GET requests on network errors and 5xx responses, with backoff. It never retries a POST, so a write can't be duplicated behind your back.

One trap: a few permanent errors are deliberately reported as GarminConnectionError, such as uploading an activity that already exists. So never write a blind catch (e) { if (e instanceof GarminConnectionError) retryForever() }. Look at the message, or at error.cause, first.

Beyond sleep

The same pattern, sign in once and then call methods, covers the rest of your data. A few to try in the same script:

await garmin.getUserSummary(date);            // steps, calories, stress, Body Battery for one day
await garmin.getDailySummaries("2026-10-01", "2026-10-20"); // the same, one row per day, for a range
await garmin.getTrainingReadiness(date);      // readiness score and what's driving it
await garmin.getRacePredictions();            // 5K, 10K, half and full marathon predictions
await garmin.getActivities(0, 5);             // your five most recent activities
Enter fullscreen mode Exit fullscreen mode

And if you need an endpoint the library doesn't wrap, client.connectapi(path) calls any Garmin Connect path with the same authentication. You'll see that in Part 4.

What's next

You now have the two building blocks: a session you sign in to once, and typed reads with proper error handling. In Part 2 we'll put this behind a web app: a Next.js app where users sign in with their own Garmin account (MFA included, across two requests), with tokens stored in your database instead of a folder.

If something here doesn't match what your account returns, tell me in the comments. Undocumented endpoints are full of surprises, and every report makes the library better.

Top comments (0)