Created
August 7, 2026 06:47
-
-
Save luizs81/599b0e233b248f2fbfd344ac85e771fc to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Garmin Connect Workout Upload API — Lessons Learned | |
| Notes from building and debugging a 4-week structured training plan (strength + cycling + running) uploaded via the Garmin Connect workout API. The API frequently accepts structurally valid JSON and returns "success" while silently storing something wrong or degraded. These are the failure modes worth checking for before trusting an upload. | |
| ## 1. Reps alone don't create sets | |
| Sending a single step with `endConditionValue: 10` under a `reps` end condition produces one repetition, not "3 sets of 10." To get real sets, wrap the exercise + rest pair in a `RepeatGroupDTO`: | |
| ```json | |
| { | |
| "type": "RepeatGroupDTO", | |
| "stepType": {"stepTypeId": 6, "stepTypeKey": "repeat"}, | |
| "numberOfIterations": 3, | |
| "endCondition": {"conditionTypeId": 7, "conditionTypeKey": "iterations"}, | |
| "endConditionValue": 3, | |
| "workoutSteps": [ | |
| { "...exercise step (reps end condition)..." }, | |
| { "...rest step (time end condition)..." } | |
| ] | |
| } | |
| ``` | |
| Without the wrapper, the app just lists the exercise once with no set count. | |
| ## 2. Exercise names must match Garmin's internal database exactly — or they're silently dropped | |
| Sending an `exerciseName` that isn't in Garmin's fixed list doesn't error. The field is just stripped from the step on upload, and the app falls back to displaying the bare `category` name instead (e.g. a mislabeled "Dumbbell Fly" step displays as just "Plank" or whatever its category is). | |
| Common wrong guesses vs. correct names: | |
| | Wrong (looks right, gets dropped) | Correct Garmin name | Category | | |
| |---|---|---| | |
| | `DUMBBELL_FLY` | `DUMBBELL_FLYE` | `FLYE` | | |
| | `INCLINE_DUMBBELL_PRESS` | `INCLINE_DUMBBELL_BENCH_PRESS` | `BENCH_PRESS` | | |
| | `LATERAL_RAISE` (as exercise name) | `DUMBBELL_LATERAL_RAISE` | `LATERAL_RAISE` | | |
| | `AB_WHEEL_ROLLOUT` | `BARBELL_ROLLOUT` (closest match; no literal ab-wheel entry) | `CORE` | | |
| | `TRICEPS_PUSHDOWN` | `TRICEPS_PRESSDOWN` | `TRICEPS_EXTENSION` | | |
| Full reference (1624 exercises across 49 categories): https://docs.tryterra.co/planned-workouts-api/garmin-exercise-reference | |
| **Always verify with `get_workout_by_id` after upload** — check that `exercise_name` actually appears on each step. A missing field is the tell that the name didn't match. | |
| ## 3. Isometric holds need a time-based end condition, not reps | |
| Plank, wall-sit, and similar holds should use: | |
| ```json | |
| "endCondition": {"conditionTypeId": 2, "conditionTypeKey": "time"}, | |
| "endConditionValue": 45 | |
| ``` | |
| not `conditionTypeKey: "reps"` with `endConditionValue: 1`. The latter displays as "1 rep," which is meaningless for a hold. Garmin's correctly-named hold exercise is `EXTENDED_PLANK` under category `PLANK`. | |
| ## 4. Cycling absolute-watt power targets silently store as pace — use power zones instead | |
| This is a genuine API bug, not a payload mistake. Even with the documented-correct target type: | |
| ```json | |
| "targetType": {"workoutTargetTypeId": 6, "workoutTargetTypeKey": "power.between"}, | |
| "targetValueOne": 220, | |
| "targetValueTwo": 235 | |
| ``` | |
| ...Garmin stores and returns it as `"target_type": "pace.zone"` with the watt numbers preserved but mislabeled. The watch then displays a pace target (min/km) on a cycling workout, which is nonsensical. Confirmed via `get_workout_by_id` — this happens even on a single bare interval step with no repeat groups or warmup/cooldown involved, so it's not a structural issue on the sender's side. | |
| **Working alternative:** use `power.zone` (percentage of FTP, zones 1–7) instead of absolute watts: | |
| ```json | |
| "targetType": {"workoutTargetTypeId": 2, "workoutTargetTypeKey": "power.zone"}, | |
| "zoneNumber": 3 | |
| ``` | |
| This round-trips correctly. Trade-off: zones are coarser than a tight watt range (e.g. Zone 3 at FTP 250W spans roughly 190–225W), but it's the only power target type that reliably works for cycling on this API. | |
| Standard 7-zone model (% of FTP), useful for mapping existing watt targets to zones: | |
| | Zone | % FTP | Zone | % FTP | | |
| |---|---|---|---| | |
| | 1 (Recovery) | ≤55% | 5 (VO2max) | 106–120% | | |
| | 2 (Endurance) | 56–75% | 6 (Anaerobic) | 121–150% | | |
| | 3 (Tempo) | 76–90% | 7 (Neuromuscular) | >150% | | |
| | 4 (Threshold) | 91–105% | | | | |
| ## General takeaway | |
| Treat "upload successful" as necessary but not sufficient. After any batch upload, spot-check with `get_workout_by_id` on at least one workout per sport/step-type combination and confirm: | |
| - exercise names actually appear on strength steps | |
| - set/repeat counts show a `repeat_count` matching intent | |
| - `target_type` on the returned step matches what was sent (not silently swapped) | |
| Cheaper to catch here than after the person's already looked at their phone. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment