FIT workout import silently drops REPS steps and mis-scales DISTANCE steps

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 .FIT writer, independently round-tripped through fitdecode (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:

  1. 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 configured max_hr (195). Nothing in the source data suggested a targe
    t at all.
  2. hr_zone target exports as a wrong absolute range: a workout_doc step with hr: {value: 2, units: "hr_zone"} (real HR zone 2) exported to F
    IT as custom_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 own sportSettings.hr_zones).
  3. Pace targets silently dropped on export: a workout_doc step with a correctly-unit-converted pace: {start, end, units: "%pace"} target expor
    ted with target_type=OPEN — the pace target just isn’t encoded into the outbound FIT at all.
  4. 500 on download for an unrecognized workout_doc field: a workout_doc step with a non-standard key (I tried "reps", since there’s apparent
    ly no supported field for rep-based strength content) caused GET .../download.fit to 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 .FIT workout (or a workout_doc JSON 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.

I stopped reading at the swim distance… I hate to read AI hallucinated stuff :wink:
Make sure to respect the syntax mentioned here