Hi David,
I’m building a coaching-platform integration with Intervals.icu — PROTRAIN pushes each athlete’s
planned endurance sessions into their calendar, written to `POST /api/v1/athlete/{id}/events/bulk?upsert=true` and upserted by
`external_id`. Everything looked correct on the Intervals.icu side, but the workouts were arriving
empty on the athlete’s watch. I’ve isolated it to two independent problems, one of which I’ve
already solved. Byte-exact numbers below — I’d rather ask a few precise questions than a vague
one.
## 1. The same event exports differently through the web and through the API
One of our pushed events (`Ritmo Suave`, a single 10 km step):
| download path | file size | `workout_step` messages | `num_valid_steps` |
|—|—|—|—|
| API, `GET /events?category=WORKOUT&ext=fit` | **119 bytes** | **1** | **1** |
| Web UI download button | **92 bytes** | **0** | **0** |
Same event, same day, no edits in between. The web download was repeated after a full re-upsert
of every event, so this isn’t a stale-cache effect. `GET` on the event returns
`workout_doc.steps` intact either way.
## 2. A controlled A/B pair isolates the cause to the *vehicle*
Two events describing the **same** workout (10 km @ 5:30/km), differing only in how it was sent:
| control | payload sent | `workout_doc` after GET | FIT via **API** | FIT via **web** |
|—|—|—|—|—|
| **A** | `description: “- 10km 5:30/km Pace”` only | 1 step (compiled by Intervals) | 121 b / **1 step** | 121 b / **1 step** |
| **B** | `workout_doc` only, no description | 1 step (exactly as sent) | 119 b / **1 step** | 92 b / **0 steps** |
A workout authored as **text** exports correctly through both paths. One authored as
**`workout_doc`** exports correctly only through the API — the web generator appears not to see
steps that were uploaded as `workout_doc` rather than compiled from the description text.
This lines up with [#124215]( API: Create or update workout without using description text syntax? ),
where a user created a workout via `workout_doc`, got well-formed steps back from the backend, and
still found “the pace range is missing” once it reached Garmin — an independent report on a real
device rather than on a download.
## 3. Pace targets need the athlete’s threshold pace — solved, but worth documenting
Initially the exported step came out with no target at all:
```
threshold_pace: null → 92 bytes step 0: duration = DISTANCE 1000000 (10 km, cm) target = OPEN
```
The pace was present in the compiled `workout_doc` the whole time —
`{“pace”:{“units”:“secs/km”,“value”:330},“distance”:10000,“duration”:3300}`, and Intervals even
derived the 3300 s duration from it — but it never reached the FIT.
Following [#130706]( Pace targets lost in Garmin export for API-created running workouts — steps arrive on watch as "No Target" (parsed correctly in workout_doc) ),
we set a Run threshold pace in the athlete’s sport settings and re-exported the same event:
```
threshold_pace set → 146 bytes step 0: duration = DISTANCE 1000000 target = SPEED 2.955–3.106 m/s
```
That’s a 5:22–5:38 /km band around the 5:30 we prescribed, so a single prescribed value is widened
by roughly ±8 s/km on export — good to know, and it confirmed for us that we should keep emitting
single values rather than bands.
So this one is a **configuration prerequisite**, not a bug. It’s just an invisible one from an
integrator’s side: an unset threshold silently strips targets from otherwise valid workouts, and
nothing in the API response hints at it. A line in the upload guide would save the next person the
same hunt.
## Questions
1. **Which representation feeds device sync (the Garmin push)** — the compiled one the web
download uses, or the `workout_doc` the API download uses? Our evidence says these are two
different generators, and knowing which one reaches the watch decides how we should be writing.
2. **Is `workout_doc` via the API a supported path, or is the contract the `description` text
syntax?** The guide in [#63624]( Uploading planned workouts to Intervals.icu )
documents text and file uploads but never mentions `workout_doc`. If text is the intended
contract we’ll switch our emitter over — we’d just rather build against the supported path than
one that happens to work.
3. **Is the threshold-pace prerequisite intended?** It’s surprising for an *absolute* target:
`5:30/km` needs no athlete-specific reference to be expressed, yet without a threshold it’s
dropped entirely rather than exported as-is.
And one that would help regardless of the answers: **when a planned workout ends up with zero
steps, or with every target `OPEN`, is there anything we can check from the API to detect it?**
Right now `GET` returns a perfectly valid `workout_doc`, so our post-write verification passes
while the athlete’s watch is empty. That gap is what made this take a while to find.
Happy to share exact payloads, decoded FIT bytes, or to run any test on our side — we have a
scripted harness for it.
Thanks for intervals.icu, and for keeping the API open.