For developers. Just want to ask Claude about your training, with no code? Start with Your Garmin data in Claude instead.
The first three parts of this series signed in, built a web app around it and read activities and training data. This last part is about writing: creating a workout, putting it on the calendar, and sending it to a watch.
Writing to Garmin has a property that shapes everything in this post: Garmin often accepts a mistake. It returns success and stores something different from what you meant. So alongside the how-to, there's one habit to take away: a write only counts once you've read it back.
- Why a 2xx proves nothing
- Building workouts with the type-checked builder
- Saving, reading back, scheduling and sending to a watch
- Workouts from JSON: forms, databases and AI tools
- Other writes, and their traps
- Testing without touching Garmin
- The escape hatch for anything else
Written against garminconnect-js 0.10.0. Every snippet type-checks against that version, and the test in section 6 runs as written. Snippets assume a signed-in
garminfrom Part 1.
Why a 2xx proves nothing
These are real cases, all found while building the library, where Garmin returned success:
- A strength exercise with a name Garmin doesn't know is stored as an empty string. Your "Barbell back squat" becomes a blank step on the watch.
-
A "walking workout" is accepted and stored with no sport at all, because Garmin has no walking workout type. (That's why the library has no walking or hiking workout helper. Use
otherorcardio_training.) - A pace target sent as minutes per km is stored as a speed in metres per second, so it becomes a nonsense target with no error.
-
Pushing a workout to a watch was documented to return the workout. On a real device it returns an array of device messages, so code reading
result.workoutIdgotundefined.
The library prevents the first three for you. The fourth taught the general rule: after a write, read the stored value back and check the thing you care about. You'll see it below.
Building workouts
Garmin's workout JSON is deeply nested and full of rules: step numbers that run continuously through repeat blocks, sport/step/condition ids that must agree, pace as descending metres per second. buildWorkout handles all of that:
import { buildWorkout } from "garminconnect-js";
const intervals = buildWorkout("6x400 at 5k pace", { sport: "running" })
.warmup({ time: 900, target: { heartRateZone: 2 } })
.repeat(6, (set) =>
set
.interval({ distance: 400, target: { pace: { minPerKm: [4, 4.2] } } })
.recovery({ time: 120 }),
)
.cooldown({ lapButton: true })
.build();
Every step needs exactly one end condition: time (seconds), distance (metres), reps, lapButton, and a few more. Zero or two is an error, thrown before anything reaches Garmin.
Targets are optional, and you write them the way you think about them. The pace above is "4:00 to 4:12 per km", and the builder converts it to the descending metres-per-second pair Garmin wants. You can use minPerMile instead, heart-rate or power zones (heartRateZone: 2), or explicit ranges (heartRateBpm: [140, 155], powerWatts: [250, 280]). A secondaryTarget, such as cadence, works too.
Strength, with exercise names checked by the compiler
Strength is where Garmin's silent failure bites hardest, so the builder type-checks exercise names. Once you pick a category, name autocompletes to that category's exercises, and a typo is a compile error:
const legs = buildWorkout("Lower body", { sport: "strength_training" })
.warmup({ time: 300, exercise: { category: "CARDIO" } })
.repeat(3, (set) =>
set
.interval({ reps: 8, exercise: { category: "SQUAT", name: "BARBELL_BACK_SQUAT" }, weightKg: 60 })
.rest(90),
)
.interval({ reps: 20, exercise: { category: "PLANK" } })
.cooldown({ lapButton: true })
.build();
All 1,830 names were uploaded to a real account one by one, and only those Garmin stored unchanged were kept. A few things to know:
-
.rest(90)is a rest of 90 seconds. Garmin treats a timed rest differently from a timed step, and the builder picks the right one. -
Names are Garmin's keys, not display names. "Barbell overhead press" is
SHOULDER_PRESS/OVERHEAD_BARBELL_PRESS. To search the catalogue at runtime, importgarminconnect-js/exercises, a separate entry point so the 60 KB list never lands in bundles that don't use it. -
Weights drift slightly. Garmin stores kilograms with limited precision, so a weight you send may read back as
59.998. Don't assert exact equality on weights.
Swimming
const swim = buildWorkout("Threshold 100s", { sport: "swimming", poolLength: 25 })
.warmup({ distance: 400, stroke: "free" })
.repeat(8, (set) => set.interval({ distance: 100, stroke: "free", target: { swimCssOffsetSeconds: 5 } }).rest(15))
.cooldown({ distance: 200, stroke: "any_stroke" })
.build();
Strokes, drills (kick, pull, drill) and equipment (fins, paddles, pull_buoy and more) are all typed. The builder covers twelve sports, from cycling and HIIT to yoga, rucking and multi-sport bricks, and WORKOUTS.md has a worked example for each.
Save, read back, schedule, send
// Save to the workout library
const saved = await garmin.uploadWorkout(intervals);
const workoutId = saved?.workoutId;
if (!workoutId) throw new Error("Garmin did not return a workout id");
// Read it back, and check what you care about
const stored = await garmin.getWorkoutById(workoutId);
const sport = (stored?.sportType as { sportTypeKey?: string } | undefined)?.sportTypeKey;
if (sport !== "running") throw new Error(`Stored with sport ${sport}, expected running`);
// Put it on the calendar
await garmin.scheduleWorkout(workoutId, "2026-10-30");
// Send it to the watch
const messages = await garmin.pushWorkoutToDevice(workoutId);
console.log(messages?.length ? "Queued for your watch" : "Already queued for your watch");
What each step does:
-
uploadWorkoutsaves the workout to the account's library and returns the stored record, including itsworkoutId. - The read-back is the habit from the start of this post. Here it checks the sport, the field that was silently lost in the walking-workout bug. Check whatever matters for your feature.
-
scheduleWorkoutadds it to the Garmin calendar on that date. Deleting the schedule later (unscheduleWorkout) keeps the workout in the library. -
pushWorkoutToDevicequeues it for the watch, which picks it up on its next sync. Without a device id it uses the device the account used most recently. It returns device messages, not the workout. An empty array is a success too: it means the workout was already queued. On an account that has never synced a watch, it throws aGarminHttpError(404, "Device id 0 is not registered"), so catch that if your users might not have one yet.
To change a workout, updateWorkout(id, workout) replaces the whole thing; there's no partial update. deleteWorkout(id) removes it from the library for good.
Workouts from JSON
A fluent builder is great in code, but sometimes a workout arrives as data: a row in your database, a form, or an AI assistant's tool call. For those, workoutFromSpec takes the same options as plain JSON and replays them through the same builder:
import { workoutFromSpec } from "garminconnect-js";
import { EXERCISES } from "garminconnect-js/exercises";
const fromForm = workoutFromSpec(
{
name: "Legs",
sport: "strength_training",
steps: [
{ type: "warmup", time: 300 },
{ type: "repeat", times: 3, steps: [
{ type: "interval", reps: 8, exercise: { category: "SQUAT", name: "BARBELL_BACK_SQUAT" }, weightKg: 60 },
{ type: "rest", restSeconds: 90 },
] },
],
},
EXERCISES,
);
await garmin.uploadWorkout(fromForm);
It's stricter than the builder, because JSON from outside your code deserves it. Unknown keys and unknown exercise names throw an error with the exact path and the closest valid names:
workout.steps[1].steps[0].exercise.name: "Barbell Back Squat" is not an exercise in SQUAT; closest: BARBELL_BACK_SQUAT, SQUAT, BARBELL_HACK_SQUAT, BARBELL_BOX_SQUAT, BARBELL_HALF_SQUAT
WORKOUT_SPEC_JSON_SCHEMA describes the input as a JSON Schema, usable directly as an AI tool's input schema. That's exactly how the Garmin MCP server lets Claude create workouts: when Claude gets a name wrong, the error tells it the closest valid names, and it corrects itself.
Other writes, and their traps
Workouts are the biggest write surface, but not the only one. The ones people use most, with the trap each one hides:
-
Weight: units are asymmetric.
addWeighIn(72.4, "kg")sends the value raw, in the unit you name, and Garmin converts. ButgetWeighInsreturns grams, whatever unit you recorded in. Converting both directions the same way is the classic bug: an early version of this library stored a 93 kg weigh-in as 93,000 kg. -
Activity edits are partial and safe.
setActivityName,setActivityDescription,setActivityType, andsetActivityPerceivedEffort(RPE 1 to 10; Garmin stores it times ten, which the method handles) change only that field. -
Gear needs a backdated start date.
addGearToActivityfails unless the gear's first-use date is before the activity. New gear can't be linked to old runs until you backdate it. -
Some writes can't be undone.
addHydrationDatahas no delete endpoint at all. Don't experiment with it on an account you care about.
EU accounts: every write returns 412 until consent is granted. If you're in the EU, Garmin refuses all writes until the account has granted upload consent in Garmin Connect's settings. The request is correct; the account isn't ready. Your users have to grant it themselves, so turn a 412 into a clear message that tells them where.
Testing without touching Garmin
You don't want your test suite writing to a real account, or depending on Garmin being up. garminconnect-js takes a fetchImpl option, so you can swap in a fake fetch and let the real library code run against it: URL building, auth headers, parsing, errors.
// sleep.test.ts (run with: npx tsx --test sleep.test.ts)
import { test } from "node:test";
import assert from "node:assert/strict";
import { Garmin, GarminClient, type Tokens } from "garminconnect-js";
const inADay = Math.floor(Date.now() / 1000) + 86_400;
const fakeTokens: Tokens = {
oauth1: { oauth_token: "t", oauth_token_secret: "s" },
oauth2: {
scope: "", jti: "", token_type: "Bearer", access_token: "fake", refresh_token: "fake",
expires_in: 86_400, refresh_token_expires_in: 2_592_000,
expires_at: inADay, refresh_token_expires_at: inADay * 30,
},
};
function fakeGarmin(routes: Record<string, unknown>) {
const calls: string[] = [];
const fetchImpl: typeof fetch = async (input) => {
const url = new URL(input instanceof Request ? input.url : String(input));
calls.push(url.pathname);
const match = Object.keys(routes).find((path) => url.pathname.startsWith(path));
return match
? Response.json(routes[match])
: new Response("not found", { status: 404 });
};
const client = new GarminClient({ fetchImpl, retries: 0 });
client.setTokens(fakeTokens);
return { garmin: new Garmin(client), calls };
}
test("reads last night's sleep", async () => {
const { garmin, calls } = fakeGarmin({
"/userprofile-service/socialProfile": { displayName: "runner42", fullName: "Test Runner" },
"/wellness-service/wellness/dailySleepData/": { dailySleepDTO: { sleepTimeSeconds: 27_000 } },
});
const sleep = await garmin.getSleepData("2026-10-20");
assert.equal((sleep?.dailySleepDTO as { sleepTimeSeconds?: number }).sleepTimeSeconds, 27_000);
assert.ok(calls.includes("/wellness-service/wellness/dailySleepData/runner42"));
});
test("a 404 from Garmin becomes a GarminHttpError", async () => {
const { garmin } = fakeGarmin({ "/userprofile-service/socialProfile": { displayName: "runner42" } });
await assert.rejects(garmin.getActivity(123), { name: "GarminHttpError" });
});
What's going on:
-
setTokenswith tokens that don't expire for a day means the library never tries to refresh, so no sign-in traffic happens at all. - The profile route is needed because most date-based methods first look up the account's display name, which goes into the URL. The first test checks that it did.
-
retries: 0keeps error tests fast; by default the library retries failed reads with backoff.
One caution from experience. A mock only proves your code agrees with the mock. When the mock, the code and the test were all written from the same wrong assumption about Garmin's response, everything passes and production breaks. Three real bugs in this library shipped exactly that way. So use mocks for your logic, and when you depend on a response shape, confirm it once against a real account.
The escape hatch
garminconnect-js wraps around 200 endpoints, but Garmin Connect has more. For anything it doesn't wrap, client.connectapi(path) calls any Garmin Connect path with the same sign-in, token refresh, retries and errors:
// Does this EU account allow uploads yet? (No method wraps this endpoint.)
const consent = await garmin.client.connectapi<{ userOption?: string }>("/gdprconsent-service/feature/UPLOAD");
const canWrite = consent?.userOption === "opt-in";
Three rules for it:
-
Always start the path with
/. The URL is built by plain string concatenation, so a missing slash produces a broken hostname and a connection error, not a helpful 404. -
Bring your own response type. For paths in Garmin's published Personal API spec, the library exports ready-made types as
PersonalApi.Endpoints["/path/{param}"]. -
Copy load-bearing headers. Some endpoints behave differently without the headers Garmin's own web app sends. (
goal-service, for one, silently returns[]withoutSec-Fetch-Site: same-origin; the library'sgetGoalssends it for you.)
That's the series
Across four posts we've gone from an empty folder to an app that signs users in, reads their training, and writes workouts to their watch, with the traps marked along the way.
- Docs and every method, with what was verified live: garmin.ficdev.xyz
- The workout guide: WORKOUTS.md
- Source (MIT, a star helps): github.com/DynamicsNinja/garminconnect-js
If you build something with it, or find an endpoint behaving differently on your account, I'd love to hear about it, in the comments or as a GitHub issue.
Top comments (0)