Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save luizs81/599b0e233b248f2fbfd344ac85e771fc to your computer and use it in GitHub Desktop.

Select an option

Save luizs81/599b0e233b248f2fbfd344ac85e771fc to your computer and use it in GitHub Desktop.
# 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