| sign_in_to_garminA | Sign in to Garmin Connect. Call this when another Garmin tool says you are not logged in or the session expired. It opens a sign-in page in the user's browser; the password and MFA code are entered there, never in the chat. Afterwards, ask the user to tell you when they have finished, then retry. |
| preview_workoutA | Check a workout and show it in readable form WITHOUT saving anything. Always call this first, show the user the result, and wait for them to confirm before create_workout. Paces are minutes as decimals (4:30 = 4.5), distances metres, times seconds. |
| create_workoutA | Save a workout to the user's Garmin Connect workout library. Only after the user has confirmed a preview_workout of the same spec. Look exercise names up with search_exercises first; an unknown name is rejected. Afterwards, schedule_workout (if available) puts it on the calendar and push_workout_to_device (if available) sends it to the watch. Paces are minutes as decimals (4:30 = 4.5), distances metres, times seconds. |
| update_workoutA | Replace an existing workout with a new spec. This OVERWRITES the whole workout; confirm with the user first, ideally after preview_workout. Paces are minutes as decimals (4:30 = 4.5), distances metres, times seconds. |
| search_exercisesA | Find Garmin exercise names for strength, HIIT, cardio, yoga, pilates and mobility steps. Returns CATEGORY / NAME lines; use them as exercise: { category, name }. |
| get_user_profileC | Cached per instance; every date-scoped endpoint needs the display name. |
| get_user_settingsC | Cached per instance, same eviction-on-failure pattern as getUserProfile(). |
| display_nameA | The account's display name, which date-scoped endpoints put in their URLs. Cached. |
| full_nameA | The account holder's full name, from the cached user profile. |
| user_nameB | The account's user name, from the cached user profile. |
| unit_systemA | measurementSystem lives on /userprofile-service/userprofile/user-settings (under userData), not on the social profile. A convenience accessor, not a data endpoint: returns undefined rather than throwing if the payload shape is missing.
|
| get_userprofile_settingsB | GETs /userprofile-service/userprofile/settings (SINGULAR "settings", distinct from getUserSettings's "user-settings"; the two paths are one character apart and easy to transpose) Three profile endpoints, three methods: getUserProfile() reads /userprofile-service/socialProfile (and backs displayName/fullName/userName), getUserSettings() reads /userprofile-service/userprofile/user-settings (and backs unitSystem), and this one reads .../settings |
| has_connect_plusA | whether the account has a Garmin Connect+ subscription, read from the cached user profile (/userprofile-service/socialProfile, so usually no extra request): true when hasPremiumSocialIcon is true or userRoles holds any ROLE_SP_FEATURE_n entry. The methods in CONNECT_PLUS_METHODS (searchFoods, getCustomFoods, getCustomFoodServingUnits, createCustomFood, updateCustomFood, deleteCustomFood, logFood, quickAddFood, deleteFoodLogs) need it; without it they throw GarminConnectPlusRequiredError instead of the bare 403 Garmin sends |
| get_user_summaryB | The daily summary for one date: steps, distance, calories and the rest of the day's totals. |
| get_statsC | |
| get_steps_dataA | Step counts for one date, as a series of time intervals; [] if there are none. |
| get_heart_ratesB | Heart-rate samples plus resting, min and max heart rate for one date. |
| get_sleep_dataB | Sleep data for one date: the night's summary and its sleep levels. |
| get_hrv_dataB | Heart-rate variability (HRV) readings and status for one date. |
| get_body_batteryA | Daily Body Battery reports for a date or a date range (enddate defaults to startdate). |
| get_body_battery_eventsC | Body Battery events for one date. |
| get_floorsD | throws GarminError if Garmin returns nothing |
| get_daily_stepsB | auto-chunks ranges over Garmin's 28-day-per-request limit into ≤28-day windows and concatenates; a single request within the limit passes its (possibly null) result through unchecked |
| get_daily_statsA | GETs /usersummary-service/stats/daily/{start}/{end}?statsType=CALORIES|STEPS; statsType is exactly "CALORIES" or "STEPS" (any other value, lower-case included, is a Garmin 404). Garmin 400s when end - start > 27, so longer ranges are fetched in 28-day windows and the rows concatenated; Garmin's per-request aggregations (averages) are DROPPED rather than returned wrong for a chunked range. A range with no data returns [] (Garmin answers null). Overlaps getDailySteps/getCaloriesDaily; this one returns total/active/resting calories together, and steps with totalDistance and… |
| get_weekly_stepsC | weeks defaults to 52, must be a positive integer
|
| get_weekly_stressD | same weeks default/validation as getWeeklySteps |
| get_weekly_intensity_minutesB | Weekly intensity-minute totals between two dates. |
| get_stats_and_bodyC | merges getUserSummary with the body-composition totalAverage block, delegating to getBodyComposition (bodyComposition service) for the latter |
| set_blood_pressureC | validates systolic 70-260, diastolic 40-150, pulse (if given) 20-250, all integers; when defaults to new Date() |
| get_blood_pressureD | enddate defaults to startdate
|
| delete_blood_pressureA | deletes one reading by its version, taken from getBloodPressure IRREVERSIBLE: confirm with the user before calling. |
| add_hydration_dataA | raw milliliters, magnitude capped at 10000, negative values allowed; no delete endpoint exists, so not safely round-trippable |
| get_hydration_dataC | The hydration log for one date. |
| get_respiration_dataC | Breathing-rate data for one date. |
| get_spo2_dataD | coerces a string lastSevenDaysAvgSpO2 to a number |
| get_intensity_minutes_dataC | Moderate and vigorous intensity minutes for one date. |
| get_all_day_stressB | All-day stress levels for one date. |
| get_stress_dataC | identical URL to getAllDayStress; an alias |
| get_all_day_eventsB | Daily events for one date (Garmin's dailyEvents feed). |
| get_sleep_dailyC | Garmin's endpoint has a documented 28-day-per-request limit; ranges beyond that are auto-chunked, de-duplicated by calendarDate, and sorted |
| get_rhr_dayC | Resting heart rate for one date. |
| get_rhr_dailyC | reshapes allMetrics.metricsMap into [{calendarDate, value}], dropping null values |
| get_calories_dailyA | merges active (metricId 22) and resting/BMR (metricId 23) series into [{calendarDate, active, resting, total}] |
| get_hrv_data_rangeB | Daily HRV summaries over a date range. |
| count_activitiesA | returns the envelope's totalCount, not the whole response; throws GarminError if Garmin returns nothing or a non-numeric totalCount |
| get_activitiesC | defaults start=0, limit=20 |
| get_activities_for_dateD | despite the name, the path is /mobile-gateway/heartRate/..., not an activities-service path |
| get_activities_by_dateC | paginates internally: fetches fixed pages of 20, incrementing start by 20, until an empty page (normal end) or 2000 pages without one (throws GarminError) |
| get_last_activityB | delegates to getActivities(0, 1), returns the last element or null |
| get_activityA | One activity's summary by id. Throws GarminError if Garmin returns nothing. |
| get_activity_typesB | ActivityTypesResponse is ActivityType[] (154 entries observed live: {typeId, typeKey, parentTypeId, isHidden, restricted, trimmable}), an ARRAY, verified live
|
| delete_activityB | resolves to null on success (204). Live-verified on synthetic fixtures created by the probe itself (never against pre-existing data): create → delete → poll-confirm gone via getActivities IRREVERSIBLE: confirm with the user before calling. |
| set_activity_nameC | resolves to null on success. Live-verified: value read back via getActivity after the call, not just that the request was accepted |
| set_activity_typeD | resolves to null on success. Live-verified: activityTypeDTO read back via getActivity after the call |
| set_activity_descriptionC | resolves to null on success. Live-verified: description read back via getActivity after the call |
| get_activity_event_typesA | GETs /activity-service/activity/eventTypes: nine {typeId, typeKey, sortOrder} entries (race, recreation, specialEvent, training, transportation, touring, geocaching, fitness, uncategorized) |
| set_activity_event_typeB | Partial PUT /activity-service/activity/{id} with {activityId, eventTypeDTO: {typeKey}}. The typeKey alone is enough; Garmin fills in typeId/sortOrder. Throws GarminError before any request for a key outside the nine |
| set_activity_perceived_effortA | Partial PUT of summaryDTO.directWorkoutRpe, which Garmin stores TIMES TEN: pass RPE 1-10 (integer), it sends 10-100; null clears it. Garmin itself rejects 150 with a 400 MEASUREMENT_NOT_VALID. Never read-modify-write the whole summaryDTO instead: PUTting its stored start-coordinate pair back is a 400 |
| set_activity_feelA | Partial PUT of summaryDTO.directWorkoutFeel, one of 0/25/50/75/100 ("How did you feel?", very weak to very strong); null clears it. Garmin stores any number (30 read back as 30) that its apps cannot display, so the method refuses anything else |
| create_manual_activity_from_jsonB | sends payload to Garmin verbatim, no shape validation |
| create_manual_activityC | converts units: distanceKm * 1000 → meters, durationMin * 60 → seconds, before building the request body; startDatetime is NOT routed through formatDate (it's a full local timestamp, not a bare calendar date) and must include milliseconds, e.g. "2026-09-22T10:00:00.000"; omitting them produced a live HTTP 500 ValueInstantiationException from Garmin during verification. Live-verified: the converted summaryDTO.distance/summaryDTO.duration were read back via getActivity and matched the expected meters/seconds exactly (5.5km/30min → 5500m/1800s) |
| import_activityB | multipart upload to /upload-service/upload/{ext} (extension from filename, must be fit/gpx/tcx) with the load-bearing NK/origin/custom User-Agent headers that make Garmin treat it as an import rather than a device sync; a 409 is re-raised as GarminConnectionError ("Activity already exists (duplicate):...") |
| upload_activityA | multipart upload to the PLAIN /upload-service/upload path, no extension suffix and none of importActivity's load-bearing import headers (ordinary device-sync-shaped upload, distinct from importActivity's spoofed-client import) |
| get_personal_recordB | GETs /personalrecord-service/personalrecord/prs/{displayName}; no args PersonalRecords is PersonalRecord[], an ARRAY, verified live (the test account returned array[0]). Note the plural type name: the method name is singular but the payload is a list |
| download_activityD | ActivityDownloadFormat is "ORIGINAL" | "TCX" | "GPX" | "KML" | "CSV", default "ORIGINAL"
|
| get_activity_splitsC | |
| get_activity_typed_splitsB | richer detail than getActivitySplits for some activity types (e.g. Bouldering) |
| get_activity_split_summariesC | Summaries of an activity's splits. |
| get_activity_weatherC | The weather recorded for an activity. |
| get_activity_hr_in_timezonesB | Time spent in each heart-rate zone during an activity. |
| get_activity_power_in_timezonesB | Time spent in each power zone during an activity. |
| get_activity_detailsD | defaults maxchart=2000, maxpoly=4000, sent as maxChartSize/maxPolylineSize |
| get_activity_exercise_setsC | A strength activity's exercise sets. |
| set_activity_exercise_setsB | replace-all semantics, payload sent verbatim. IRREVERSIBLE: confirm with the user before calling. |
| get_activity_gearD | returns an ARRAY, verified live |
| get_gear_activitiesC | limit clamped to 1000; returns [] on a 404 instead of throwing
|
| add_gear_to_activityC | on 404 re-raises as GarminConnectionError ("gear not found (likely retired/removed)") |
| remove_gear_from_activityC | PUT, not DELETE; same 404-handling pattern as addGearToActivity |
| get_progress_summary_between_datesD | defaults metric="distance", groupbyactivities=true; both dates routed through formatDate |
| download_health_snapshotD | routed through formatDate, routed through client.download |
| get_gearB | hits /gear-service/gear/filterGear?userProfilePk=...; returns an ARRAY of gear entries, verified live. This is the dedicated gear-CRUD service (src/services/gear.ts), distinct from getActivityGear (activities service, reuses the same base URL with activityId instead) |
| create_gearA | POSTs /gear-service/gear/v2; defaults usageType="DISTANCE", notes=""; firstUseDate routed through formatDate; gearType/usageType upper-cased via validateSportKey; converts maxUsageDistanceKm -> maxUsageDistanceMeters (round(km*1000), floor 1) and maxUsageDurationMin -> maxUsageDurationSeconds (round(min*60), floor 1), the opposite direction from addWeighIn's "send raw" rule; returns the response unchecked |
| get_gear_statsA | GETs /gear-service/gear/stats/{gearUUID}; gearUUID validated via validateUuid (hex, hyphens optional); returns {} on a 404 instead of throwing; other errors re-raised |
| delete_gearA | DELETE /gear-service/gear/v2/{gearUUID}, resolves null on success (204). The UUID must be HYPHENATED: getGear returns them WITHOUT hyphens and that form 404s, so this method re-inserts them for you when handed the bare 32-char form; only hand-rolled URLs hit the 404. IRREVERSIBLE: removes the gear AND its activity history IRREVERSIBLE: confirm with the user before calling.
|
| set_gear_activity_defaultsA | The working way to set gear defaults (the old dedicated default-gear endpoint is dead; there is no setGearDefault). Read-modify-writes the v2 record: GETs /gear-service/gear/v2/{uuid}, replaces associatedActivityTypes with [{activityTypeKey, defaultGear: true, preferredGear: false}] per key, PUTs the whole record back. Keys are lowercase ("running"), matching createGear, NOT setGearDefault's upper-cased form. [] clears all defaults. Concurrent callers can clobber each other IRREVERSIBLE: confirm with the user before calling. |
| get_gear_defaultsC | GETs /gear-service/gear/user/{userProfileNumber}/activityTypes; returns an ARRAY of {uuid, activityTypePk, defaultGear} entries, verified live |
| list_coursesA | GETs /web-gateway/course/owner/ (trailing slash included); returns an ENVELOPE { coursesForUser: CourseSummary[] }, not a bare array |
| get_courseB | GETs /course-service/course/{courseId}; a missing id is a 404 GarminHttpError ("Course not found : {id}") |
| import_course_gpxA | multipart POST (field file) to /course-service/course/import. Parses only, saves NOTHING: the result has courseId: null and a RESAMPLED track (13 GPX points came back as 37) with null elevations/timestamps; courseName is the GPX <name>. Only .gpx accepted (only .gpx verified); throws GarminError on an empty response |
| create_courseA | POSTs /course-service/course with the template Garmin's web client sends. CourseInput = { name, geoPoints, coursePoints?, activityTypeId? = 1 (running), privacy? = "private" }; privacy maps to rulePK public 1 / private 2. Garmin computes distanceMeter and elevation. Throws GarminError before any request on an empty name or fewer than two geoPoints |
| create_course_from_gpxB | importCourseGpx then createCourse. Name defaults to the GPX <name>, then the filename without .gpx
|
| update_courseA | READ-MODIFY-WRITE: GETs the record, overlays the changes, PUTs the whole record back to /course-service/course/{courseId}, the same request Garmin's own web editor sends on Save (observed 2026-09-24). Concurrent callers can clobber each other. Throws before any request if neither field is given or the name is blank. A just-created course may 429 "not yet ready" |
| delete_courseA | DELETE /course-service/course/{courseId}, resolves null (204). IRREVERSIBLE. May 429 "not yet ready" for a few seconds after creation IRREVERSIBLE: confirm with the user before calling.
|
| download_course_gpxC | GETs /course-service/course/gpx/{courseId} through client.download |
| get_devicesC | undocumented per-device shape, deviceId is the field the other device methods key off of |
| get_device_settingsC | deviceId coerced to an int, validated positive, re-stringified before being placed in the path Two-call sequence: get a deviceId from a getDevices() entry first, then pass it here
|
| get_primary_training_deviceC | The account's primary training device. |
| get_device_solar_dataB | the only raising method in this group: throws GarminConnectionError if the response is falsy or missing the deviceSolarInput key; returns resp.deviceSolarInput, NOT the envelope. enddate defaults to startdate, and singleDayView is sent "true" exactly when enddate was omitted |
| get_device_alarmsB | no HTTP path of its own: calls getDevices() once, then getDeviceSettings(device.deviceId) once per device (N+1 fan-out, sequential by design; do not parallelize), concatenating each device's alarms; a device with no alarms contributes nothing, never throws for that case |
| get_device_last_usedC | also used internally by pushWorkoutToDevice to resolve a missing deviceId |
| get_earned_badgesA | GETs /badge-service/badge/earned; each badge gains badgeImageUrls: { small, large }, added by this library (Garmin sends no image URL): https://connect.garmin.com/images/badges/xxhdpi/badge_<badgeUuid ?? badgeId>_sml.png (_lrg.png for large), the URL Garmin Connect's web app builds; public, no session. Every other field passes through unchanged; stays nullable (does NOT coalesce to []) |
| get_available_badgesB | GETs /badge-service/badge/available?showExclusiveBadge=true; same badgeImageUrls as getEarnedBadges; stays nullable |
| get_in_progress_badgesB | no HTTP path of its own: calls getEarnedBadges() and getAvailableBadges(), filters each with an in-progress predicate (progress truthy; if progress === target, only "in progress" when badgeLimitCount is set and badgeEarnedNumber < badgeLimitCount), then merges both filtered lists into a Map keyed by badgeId (available overwrites earned on collision, keeping the earned entry's position); never raises: a null from either call is treated as []. Badges carry badgeImageUrls (inherited from the two calls) |
| get_badge_detailA | GETs /badge-service/badge/detail/v3/{badgeId}, the request Garmin Connect's web app makes when a badge is opened; badgeId validated as a positive integer. Returns an OBJECT: the getEarnedBadges fields plus relatedBadges (the rest of the series, each with earnedByMe), badgeAssocType/badgeAssocDataId/badgeAssocDataName (for "activityId", the activity that earned it) and followings. Works for unearned badges. An unknown id is a 400 GarminHttpError, not a 404. The badge and each relatedBadges entry gain badgeImageUrls (see getEarnedBadges). Carries no description t… |
| get_adhoc_challengesB | GETs /adhocchallenge-service/adHocChallenge/historical; start validated non-negative, limit validated positive (throws GarminError otherwise); returns an ARRAY, verified live |
| get_badge_challengesA | GETs /badgechallenge-service/badgeChallenge/completed; same start/limit validation as getAdhocChallenges; returns an ARRAY, verified live. Live discovery: Garmin's server rejects start=0 with a 400 ("start should > 0.") on this endpoint even though the client-side check allows it (non-negative); the 400 is Garmin's server, not a wrong URL; call with start>=1 in practice |
| get_available_badge_challengesB | GETs /badgechallenge-service/badgeChallenge/available; same start/limit validation; same array-not-dict correction and same live start=0 -> 400 discovery as getBadgeChallenges |
| get_non_completed_badge_challengesC | GETs /badgechallenge-service/badgeChallenge/non-completed; same start/limit validation; same array-not-dict correction and same live start=0 -> 400 discovery as getBadgeChallenges |
| get_inprogress_virtual_challengesC | GETs /badgechallenge-service/virtualChallenge/inProgress; asymmetric validation: start validated POSITIVE here (rejects start=0), unlike the non-negative start on the four challenge methods above; limit validated positive; same array-not-dict correction |
| get_weigh_insC | Weigh-ins between two dates. |
| add_weigh_inC | defaults unitKey="kg", when=new Date() |
| delete_weigh_inA | Deletes one weigh-in, by date and its weightPk. IRREVERSIBLE: confirm with the user before calling. |
| add_weigh_in_with_timestampsB | same raw-value, no-conversion rule as addWeighIn; the two timestamps are COUPLED: the local instant resolves from dateTimestamp if given (naive strings read as LOCAL time) else from when (defaults new Date()), and gmtTimestamp, when omitted, is derived from THAT resolved instant, never independently from when. Both supplied strings are re-formatted rather than forwarded verbatim (a naive gmtTimestamp is read as UTC) |
| get_daily_weigh_insC | GETs /weight-service/weight/dayview/{cdate}?includeAll=true |
| delete_weigh_insA | no HTTP path of its own: calls getDailyWeighIns, then loops deleteWeighIn per entry; returns null (deletes nothing) if there are zero entries, or more than one entry and deleteAll is not true; otherwise deletes every entry that day and returns the count. IRREVERSIBLE: deletes ALL weigh-ins recorded on cdate when it proceeds IRREVERSIBLE: confirm with the user before calling. |
| get_body_compositionB | GETs /weight-service/weight/dateRange; enddate defaults to startdate; throws GarminError if startdate > enddate getStatsAndBody (wellness service) now delegates to this instead of inlining its own copy of the same call |
| add_body_compositionC | builds a .fit binary in memory (src/util/fit.ts, a hand-rolled FIT encoder) and uploads it via client.upload to /upload-service/upload; weight validated positive/finite, throws GarminError otherwise; passes client.upload's result through unchecked |
| get_max_metricsD | the date is repeated twice in the path (start=end=cdate) |
| get_max_metrics_rangeD | throws GarminError if start > end |
| get_functional_threshold_power_rangeC | defaults sport="RUNNING", aggregation="daily"; sport upper-cased and validated (^[A-Z_]+$); aggregation restricted to {daily,weekly,monthly,yearly} |
| get_lactate_thresholdC | defaults latest=true, aggregation="daily". TWO DIFFERENT branches: 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 |
| get_training_readinessB | Training readiness entries for one date. |
| get_morning_training_readinessB | 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 |
| get_endurance_scoreB | TWO branches by presence of enddate: no enddate hits the single-day endpoint; with enddate hits .../stats with hard-coded aggregation="weekly" |
| get_running_toleranceC | defaults aggregation="weekly"; restricted to {daily,weekly} (narrower than the FTP/lactate methods) |
| get_race_predictionsA | TWO branches, all-or-nothing params (throws on a partial combination): no params hits .../latest/{displayName}; all three hit .../{type}/{displayName}, capped at a 366-day span |
| get_training_statusC | Training status for one date. |
| get_fitness_age_dataC | Fitness age for one date. |
| get_hill_scoreC | 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) |
| get_cycling_ftpA | latest value only; use getFunctionalThresholdPowerRange for history |
| get_heart_rate_zonesB | Heart-rate zone profiles: DEFAULT plus any per-sport ones. |
| set_heart_rate_zonesA | 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 ascendi… |
| delete_heart_rate_zonesA | 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 IRREVERSIBLE: confirm with the user before calling. |
| get_power_zonesC | |
| get_power_zones_for_sportC | sport upper-cased and validated the same way as getFunctionalThresholdPowerRange
|
| get_workoutsC | defaults start=0, limit=100, stays nullable (does NOT coalesce to []) |
| get_workout_by_idC | One workout from the library by id. |
| delete_workoutA | deletes the template from the workout library, irreversible IRREVERSIBLE: confirm with the user before calling. |
| download_workoutC | |
| push_workout_to_deviceB | multi-call: resolves a missing deviceId via /device-service/deviceservice/mylastused's userDeviceId, a missing workoutId via getWorkouts(0,1)'s first result (throws if none), then reads getWorkoutById(workoutId).workoutName for the push message on the final POST |
| get_scheduled_workoutsD | month is 1-12 on the way in, converted to 0-indexed on the wire; validates year>=2000, month 1-12
|
| get_scheduled_workout_by_idC | uses a DIFFERENT base (/workout-service/schedule) than getScheduledWorkouts (/calendar-service) |
| get_next_scheduled_workoutB | computed from two getScheduledWorkouts calls (this month + next, handling Dec->Jan rollover); returns {} if nothing matches, never throws |
| schedule_workoutD | dateStr routed through formatDate
|
| unschedule_workoutA | removes the calendar entry without deleting the workout template; irreversible IRREVERSIBLE: confirm with the user before calling. |
| get_adaptive_workoutA | GETs /workout-service/fbt-adaptive/{uuid}: a Garmin Coach workout with segments, steps and estimatedTrainingEffect. Coach workouts have no workoutId, and getWorkoutById cannot fetch them: /workout-service/workout/{uuid} is a 404. Take the uuid from getTrainingPlanWorkouts or getScheduledWorkoutSummaries |
| get_scheduled_workout_summariesA | POSTs the fixed GraphQL query workoutScheduleSummariesScalar(startDate, endDate): every scheduled workout in the range, plan or self-scheduled (tpType: null), as compact rows whose scheduledWorkoutId is what unscheduleWorkout takes, or null for a Garmin Coach workout, which also has tpType: null like a self-scheduled one (tell them apart by fbtAdaptivePlanId). Dates are validated before they are spliced into the query. A GraphQL error arrives as HTTP 200 with errors, turned into a GarminError. It LAGS writes: a just-scheduled workout is in the month feed at once but abse… |
| get_training_plan_workoutsB | POSTs GraphQL trainingPlanScalar(calendarDate, lang, firstDayOfWeek) (all three arguments are REQUIRED by Garmin) and unwraps trainingPlanWorkoutScheduleDTOS: one entry per enrolled plan, {trainingPlanId, planName, trainingPlanClassification, trainingPlanDetailsDTO, workoutScheduleSummaries}. Garmin picks the window (18 workouts across several weeks for an ITP plan); [] with no plan |
| list_calendar_eventsA | Events also appear in getScheduledWorkouts' month feed as itemType event; this GETs /calendar-service/events, with startDate/endDate when both dates are given (inclusive) or no params for every event on the account; one date alone throws |
| get_calendar_eventB | GETs /calendar-service/event/{id}; a missing id is a 404 GarminHttpError |
| get_shared_calendar_eventA | GETs /calendar-service/event/{uuid}/shareable: an event from Garmin's public events catalogue (Races & Events -> Find an Event), with locationStartPoint, subscribersCount, provider and subscribed. Works WITHOUT subscribing (subscribed: false). The plain /event/{uuid} is a 404. Events you create never get a shareable uuid (Garmin forces them private) |
| create_calendar_eventA | POSTs /calendar-service/event. CalendarEventInput = {name, date, startTime?: "HH:MM", timeZoneId?, eventType?, race?, primary?, distance?: {value, unit: meter|kilometer|yard|mile}, goalTimeSeconds?, location?, url?, note?}; only name/date are required by Garmin ('addEvent.arg3.eventName' must not be blank otherwise). Garmin forces every user-created event PRIVATE and unshareable whatever is sent, sets isTrainingEvent whenever isPrimaryEvent is, and answers an unknown eventType with a 500. It also rewrote Europe/Zagreb to Europe/Paris (same offset) while keeping `Amer… |
| update_calendar_eventA | READ-MODIFY-WRITE: GETs the event, overlays {name?, date?, location?, url?, note?, race?, goalTimeSeconds?} (null clears an optional one), PUTs the whole record back to /calendar-service/event/{id}; Garmin answers with no body, so it resolves to null. Concurrent callers can clobber each other |
| delete_calendar_eventA | DELETE /calendar-service/event/{id}, resolves null (204). IRREVERSIBLE IRREVERSIBLE: confirm with the user before calling.
|
| get_menstrual_data_for_dateB | The menstrual-cycle day view for one date. |
| get_menstrual_calendar_dataC | Garmin rejects windows of 92+ inclusive days; not enforced here (caller's responsibility) |
| get_menstrual_last_confirmedC | The last confirmed period as of a date. |
| get_menstrual_cycle_summaryC | A summary of the menstrual cycle containing a date. |
| get_menstrual_reportsC | defaults numberOfCycles=6, nextReport=false, reportType="CYCLE", todayCalendarDate defaults to today; numberOfCycles restricted to {1,6,12} or throws GarminError |
| get_pregnancy_summaryC | The pregnancy-tracking summary. |
| update_menstrual_daily_logA | full-day replace, not a merge; at least one optional field required or throws GarminError; notes: undefined preserves the existing note, notes: "" clears it (distinct, load-bearing); discharge rejects combining "NO_DISCHARGE" with any other value IRREVERSIBLE: confirm with the user before calling. |
| update_menstrual_calendarA | full replace, not a merge; each cycleDatesLists group must be non-empty, consecutive calendar dates, and fall within [startdate, enddate], else throws GarminError IRREVERSIBLE: confirm with the user before calling. |
| init_menstrual_cycle_setupC | does NOT also update tracking-preference settings (call updateMenstrualSettings separately for that); not intended for an already-configured account |
| confirm_menstrual_period_startC | POSTs /periodichealth-service/menstrualcycle/{periodStartDate} directly, NOT the dayview/calendar/lastconfirmed/summary sub-paths |
| update_menstrual_settingsB | settings must be non-empty or throws GarminError; multi-step: GETs /userprofile-service/userprofile/user-settings, overlays settings onto the current userMenstrualCycleSettings, then PUTs the same endpoint, including id if resolvable
|
| get_goalsA | defaults status="active", **start=1**, limit=30. start defaults to 1, NOT 0, because goal-service is 1-INDEXED and start=0 silently returns []; throws GarminError before any request for an invalid status. Paginated, multi-call: starting at start, fetches successive pages of limit entries (incrementing start by limit each call) until a page comes back empty/falsy, same fixed-page-size pattern as getActivitiesByDate; throws GarminError if MAX_PAGINATED_REQUESTS (2000) pages are fetched without ever seeing an empty one. **Sends the load-bearing `Sec-Fetch-Site: same… |
| get_golf_summaryC | defaults start=0, limit=100; start validated non-negative, limit validated positive (throws GarminError otherwise); query params are literally hyphenated (per-page, start), matching Garmin's own naming. An OBJECT, not an array, verified live: the test account (0 rounds recorded) returned a single pagination-envelope OBJECT {pageNumber, rowsPerPage, totalRows}, not an array |
| get_golf_scorecardC | GETs /gcs-golfcommunity/api/v2/scorecard/detail; hyphenated query params scorecard-ids and include-longest-shot-distance (sent as the literal string "true") |
| get_golf_shot_dataA | GETs /gcs-golfcommunity/api/v2/shot/scorecard/{scorecardId}/hole; holeNumbers accepts commas or hyphens as separators (spaces stripped), re-joined with - before sending as the hyphenated hole-numbers param; if any requested hole number is >9, the filter is silently dropped and all 18 holes are requested instead (Garmin's endpoint drops double-digit hole numbers from a filtered query); omitting holeNumbers also fetches all 18 |
| get_golf_club_statsB | defaults limit=1000; validated positive; GETs /gcs-golfcommunity/api/v2/club/player; hyphenated query params per-page and include-stats (literal "true"). An ARRAY, verified live: the test account returned a JSON ARRAY of 17 club entries ({id, clubTypeId, shaftLength, flexTypeId, averageDistance, adviceDistance, retired, deleted, lastModifiedTime}), not a single object |
| get_golf_user_statsB | GETs /gcs-golfcommunity/api/v2/player/stats; handicap and strokes-gained overview, no params |
| get_nutrition_daily_food_logC | GETs /nutrition-service/food/logs/{cdate} |
| get_nutrition_daily_mealsC | GETs /nutrition-service/meals/{cdate} |
| get_nutrition_daily_settingsC | GETs /nutrition-service/settings/{cdate} |
| get_nutrition_food_log_rangeA | GETs /nutrition-service/food/logs/range?startDate&endDate: {dailyNutritionSummaries}, one day-log per day that has anything logged. The ONLY food-logging call that works without Connect+ (returns no days then) |
| search_foodsA | Requires a Garmin Connect+ subscription (check with has_connect_plus). GETs /nutrition-service/food/search?searchExpression&start&limit (defaults 0/20): {results: Food[], moreDataAvailable}; catalogue foods carry source: "FATSECRET" and several servings each. Needs Garmin Connect+, which needs a paired Garmin device (a bare 403 ForbiddenException without it) |
| get_custom_foodsA | Requires a Garmin Connect+ subscription (check with has_connect_plus). GETs /nutrition-service/customFood with includeContent=true: {customFoods, moreDataAvailable}. limit is capped at 20 (Garmin 400s above it). There is NO get-by-id: GET /customFood/{id} is a 405, so search by name to read one back. Needs Connect+ |
| get_custom_food_serving_unitsB | Requires a Garmin Connect+ subscription (check with has_connect_plus). GETs /nutrition-service/metadata/customFoodServingUnits (13 units). Needs Connect+ |
| create_custom_foodA | Requires a Garmin Connect+ subscription (check with has_connect_plus). A PUT, not a POST, to /nutrition-service/customFood; CustomFoodInput = {name, calories, servingUnit? = "G", servingSize? = 100, brand?, carbs?, protein?, fat?, fiber?, sugar?, saturatedFat?, transFat?, sodium?, cholesterol?, potassium?, calcium?, iron?, vitaminD?} per ONE serving, absolute amounts (not %DV); numbers are sent as strings, as Garmin's own client does. Returns the stored food with the foodId/servingId logFood takes. Needs Connect+ |
| update_custom_foodC | Requires a Garmin Connect+ subscription (check with has_connect_plus). The same PUT carrying both ids. FULL REPLACE: a nutrient or brand left out is removed (verified: the brand was dropped). Needs Connect+ |
| delete_custom_foodA | Requires a Garmin Connect+ subscription (check with has_connect_plus). DELETE /nutrition-service/customFood/{foodId}, resolves null. IRREVERSIBLE. Needs Connect+ IRREVERSIBLE: confirm with the user before calling. |
| log_foodA | Requires a Garmin Connect+ subscription (check with has_connect_plus). PUTs /nutrition-service/food/logs with one REGULAR_LOG item and returns the whole day's log. FoodLogInput = {date, foodId, servingId, servings? = 1, time?, meal?, source? = "GARMIN", regionCode?, languageCode?}. Pass the catalogue food's source/regionCode/languageCode for a search result. Needs a mealId, which only exists after Garmin's nutrition setup in the app (400 mealId must not be null otherwise; this method throws a clearer error first). The meal is the named one, else the one whose window holds time, else SNACKS; with a meal and no time it picks a time that… |
| quick_add_foodA | Requires a Garmin Connect+ subscription (check with has_connect_plus). PUTs /nutrition-service/food/logs/quickAdd: an entry by name + calories/carbs/protein/fat with no food behind it (QUICK_ADD). Same meal rules as logFood. Needs Connect+ |
| delete_food_logsA | Requires a Garmin Connect+ subscription (check with has_connect_plus). DELETE /nutrition-service/food/logs/{date} with {logIds} as the body: any number in ONE call, regular and quick-add alike. logIds are on the entries of getNutritionDailyFoodLog. IRREVERSIBLE. Needs Connect+ IRREVERSIBLE: confirm with the user before calling. |
| get_training_plansC | GETs /trainingplan-service/trainingplan/plans; no params |
| get_training_plan_by_idC | GETs /trainingplan-service/trainingplan/phased/{planId} |
| get_adaptive_training_plan_by_idC | GETs /trainingplan-service/trainingplan/fbt-adaptive/{planId}, a distinct sub-path from getTrainingPlanById's phased path |
| delete_training_planA | DELETE /trainingplan-service/trainingplan/trainingplan/{planId} (the trainingplan segment really is doubled): the request Garmin Connect's "Quit Plan" sends, observed in the web client on 2026-10-06. Resolves null (204); the plan's scheduled workouts leave the calendar, completed activities stay. A second call is a 404 "Training plan not found with ID: …". IRREVERSIBLE IRREVERSIBLE: confirm with the user before calling.
|
| get_lifestyle_logging_dataC | GETs /lifestylelogging-service/dailyLog/{cdate} Grouped under misc per the plan's explicit instruction, even though it superficially resembles a wellness-daily endpoint |
| request_reloadA | POSTs /wellness-service/wellness/epoch/request/{cdate} with no JSON body; asks Garmin to reload/recompute a day's data (Garmin offloads older data, so this forces it back) |