Summary
Pushing a spec-valid Garmin workout-type .FIT file via POST /api/v1/athlete/{id}/events/bulk with file_contents_base64 produces badly corrupted
calendar events for two common step shapes: strength duration_type=REPS steps are silently dropped (with the parser’s own error text left behind in
the event description), and swim duration_type=DISTANCE steps have their raw meter value misread as minutes, inflating duration by exactly 60x.
I’m a developer (building a training-plan app that generates real, independently-verified .FIT workout files) so I have exact before/after data for
both bugs, plus two related export-side issues I found while testing a workaround.
Environment
- Account: athlete id
i318548 - API:
https://intervals.icu/api/v1/athlete/i318548/events/bulk?upsert=true - Verified against my own
.FITwriter, independently round-tripped throughfitdecode(an unrelated open-source FIT reader) before every push — the
files I sent are confirmed spec-correct.
Bug 1: duration_type=REPS steps are dropped, not imported
Steps to reproduce: POST a .FIT workout event (category=WORKOUT, type=WeightTraining) whose workout_step messages include steps with dura tion_type=REPS (e.g. “KB swings, 10 reps”) followed by a REPEAT_UNTIL_STEPS_CMPLT marker looping back to them — a completely standard FIT SDK-docum
ented shape for a repeated exercise circuit.
Expected: the 4 exercise steps + repeat marker import as structured workout content.
Actual: all 5 steps vanish. GET on the resulting event shows only the bookend TIME-based warm-up/cool-down steps, and the event’s description
field contains this, verbatim, in place of the real content:
step [1]: Unhandled duration_type: REPS message_index=1 wkt_step_name=KB swings duration_type=29 duration_value=10 target_type=2 intensity=0 exercise_
weight=14.0 weight_display_unit=0
step [2]: Unhandled duration_type: REPS message_index=2 wkt_step_name=Goblet squat duration_type=29 duration_value=10 target_type=2 intensity=0 notes=
https://www.rehabhero.ca/exercise/goblet-squat exercise_weight=14.0 weight_display_unit=0
step [3]: Unhandled duration_type: REPS message_index=3 wkt_step_name=Single-leg KB RDL duration_type=29 duration_value=8 target_type=2 intensity=0 no
tes=https://www.rehabhero.ca/exercise/single-leg-deadlift exercise_weight=14.0 weight_display_unit=0
step [4]: Unhandled duration_type: REPS message_index=4 wkt_step_name=Halos each direction duration_type=29 duration_value=10 target_type=2 intensity=
0 exercise_weight=14.0 weight_display_unit=0
step [5]: No step found for duration_value 1 message_index=5 duration_type=6 duration_value=1 target_type=2 target_value=3 intensity=0
That looks like your own importer’s internal diagnostic output leaking into a user-facing field rather than a real crash/error response — worth fixing
both the missing REPS case and the leak.
Bug 2: duration_type=DISTANCE steps are misread as minutes (60x inflation)
Steps to reproduce: POST a .FIT workout event (type=Swim) with workout_step messages using duration_type=DISTANCE (e.g. 200m, 200m, 100m —
a standard 500m swim set, correctly spec-encoded — duration_distance scaled per the FIT SDK’s own field definition).
Expected: a ~15 minute, 500m swim.
Actual: the imported event shows moving_time: 30000 (8h 20m) and distance: 39066.664 (~39km). Checking the numbers directly: each step’s impor
ted duration is exactly 60x its real distance value in meters (200m → 12000s, 100m → 6000s). It looks like the raw duration_distance field val
ue is being read and treated as minutes-then-converted-to-seconds, ignoring the duration_type discriminator entirely.
On the athlete-facing side this is very visible: the calendar/watch shows “Duration 8h20m, Distance 42723y” for what’s actually a 15-minute, 500m swim
, and the step descriptions show a nonsensical “110-114% Pace” figure baked into the stored description text.
Related findings (export side, found while testing a workout_doc JSON workaround)
To work around the above, I tried POSTing workout_doc directly in the bulk-events body instead of file_contents_base64 (bypassing the FIT importer
). That avoids bugs 1 and 2 above — I confirmed it stores step count/duration correctly and does not inject values it wasn’t given — but surfaced th
ree more issues on your FIT export side (GET /api/v1/athlete/{id}/events/{id}/download.fit), all reproduced 2026-08-21 ~02:58 UTC on account i318 548:
- Fabricated HR target on export: a step pushed via FIT import with no target at all (
target_type=OPEN) came back on the watch/calendar with a
heart-rate target of 213-242 bpm — a range that exceeds this athlete’s own configuredmax_hr(195). Nothing in the source data suggested a targe
t at all. hr_zonetarget exports as a wrong absolute range: aworkout_docstep withhr: {value: 2, units: "hr_zone"}(real HR zone 2) exported to F
IT ascustom_target_heart_rate_low/high = 250/258— again above this athlete’s max_hr, and nowhere near their real zone-2 boundaries (158-165, per t
heir ownsportSettings.hr_zones).- Pace targets silently dropped on export: a
workout_docstep with a correctly-unit-convertedpace: {start, end, units: "%pace"}target expor
ted withtarget_type=OPEN— the pace target just isn’t encoded into the outbound FIT at all. - 500 on download for an unrecognized
workout_docfield: aworkout_docstep with a non-standard key (I tried"reps", since there’s apparent
ly no supported field for rep-based strength content) causedGET .../download.fitto fail with{"status":500,"error":"Internal server error, trace 8bb19287"}instead of ignoring the unknown field or returning a clean 4xx.
I’ve since deleted my test events, so those specific event ids won’t reproduce (2), (3), (4) directly — happy to re-create minimal repro events on req
uest if that trace id isn’t enough for you to find in your logs, or to share the exact .fit/JSON payloads I used.
Expected vs actual, short version
- Expected: a spec-valid
.FITworkout (or aworkout_docJSON body using your own vocabulary) round-trips through import → calendar → Garmin push
with its real content intact. - Actual: strength rep-circuits are silently discarded with a debug string left in the description; swim distance sets are inflated 60x; and even the
JSON-native path has real gaps on export (fabricated/wrong HR targets, dropped pace targets, and a 500 on an unrecognized field).
Happy to share the exact .fit files and workout_doc JSON bodies used for each repro if useful.