Events created via API with `workout_doc`: the web download produces an empty FIT while the API download does not — which representation feeds device sync?

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.

Please ask with your own words, I don’t understand what Claude want to know.

I’ve tested to download a workout fit file and it shows up correctly in fitfileviewer.com

You should use the description field.

Yes. Otherwise for example no load could be calculated.

You could probably read the athlete settings and check their zones setup…

My 2 cents:
This is something we see happen more often recently. Questions that clearly show that the user has barely or ever used the Intervals platform. They are just after a hub that easily and freely transfers workouts and activities from multiple device manufacturers from and to their own, often AI, platform.

Thanks R2Tom, really helpful. Fair point on the wording — I over-edited the
post. We’ll switch to the description field, and the preflight idea (checking
the athlete’s zones/threshold before syncing) is exactly what we were missing.

For context: I train with Intervals myself and my athletes will be using
their own accounts — the integration just saves my coach from typing every
session twice. Thanks again!

If it’s only that, you surely already know that there is the possibility to use a library, and a coaching group to easily push workouts to your athletes inside of intervals.

Thanks R2Tom — fair point on the wording, I over-edited the post.

We followed your advice and switched our emitter to the description field:
same workout that exported as 92 bytes / 0 steps now comes out as 450 bytes
with all 8 steps and proper speed targets. The preflight idea (checking the
athlete’s zones before syncing) is implemented too.

For context: I train with Intervals myself and my athletes use their own
accounts — the integration just saves my coach from typing every session
twice. Thanks again!