For developers. Just want to ask Claude about your training, with no code? Start with Your Garmin data in Claude instead.
So far in this series we've signed in from a script and built a web app where users connect their Garmin account. Now for the data people actually want: their training.
This post covers:
- Searching and filtering activities, and three traps that return nothing without an error
- Opening one activity: details, splits and the original file
- Day-by-day health data over long ranges
- Readiness, training status and race predictions
- Putting it together: a weekly training report
Written against garminconnect-js 0.10.0. Every snippet type-checks against that version. Each one assumes a signed-in
garminfrom Part 1.
Listing and searching activities
The simplest call gives you the most recent activities, newest first:
const latest = await garmin.getActivities(0, 10); // start, limit
for (const a of latest) {
console.log(a.startTimeLocal, a.activityType?.typeKey, a.activityName, `${((a.distance ?? 0) / 1000).toFixed(1)} km`);
}
Garmin works in metric base units throughout: distance in metres, durations in seconds, speed in metres per second. Convert at the edge of your app, once.
For anything more specific, use searchActivities. It's new in 0.10, and it exposes the filters from Garmin's own API spec:
const longRuns = await garmin.searchActivities({
activityType: "running", // includes treadmill and trail runs
startDate: "2026-06-01",
endDate: "2026-10-24",
minDistance: 10_000, // metres
sortBy: "distance",
sortOrder: "desc",
limit: 20,
});
The filters combine with AND. You can also filter by text in the name (search), subtype (activitySubType: "trail_running"), event type (eventType: "race"), duration, elevation gain, average power, favourites and more. Your editor lists them all.
Three traps that return nothing
Garmin's search endpoint has a nasty habit: some mistakes don't fail, they just return an empty list. An empty result looks exactly like "you have no matching activities", so these are worth knowing:
-
An unknown
sortByreturns[]."startLocal"(the default) and"distance"are verified to work. -
An unknown name in
requiredFieldsreturns[]. And Garmin's field names aren't always the response's:requiredFields: ["averageHR"]matches nothing, even though every activity has anaverageHR. -
activityTypemust be a parent type."running"works and includes its subtypes. A subtype like"trail_running"there is a 400 error; put it inactivitySubTypeinstead.
So if a search comes back empty and you didn't expect it to, remove filters one at a time before concluding there's no data.
Every activity in a date range
searchActivities returns one page. To get everything in a range, use getActivitiesByDate, which pages through Garmin's results for you, 20 at a time, until there are no more:
const allOfSeptember = await garmin.getActivitiesByDate("2026-09-01", "2026-09-30");
const runsOnly = await garmin.getActivitiesByDate("2026-09-01", "2026-09-30", "running");
One activity in detail
A list row is a summary. getActivity(id) returns the full record, and it's shaped differently: the numbers live under summaryDTO, and the type under activityTypeDTO.
const run = await garmin.getActivity(longRuns[0]!.activityId);
const s = run.summaryDTO;
console.log(run.activityName, run.activityTypeDTO?.typeKey);
console.log(`${((s?.distance ?? 0) / 1000).toFixed(2)} km in ${Math.round((s?.duration ?? 0) / 60)} min, avg HR ${s?.averageHR ?? "n/a"}`);
From there, a handful of methods go deeper. All take the activity id:
| Method | What you get |
|---|---|
getActivitySplits(id) |
Laps, as recorded by the watch |
getActivityTypedSplits(id) |
Richer splits for some sports (climbing, for example) |
getActivitySplitSummaries(id) |
Totals per split type, such as run vs walk |
getActivityDetails(id) |
The time series: heart rate, pace, cadence and position, sampled for charts |
getActivityHrInTimezones(id) |
Time spent in each heart-rate zone |
getActivityWeather(id) |
The weather during the activity |
These detail responses are large and vary by sport and device, so they're typed loosely. Log one with console.dir(result, { depth: 3 }) for your own activities before you build on it.
Downloading the original file
import { writeFile } from "node:fs/promises";
const fit = await garmin.downloadActivity(run.activityId); // the original file, zipped
const gpx = await garmin.downloadActivity(run.activityId, "GPX"); // or TCX, KML, CSV
await writeFile(`run-${run.activityId}.gpx`, gpx);
The formats are "ORIGINAL" (the default: exactly what your watch uploaded, usually a zipped .fit), "TCX", "GPX", "KML" and "CSV". Downloads get a 60-second timeout instead of the usual 10.
Going the other way, importActivity(file, filename) uploads a .fit, .gpx or .tcx file. Garmin processes it in the background, so the new activity appears in getActivities a few seconds later, not immediately. Uploading the same file twice throws a GarminConnectionError saying the activity already exists.
Health data over long ranges
For trends, you want one row per day. getDailySummaries returns a typed summary for each day, grouped by topic:
const days = await garmin.getDailySummaries("2026-07-01", "2026-10-24");
for (const d of days) {
console.log(
d.calendarDate,
`${d.movement?.steps?.value ?? 0} steps`,
`RHR ${d.heartRate?.restingValue ?? "?"}`,
`stress ${d.stress?.avgLevel ?? "?"}`,
`Body Battery +${d.bodyBattery?.chargedValue ?? "?"}`,
);
}
Two things to know about ranges:
-
Days with no data are missing, not empty. If you didn't wear the watch on a day, that date isn't in the result at all. Don't assume
days[6]is "a week ago"; match oncalendarDate. -
Garmin caps how much you can ask for at once, and the cap differs per endpoint: a year for daily summaries, 28 days for sleep and steps. The library splits longer ranges into allowed windows for you, so you can ask for two years of
getDailySummariesor three months ofgetSleepDailyin one call.
A few related range methods: getSleepDaily(start, end) for nightly sleep, getRhrDaily(start, end) for resting heart rate as { calendarDate, value } pairs, and getHrvDataRange(start, end) for overnight HRV.
Readiness, training status and race predictions
These are the numbers your watch shows on its morning report:
const today = "2026-10-24";
const readiness = await garmin.getMorningTrainingReadiness(today); // the score from when you woke up
const status = await garmin.getTrainingStatus(today); // productive, maintaining, peaking...
const predictions = await garmin.getRacePredictions(); // 5K, 10K, half and full marathon
Two notes.
Readiness can have several entries per day. Garmin recomputes it after activities. getTrainingReadiness(date) returns all of them; getMorningTrainingReadiness(date) picks the one calculated when you woke up, which is usually the one you want.
These responses are loosely typed, on purpose. Their fields vary between watch models and firmware, so the library doesn't promise fields it hasn't verified. Explore yours first:
console.dir(readiness, { depth: 2 });
console.dir(predictions, { depth: 2 });
Then type the few fields you use, in your own code.
getRacePredictions has a second form for history: getRacePredictions(start, end, "daily" | "monthly") returns how your predictions changed over up to a year. It's all three arguments or none; passing one or two throws.
Putting it together: a weekly training report
Here's a script that summarises the last seven days. It uses only the fully typed data, so it works on any account:
// weekly-report.ts
import { GarminClient, Garmin, FileTokenStore } from "garminconnect-js";
const client = new GarminClient({ tokenStore: new FileTokenStore("./tokens") });
if (!(await client.loadTokens())) throw new Error("Run login.ts from Part 1 first");
const garmin = new Garmin(client);
const day = (offset: number) => new Date(Date.now() - offset * 86_400_000).toISOString().slice(0, 10);
const [start, end] = [day(6), day(0)];
const [activities, days] = await Promise.all([
garmin.getActivitiesByDate(start, end),
garmin.getDailySummaries(start, end),
]);
const km = activities.reduce((sum, a) => sum + (a.distance ?? 0), 0) / 1000;
const hours = activities.reduce((sum, a) => sum + (a.duration ?? 0), 0) / 3600;
const byType = new Map<string, number>();
for (const a of activities) {
const type = a.activityType?.typeKey ?? "other";
byType.set(type, (byType.get(type) ?? 0) + 1);
}
const average = (values: (number | null | undefined)[]) => {
const known = values.filter((v): v is number => typeof v === "number");
return known.length ? Math.round(known.reduce((a, b) => a + b, 0) / known.length) : null;
};
console.log(`Week ${start} to ${end}`);
console.log(` ${activities.length} activities, ${km.toFixed(1)} km, ${hours.toFixed(1)} h`);
console.log(` ${[...byType].map(([type, n]) => `${n}x ${type}`).join(", ") || "no activities"}`);
console.log(` Average steps: ${average(days.map((d) => d.movement?.steps?.value)) ?? "n/a"}`);
console.log(` Average resting HR: ${average(days.map((d) => d.heartRate?.restingValue)) ?? "n/a"}`);
console.log(` Average stress: ${average(days.map((d) => d.stress?.avgLevel)) ?? "n/a"}`);
console.log(` Days with data: ${days.length} of 7`);
The output looks like this (yours will differ):
Week 2026-10-18 to 2026-10-24
5 activities, 41.3 km, 5.2 h
3x running, 1x cycling, 1x strength_training
Average steps: 11240
Average resting HR: 49
Average stress: 27
Days with data: 7 of 7
Run it from a weekly cron job and post the result wherever you like: a Slack webhook, an email, a file in your notes.
A word on rate limits
Everything above is an ordinary HTTPS call to Garmin, and Garmin doesn't publish its limits. In practice:
- Reading is generous for one person's data. A report like this, or a dashboard refresh, is fine.
- Don't poll. Garmin data arrives when the watch syncs, typically a few times a day, so asking every minute only spends your goodwill. Cache results, and refresh on a schedule or when the user asks.
-
Back off on 429. A
GarminRateLimitErrorcarriesretryAfterin seconds when Garmin sends one. The library already retries failed reads a few times with backoff, but it won't wait out a rate limit for you.
What's next
We've read a lot. In Part 4, the last of this series, we write: building workouts with the type-checked builder, scheduling them, sending them to a watch, and why a write only counts as working once you've read it back. Plus how to test code like this without touching Garmin at all.
- Every method, with what was verified: garmin.ficdev.xyz/docs/reference
- Source (MIT): github.com/DynamicsNinja/garminconnect-js
Found a response shape that differs on your watch? Tell me in the comments, ideally with which model you use.
Top comments (0)