Skip to content
Docs menu

Code examples

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:

export const runtime = "nodejs";

Never import it into a Client Component — credentials and tokens must stay on the server.

Reading data

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

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<string | null>; set(key: string, value: string): Promise<unknown>; del(key: string): Promise<unknown> };

export class RedisTokenStore implements TokenStore {
  constructor(private redis: Redis, private userId: string) {}

  async load(): Promise<Tokens | null> {
    const raw = await this.redis.get(`garmin:${this.userId}`);
    return raw ? (JSON.parse(raw) as Tokens) : null;
  }
  async save(tokens: Tokens): Promise<void> {
    await this.redis.set(`garmin:${this.userId}`, JSON.stringify(tokens));
  }
  async clear(): Promise<void> {
    await this.redis.del(`garmin:${this.userId}`);
  }
}

Persist the whole Tokens object — in particular 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

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

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:

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

ErrorMeaning
GarminErrorBase class for everything below; also thrown directly for malformed responses.
GarminAuthError401/403, failed SSO, or expired tokens. Log in again.
GarminRateLimitError429. Carries retryAfter seconds when Garmin sends it.
GarminConnectionErrorNetwork 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.
GarminHttpErrorAny other non-2xx. Carries status, url, body.
GarminConnectPlusRequiredErrorThe 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().