Training metrics
Training status and readiness, race predictions, FTP, lactate threshold, heart-rate and power zones, endurance and hill scores, fitness age.
Every method below hangs off a Garmin instance. See Installation & setup for how to construct one:
import { GarminClient, Garmin, FileTokenStore } from "garminconnect-js";
const client = new GarminClient({ tokenStore: new FileTokenStore("./tokens") });
if (!(await client.loadTokens())) throw new Error("not connected to Garmin");
const garmin = new Garmin(client);
18 methods. The verification column says what has been
confirmed against a live Garmin account, not merely unit-tested —
AGENTS.md carries the full evidence per method.
| Method | Verified |
|---|---|
deleteHeartRateZones | live-verified |
getCyclingFtp | live-verified |
getEnduranceScore | live-verified |
getFitnessAgeData | live-verified |
getFunctionalThresholdPowerRange | live-verified |
getHeartRateZones | live-verified |
getHillScore | live-verified |
getLactateThreshold | live-verified |
getMaxMetrics | live-verified |
getMaxMetricsRange | live-verified |
getMorningTrainingReadiness | live-verified |
getPowerZones | live-verified |
getPowerZonesForSport | live-verified |
getRacePredictions | live-verified |
getRunningTolerance | live-verified |
getTrainingReadiness | live-verified |
getTrainingStatus | live-verified |
setHeartRateZones | live-verified |
deleteHeartRateZones
garmin.deleteHeartRateZones(sport: string): Promise<unknown>
const result = await garmin.deleteHeartRateZones("sport");
Returns
unknown — Garmin's response is passed through unparsed. Cast it to whatever you need; this library does not model it.
PUTs the stored profile back with changeState: "DELETED", which removes it so the sport falls back to DEFAULT. Refuses DEFAULT; throws for a sport with no profile instead of doing nothing
Verification: live-verified
getCyclingFtp
garmin.getCyclingFtp(): Promise<CyclingFtpResult | null>
const result = await garmin.getCyclingFtp();
Returns
CyclingFtpResult = Record<string, unknown> | Record<string, unknown>[]
latest value only; use getFunctionalThresholdPowerRange for history
Verification: live-verified
getEnduranceScore
garmin.getEnduranceScore(startdate: string | Date, enddate?: string | Date): Promise<EnduranceScoreResult | null>
const result = await garmin.getEnduranceScore("2026-09-24");
Returns
EnduranceScoreResult — an object whose fields this library does not model. Garmin's response is passed through unparsed, so read one to see what you get, or use a Record<string, unknown> and narrow it yourself.
TWO branches by presence of enddate, see gotchas: no enddate hits the single-day endpoint; with enddate hits .../stats with hard-coded aggregation="weekly"
Verification: live-verified
getFitnessAgeData
garmin.getFitnessAgeData(cdate: string | Date): Promise<FitnessAgeResult | null>
const result = await garmin.getFitnessAgeData("2026-09-24");
Returns
FitnessAgeResult — an object whose fields this library does not model. Garmin's response is passed through unparsed, so read one to see what you get, or use a Record<string, unknown> and narrow it yourself.
passes through unchecked
Verification: live-verified
getFunctionalThresholdPowerRange
garmin.getFunctionalThresholdPowerRange(start: string | Date, end: string | Date, sport?: string, aggregation?: FtpAggregation): Promise<FtpRangeResult | null>
const result = await garmin.getFunctionalThresholdPowerRange("2026-09-24", "2026-09-24");
Returns
FtpRangeResult = Record<string, unknown> | Record<string, unknown>[]
defaults sport="RUNNING", aggregation="daily"; sport upper-cased and validated (^[A-Z_]+$); aggregation restricted to {daily,weekly,monthly,yearly}; passes through unchecked
Verification: live-verified
getHeartRateZones
garmin.getHeartRateZones(): Promise<HeartRateZoneEntry[] | null>
const result = await garmin.getHeartRateZones();
Returns
An array of HeartRateZoneEntry:
| Field | Type | Always present |
|---|---|---|
sport | string | no |
trainingMethod | HeartRateZoneMethod | no |
zone1Floor | number | no |
zone2Floor | number | no |
zone3Floor | number | no |
zone4Floor | number | no |
zone5Floor | number | no |
maxHeartRateUsed | `number | null` |
restingHeartRateUsed | `number | null` |
lactateThresholdHeartRateUsed | `number | null` |
restingHrAutoUpdateUsed | boolean | no |
changeState | string | no |
Plus every other field Garmin sends: this type carries an index signature because the real response is wider than the fields above, which are the ones this library relies on or has observed. Read an actual response before depending on a field that is not listed.
passes through unchecked
Verification: live-verified
getHillScore
garmin.getHillScore(startdate: string | Date, enddate?: string | Date): Promise<HillScoreResult | null>
const result = await garmin.getHillScore("2026-09-24");
Returns
HillScoreResult — an object whose fields this library does not model. Garmin's response is passed through unparsed, so read one to see what you get, or use a Record<string, unknown> and narrow it yourself.
TWO branches by presence of enddate, same shape as getEnduranceScore but the range branch hard-codes aggregation="daily" (NOT "weekly" — do not conflate the two), see gotchas
Verification: live-verified
getLactateThreshold
garmin.getLactateThreshold(latest?: boolean, startDate?: string | Date, endDate?: string | Date, aggregation?: FtpAggregation): Promise<LactateThresholdLatest | LactateThresholdRange>
const result = await garmin.getLactateThreshold();
Returns
LactateThresholdLatest | LactateThresholdRange
defaults latest=true, aggregation="daily". TWO DIFFERENT branches, see gotchas: latest=true returns {speed_and_heart_rate, power} from two GETs; latest=false (requires startDate, throws otherwise) returns {speed, heart_rate, power} from three GETs
Verification: live-verified
getMaxMetrics
garmin.getMaxMetrics(cdate: string | Date): Promise<MaxMetricsResult | null>
const result = await garmin.getMaxMetrics("2026-09-24");
Returns
MaxMetricsResult — an object whose fields this library does not model. Garmin's response is passed through unparsed, so read one to see what you get, or use a Record<string, unknown> and narrow it yourself.
the date is repeated twice in the path (start=end=cdate); passes through unchecked
Verification: live-verified
getMaxMetricsRange
garmin.getMaxMetricsRange(start: string | Date, end: string | Date): Promise<MaxMetricsResult | null>
const result = await garmin.getMaxMetricsRange("2026-09-24", "2026-09-24");
Returns
MaxMetricsResult — an object whose fields this library does not model. Garmin's response is passed through unparsed, so read one to see what you get, or use a Record<string, unknown> and narrow it yourself.
throws GarminError if start > end; passes through unchecked
Verification: live-verified
getMorningTrainingReadiness
garmin.getMorningTrainingReadiness(cdate: string | Date): Promise<TrainingReadinessEntry | null>
const result = await garmin.getMorningTrainingReadiness("2026-09-24");
Returns
TrainingReadinessEntry:
| Field | Type | Always present |
|---|---|---|
inputContext | string | no |
Plus every other field Garmin sends: this type carries an index signature because the real response is wider than the fields above, which are the ones this library relies on or has observed. Read an actual response before depending on a field that is not listed.
delegates to getTrainingReadiness, no HTTP call of its own; filters for inputContext === "AFTER_WAKEUP_RESET", falls back to the first entry; null for a falsy or empty result
Verification: live-verified
getPowerZones
garmin.getPowerZones(): Promise<PowerZoneEntry[] | null>
const result = await garmin.getPowerZones();
Returns
An array of PowerZoneEntry — an object whose fields this library does not model. Garmin's response is passed through unparsed, so read one to see what you get, or use a Record<string, unknown> and narrow it yourself.
passes through unchecked
Verification: live-verified
getPowerZonesForSport
garmin.getPowerZonesForSport(sport: string): Promise<PowerZonesForSportResult | null>
const result = await garmin.getPowerZonesForSport("sport");
Returns
PowerZonesForSportResult — an object whose fields this library does not model. Garmin's response is passed through unparsed, so read one to see what you get, or use a Record<string, unknown> and narrow it yourself.
sport upper-cased and validated the same way as getFunctionalThresholdPowerRange
Verification: live-verified
getRacePredictions
garmin.getRacePredictions(startdate?: string | Date, enddate?: string | Date, type?: "daily" | "monthly"): Promise<RacePredictionsResult | null>
const result = await garmin.getRacePredictions();
Returns
RacePredictionsResult — an object whose fields this library does not model. Garmin's response is passed through unparsed, so read one to see what you get, or use a Record<string, unknown> and narrow it yourself.
TWO branches, all-or-nothing params (throws on a partial combination), see gotchas: no params hits .../latest/{displayName}; all three hit .../{type}/{displayName}, capped at a 366-day span
Verification: live-verified
getRunningTolerance
garmin.getRunningTolerance(startdate: string | Date, enddate: string | Date, aggregation?: RunningToleranceAggregation): Promise<RunningToleranceEntry[] | null>
const result = await garmin.getRunningTolerance("2026-09-24", "2026-09-24");
Returns
An array of RunningToleranceEntry — an object whose fields this library does not model. Garmin's response is passed through unparsed, so read one to see what you get, or use a Record<string, unknown> and narrow it yourself.
defaults aggregation="weekly"; restricted to {daily,weekly} (narrower than the FTP/lactate methods); passes through unchecked
Verification: live-verified
getTrainingReadiness
garmin.getTrainingReadiness(cdate: string | Date): Promise<TrainingReadinessEntry[] | null>
const result = await garmin.getTrainingReadiness("2026-09-24");
Returns
An array of TrainingReadinessEntry:
| Field | Type | Always present |
|---|---|---|
inputContext | string | no |
Plus every other field Garmin sends: this type carries an index signature because the real response is wider than the fields above, which are the ones this library relies on or has observed. Read an actual response before depending on a field that is not listed.
passes through unchecked
Verification: live-verified
getTrainingStatus
garmin.getTrainingStatus(cdate: string | Date): Promise<TrainingStatusResult | null>
const result = await garmin.getTrainingStatus("2026-09-24");
Returns
TrainingStatusResult — an object whose fields this library does not model. Garmin's response is passed through unparsed, so read one to see what you get, or use a Record<string, unknown> and narrow it yourself.
passes through unchecked
Verification: live-verified
setHeartRateZones
garmin.setHeartRateZones(update: HeartRateZoneUpdate): Promise<HeartRateZoneEntry | null>
const result = await garmin.setHeartRateZones(update);
Returns
HeartRateZoneEntry:
| Field | Type | Always present |
|---|---|---|
sport | string | no |
trainingMethod | HeartRateZoneMethod | no |
zone1Floor | number | no |
zone2Floor | number | no |
zone3Floor | number | no |
zone4Floor | number | no |
zone5Floor | number | no |
maxHeartRateUsed | `number | null` |
restingHeartRateUsed | `number | null` |
lactateThresholdHeartRateUsed | `number | null` |
restingHrAutoUpdateUsed | boolean | no |
changeState | string | no |
Plus every other field Garmin sends: this type carries an index signature because the real response is wider than the fields above, which are the ones this library relies on or has observed. Read an actual response before depending on a field that is not listed.
READ-MODIFY-WRITE of one profile: GETs /biometric-service/heartRateZones, overlays {sport? = "DEFAULT", trainingMethod?, maxHeartRate?, restingHeartRate?, lactateThresholdHeartRate?, zoneFloors?: [5 bpm]} with changeState: "CHANGED", PUTs [profile] (204), then returns the profile READ BACK. A sport with no profile starts from DEFAULT's. Setting restingHeartRate also turns off restingHrAutoUpdateUsed. Garmin does NOT recompute floors when the method or a heart rate changes — send zoneFloors too. Floors must be strictly ascending (Garmin 400s "Zone Floor values must be ascending"; checked here first)
Verification: live-verified