Workouts
Send a structured session to your athlete's watch, and know whether it landed.
The one call that goes the other way: you describe a session, and we put it on your user's watch.
Create a structured workout and push it to your user's device.
The workout is stored, then pushed, then answered for — so the pushes array
is what actually happened rather than what was intended.
Omit provider to reach every connected device that can carry the workout.
A provider that cannot represent it — Wahoo and a swim — comes back
unsupported beside the ones that took it, and the call still succeeds:
the workout exists and is on a watch. You get a non-2xx only when nothing
could have worked.
Apple Watch is reached through your own iOS app, so its entry comes back
pending and turns synced once the athlete's phone has scheduled it.
namestringRequiredsportSportRequiredrunningtreadmill_runningcyclingindoor_cyclingswimminghikingcardiohiityogapilatesgenericstrengthmulti_sportuser_idstring · uuidRequiredThe athlete, by our id for them — what POST /v1/connect returned and what
every event about them carries as user_id.
descriptionstringThe coach's notes, shown beside the workout. See WorkoutSpec::description.
pool_lengthPoolLengthThe pool, for a swim. Optional — an unspecified pool is swimmable in any
pool — but required once any step carries a target or a send-off,
because a pace is metres per second and the watch needs a length to make a
send-off of it. Refused outright on any other sport. A 422 either way,
with the path.
Show 2 possible shapesHide possible shapes
Variant 1lengthnumber · doubleRequiredunitstring, value is "meters"RequiredVariant 2lengthnumber · doubleRequiredunitstring, value is "yards"RequiredproviderstringWhich provider to push to — garmin, wahoo, coros, suunto or
apple. Omit
to push to every connected provider that can carry this workout,
which is what an athlete with two watches expects and what the
first-party app has always done.
apple reaches the watch through your own iOS app: the workout is
accepted here as pending, and the Stridee SDK schedules it the next time
the app syncs.
scheduled_datestring · dateThe day this workout is for. Omit to leave it in the athlete's library without a calendar entry.
segmentsSegment[]multi_sport only. See WorkoutSpec::segments.
Show child attributesHide child attributes
sportSportRequiredrunningtreadmill_runningcyclingindoor_cyclingswimminghikingcardiohiityogapilatesgenericstrengthmulti_sportstepsStep[]RequiredShow 9 possible shapesHide possible shapes
type is repeatA block run repeats times. Children may not themselves repeat — see
MAX_REPEAT_DEPTH.
repeatsinteger · int32RequiredstepsStep[]Required#[schema(no_recursion)] is load-bearing and must not be removed.
This is the one self-referential type on the platform: Step contains
Vec<Step>. utoipa builds a schema by inlining nested types, so without
this it inlines Step into itself until the thread's stack is gone —
and the failure is nasty to place, because it is not a compile error.
The binary builds, boots, connects to Postgres, loads the signing key,
and then aborts with fatal runtime error: stack overflow the moment
it assembles the OpenAPI document under --features export-openapi.
The generated schema references Step rather than inlining it, which is
also the correct output — a recursive type is exactly what $ref is for.
Same Step shape as above.
type is repeat_untilA block repeated until a condition is met rather than a number of times —
"400s until 20 minutes are up". Same nesting rule as repeat.
stepsStep[]RequiredSee Step::Repeat on why no_recursion must stay.
Same Step shape as above.
untilRepeatUntilRequiredShow 10 possible shapesHide possible shapes
type is timesecondsinteger · int32Requiredtype is distancemetersinteger · int32Requiredtype is calorieskcalinteger · int32Requiredtype is heart_rate_belowbpminteger · int32Requiredtype is heart_rate_abovebpminteger · int32Requiredtype is power_belowwattsinteger · int32Requiredtype is power_abovewattsinteger · int32Requiredtype is power_last_lap_belowThe last lap's average power fell below this.
wattsinteger · int32Requiredtype is max_power_last_lap_belowThe last lap's maximum power fell below this.
wattsinteger · int32Requiredtype is tssTrainingPeaks TSS.
tssnumber · doubleRequiredtype is warmupdurationstringRequireddrillDrillTechnique work over the top of Interval::stroke. Swim-only, same rule.
kickpulldrillbutterflyequipmentEquipmentWhat they hold or wear while doing it. Swim-only, same rule as
Interval::stroke. Absent means nothing — there is no none variant,
because "no equipment" and "the field was not set" are the same fact and
two ways to write it is one too many.
finskickboardpaddlespull_buoysnorkelnamestringShown on the watch. Optional because most steps do not need one.
secondary_targetTargetA second thing to hold alongside target — a pace and a cadence. Only
with a target, and not on a swim, whose one target already travels in
Garmin's secondary slot.
Show 12 possible shapesHide possible shapes
type is paceMinutes per kilometre — the unit a coach writes and the canonical shape stores. The encoder converts to each provider's own unit (Garmin wants metres per second, and inverts the bounds while doing it).
average: "lap" holds the lap's average pace rather than the moment's.
highnumber · doubleRequiredlownumber · doubleRequiredaverageAverage3s10s30slaptype is heart_rateBeats per minute. average: "lap" holds the lap's average.
highnumber · doubleRequiredlownumber · doubleRequiredaverageAverage3s10s30slaptype is powerWatts. average smooths it — 3s, 10s, 30s or lap.
highnumber · doubleRequiredlownumber · doubleRequiredaverageAverage3s10s30slaptype is cadencehighnumber · doubleRequiredlownumber · doubleRequiredtype is heart_rate_zoneOne of the athlete's own heart-rate zones, 1–5, as set on their device.
zoneinteger · int32Requiredtype is power_zoneOne of the athlete's own power zones, 1–7, as set on their device.
zoneinteger · int32Requiredtype is heart_rate_percentPercent of maximum heart rate.
highnumber · doubleRequiredlownumber · doubleRequiredtype is power_percentPercent of FTP.
highnumber · doubleRequiredlownumber · doubleRequiredtype is gradePercent grade, for a treadmill or a smart trainer. May be negative.
highnumber · doubleRequiredlownumber · doubleRequiredtype is resistanceA trainer's resistance level.
highnumber · doubleRequiredlownumber · doubleRequiredtype is css_offsetSwim only: a pace relative to the swimmer's CSS, in seconds per 100, −60 to 60. Negative is faster.
secondsinteger · int32Requiredtype is swim_instructionSwim only: an effort in words rather than a number.
effortSwimEffortRequiredrecoveryvery_easyeasymoderatehardvery_hardall_outfastascenddescendstrokeStrokeWhat the athlete swims this step as. Swim-only; refused on any other
sport rather than ignored, for the reason deny_unknown_fields exists.
Absent means "as they like" — a warm-up nobody prescribes a stroke for.
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoicetargetTargetAbsent means an untargeted step — a warm-up, a float, a rest. Distinct from a target the provider could not carry, which is an error rather than an omission.
Same Target shape as above.
type is activedurationstringRequireddrillDrillTechnique work over the top of Interval::stroke. Swim-only, same rule.
kickpulldrillbutterflyequipmentEquipmentWhat they hold or wear while doing it. Swim-only, same rule as
Interval::stroke. Absent means nothing — there is no none variant,
because "no equipment" and "the field was not set" are the same fact and
two ways to write it is one too many.
finskickboardpaddlespull_buoysnorkelnamestringShown on the watch. Optional because most steps do not need one.
secondary_targetTargetA second thing to hold alongside target — a pace and a cadence. Only
with a target, and not on a swim, whose one target already travels in
Garmin's secondary slot.
Same Target shape as above.
strokeStrokeWhat the athlete swims this step as. Swim-only; refused on any other
sport rather than ignored, for the reason deny_unknown_fields exists.
Absent means "as they like" — a warm-up nobody prescribes a stroke for.
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoicetargetTargetAbsent means an untargeted step — a warm-up, a float, a rest. Distinct from a target the provider could not carry, which is an error rather than an omission.
Same Target shape as above.
type is recoverydurationstringRequireddrillDrillTechnique work over the top of Interval::stroke. Swim-only, same rule.
kickpulldrillbutterflyequipmentEquipmentWhat they hold or wear while doing it. Swim-only, same rule as
Interval::stroke. Absent means nothing — there is no none variant,
because "no equipment" and "the field was not set" are the same fact and
two ways to write it is one too many.
finskickboardpaddlespull_buoysnorkelnamestringShown on the watch. Optional because most steps do not need one.
secondary_targetTargetA second thing to hold alongside target — a pace and a cadence. Only
with a target, and not on a swim, whose one target already travels in
Garmin's secondary slot.
Same Target shape as above.
strokeStrokeWhat the athlete swims this step as. Swim-only; refused on any other
sport rather than ignored, for the reason deny_unknown_fields exists.
Absent means "as they like" — a warm-up nobody prescribes a stroke for.
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoicetargetTargetAbsent means an untargeted step — a warm-up, a float, a rest. Distinct from a target the provider could not carry, which is an error rather than an omission.
Same Target shape as above.
type is restdurationstringRequireddrillDrillTechnique work over the top of Interval::stroke. Swim-only, same rule.
kickpulldrillbutterflyequipmentEquipmentWhat they hold or wear while doing it. Swim-only, same rule as
Interval::stroke. Absent means nothing — there is no none variant,
because "no equipment" and "the field was not set" are the same fact and
two ways to write it is one too many.
finskickboardpaddlespull_buoysnorkelnamestringShown on the watch. Optional because most steps do not need one.
secondary_targetTargetA second thing to hold alongside target — a pace and a cadence. Only
with a target, and not on a swim, whose one target already travels in
Garmin's secondary slot.
Same Target shape as above.
strokeStrokeWhat the athlete swims this step as. Swim-only; refused on any other
sport rather than ignored, for the reason deny_unknown_fields exists.
Absent means "as they like" — a warm-up nobody prescribes a stroke for.
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoicetargetTargetAbsent means an untargeted step — a warm-up, a float, a rest. Distinct from a target the provider could not carry, which is an error rather than an omission.
Same Target shape as above.
type is cooldowndurationstringRequireddrillDrillTechnique work over the top of Interval::stroke. Swim-only, same rule.
kickpulldrillbutterflyequipmentEquipmentWhat they hold or wear while doing it. Swim-only, same rule as
Interval::stroke. Absent means nothing — there is no none variant,
because "no equipment" and "the field was not set" are the same fact and
two ways to write it is one too many.
finskickboardpaddlespull_buoysnorkelnamestringShown on the watch. Optional because most steps do not need one.
secondary_targetTargetA second thing to hold alongside target — a pace and a cadence. Only
with a target, and not on a swim, whose one target already travels in
Garmin's secondary slot.
Same Target shape as above.
strokeStrokeWhat the athlete swims this step as. Swim-only; refused on any other
sport rather than ignored, for the reason deny_unknown_fields exists.
Absent means "as they like" — a warm-up nobody prescribes a stroke for.
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoicetargetTargetAbsent means an untargeted step — a warm-up, a float, a rest. Distinct from a target the provider could not carry, which is an error rather than an omission.
Same Target shape as above.
type is mainThe main set of a swim — Garmin's MAIN intensity, which it defines for
swims only. Refused on any other sport.
durationstringRequireddrillDrillTechnique work over the top of Interval::stroke. Swim-only, same rule.
kickpulldrillbutterflyequipmentEquipmentWhat they hold or wear while doing it. Swim-only, same rule as
Interval::stroke. Absent means nothing — there is no none variant,
because "no equipment" and "the field was not set" are the same fact and
two ways to write it is one too many.
finskickboardpaddlespull_buoysnorkelnamestringShown on the watch. Optional because most steps do not need one.
secondary_targetTargetA second thing to hold alongside target — a pace and a cadence. Only
with a target, and not on a swim, whose one target already travels in
Garmin's secondary slot.
Same Target shape as above.
strokeStrokeWhat the athlete swims this step as. Swim-only; refused on any other
sport rather than ignored, for the reason deny_unknown_fields exists.
Absent means "as they like" — a warm-up nobody prescribes a stroke for.
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoicetargetTargetAbsent means an untargeted step — a warm-up, a float, a rest. Distinct from a target the provider could not carry, which is an error rather than an omission.
Same Target shape as above.
type is exerciseOne strength exercise: "4 × 8 back squat at 80 kg, 2 min rest". Strength
only. May sit inside a repeat, which makes a circuit.
namestringRequiredWhat the exercise is — "Barbell back squat". Shown on the watch, and matched against Garmin's exercise library so the watch can name and log it; a name Garmin has no match for still arrives, as a reps step.
repsinteger · int32RequiredReps in each set.
setsinteger · int32RequirednotestringA coaching cue — "pause at the bottom". Shown with the name.
rest_secondsinteger · int32Rest after each set. Absent for none.
weight_kgnumber · doubleThe load, in kilograms. Absent for bodyweight.
pool_lengthPoolLengthSame PoolLength shape as above.
stepsStep[]Every sport but multi_sport.
Same Step shape as above.
You get back the workout and one entry per provider that was tried:
{
"user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6",
"scheduled_date": "2026-09-02",
"name": "5 × 1km @ threshold",
"sport": "running",
"steps": [
{ "type": "warmup", "duration": { "type": "time", "seconds": 600 } },
{
"type": "repeat",
"repeats": 5,
"steps": [
{
"type": "active",
"duration": { "type": "distance", "meters": 1000 },
"target": { "type": "pace", "low": 3.5, "high": 3.8 }
},
{ "type": "rest", "duration": { "type": "time", "seconds": 90 } }
]
},
{ "type": "cooldown", "duration": { "type": "open" } }
]
}{
"id": "0f31a8c4-59d2-4e07-b6a1-8c74e2f95d30",
"user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6",
"name": "5 × 1km @ threshold",
"sport": "running",
"scheduled_date": "2026-09-02",
"pushes": [
{ "provider": "garmin", "status": "synced", "external_id": "1284410973" },
{ "provider": "wahoo", "status": "pending" }
]
}The push is synchronous
There is no job to wait on and no event to listen for. The call returns once every provider
has answered — typically under a second — so pushes is what actually happened.
Which providers get it
Garmin, Wahoo, COROS, Suunto and Apple Watch take workouts. Omit provider and it goes to every
one of those your user has connected; name one to target it. Naming any other provider is a
422.
Suunto is suunto, and the workout arrives as a SuuntoPlus Guide rather than on a
calendar: the Suunto app syncs it to the watch, and your user selects it under SuuntoPlus
before pressing start. Each step shows its target and a countdown, starts a lap, and moves on
by itself; scheduled_date is shown as the day the guide is for.
Not every Suunto watch runs guides. The Suunto Run and Run 2 have no SuuntoPlus, and
Suunto gives us no other way to put a workout on them — your user can still build one by hand
in the Suunto app. A guide pushed for one is listed in the Suunto app as "Not compatible with
watch". Suunto accepts the guide either way, so we go by
the watch your user last recorded a workout with — if it is one of those two, the push comes
back unsupported and no guide is created. A user who has not recorded a workout since
connecting has no watch on record, and their push is synced whatever they wear. The 3, 5
and 9 families, Race, Vertical and Ocean all take guides.
Apple Watch is apple, and it is reached through your own iOS app rather than a server of
Apple's: the workout is accepted here and the StrideeHealth SDK
schedules it. It counts as connected once the athlete has allowed your app to add workouts.
A partial success is still a success
Give a swim to an athlete on Garmin and Wahoo, and Garmin takes it while Wahoo — which
handles running and cycling only — cannot. That is a 201 with both facts in the array:
Only a user who is not yours, a provider they have not connected, or a workout that does not validate is a non-2xx.
"pushes": [
{ "provider": "garmin", "status": "synced", "external_id": "1284410973" },
{ "provider": "wahoo", "status": "unsupported",
"error": "Wahoo structured plans cover running and cycling, not 'swimming'" }
]What each status means
status | |
|---|---|
synced | On the watch. external_id is the provider's own id for it. |
pending | Accepted, not on the device yet. Needs nothing from you — see below. |
unsupported | This provider cannot represent this workout. Terminal; retrying changes nothing. |
failed | It did not land. reason says why, and whether retrying can help. |
Why a push did not land
Every push that is not a clean synced or an on-time pending carries a reason — a
stable code to branch on — beside error, a sentence for a person that you should
never parse:
reason | What happened | What fixes it |
|---|---|---|
not_permitted | The athlete connected, but did not grant workout access — Garmin's Workout Import, Wahoo's plan access. | The athlete reconnects through POST /v1/connect and allows it. Retrying before then fails the same way. |
not_connected | There is no live connection to this provider any more. | The athlete connects again. |
unsupported | This provider cannot represent this workout — a swim to Wahoo, a COROS date outside its window — or the athlete's watch cannot run it: a Suunto Run. | Nothing; send it somewhere else, or change the workout. |
provider_error | The provider was reached and refused, or could not be reached. | A PATCH to retry. If it keeps failing, error carries the provider's answer. |
schedule_failed | The provider has the workout but did not accept the date. Status stays pending. | Nothing — we retry the schedule. |
{ "provider": "garmin", "status": "failed", "reason": "not_permitted",
"error": "the athlete has not granted Garmin's Workout Import permission — ask them to reconnect Garmin and leave Workout Import switched on" }pending is normal, on Wahoo and Apple
Wahoo only puts a workout on the device within six days of its date, so a session scheduled
for next month is pending for weeks. Don't retry it: when the date comes into range we
finish the push and it becomes synced. There is no event for that — read the status from
GET /v1/workouts/{id} when you render the calendar.
An Apple push is pending until the athlete's phone next syncs — usually the next time
they open your app — and for a workout further out than the Workout app has room for,
until earlier ones have passed. See Workouts on Apple Watch.
Editing
PATCH /v1/workouts/{id} takes the whole workout, not a
partial one, and re-pushes it. DELETE /v1/workouts/{id} takes
it off the devices, then off us. COROS only deletes workouts dated today or later that
haven't been done. An Apple Watch copy is replaced or removed at the phone's next sync.
The step format
A workout is a list of steps. A step is either an interval, a repeat of intervals, a repeat_until — intervals repeated until a condition is met — or, in a strength workout, an exercise. See Strength.
Intervals are typed by their role — warmup, active, recovery, rest, cooldown, and
main for a swim's main set — which is what the watch shows and how it colours the screen.
A workout may also carry a description, up to 2,000 characters: the coach's notes, shown
beside the workout on Garmin, COROS and Wahoo.
Duration
Seconds and metres, not minutes and kilometres, unless the type says otherwise.
openruns until the athlete presses lap.yardsandmilesshow on the watch in the unit you wrote.repsis forcardio,hiitandstrengthworkouts only.heart_rate_below/_aboveandpower_below/_aboveend the step when the athlete crosses the threshold: "recover until 130".tssis TrainingPeaks TSS.repetition_timeandcss_repetition_timeare swim send-offs. See Send-offs.
{ "type": "time", "seconds": 600 }
{ "type": "distance", "meters": 1000 }
{ "type": "yards", "yards": 100 }
{ "type": "miles", "miles": 1.5 }
{ "type": "open" }
{ "type": "reps", "count": 12 }
{ "type": "heart_rate_below", "bpm": 130 }
{ "type": "heart_rate_above", "bpm": 160 }
{ "type": "power_below", "watts": 150 }
{ "type": "power_above", "watts": 300 }
{ "type": "calories", "kcal": 200 }
{ "type": "tss", "tss": 20 }
{ "type": "repetition_time", "seconds": 70 }
{ "type": "css_repetition_time", "offset_seconds": 10 }Target
Optional. Absent means an untargeted step — a warm-up, a float, a rest.
A band takes a range. For a single figure, set both bounds to it.
Pace is minutes per kilometre in every sport, swimming included, and low is the
faster end — the smaller number. We convert to each provider's unit.
-
Zones are the athlete's own, as set on their device: heart rate 1–5, power 1–7.
-
heart_rate_percentis percent of maximum heart rate.power_percentis percent of FTP. -
gradeis percent, for a treadmill or smart trainer, and may be negative. -
averagesmoothspace,heart_rateorpowerbefore the watch compares it:"lap"for any of the three, and"3s","10s"or"30s"for power:JSON{ "type": "power", "low": 250, "high": 280, "average": "3s" }
secondary_target takes any of the above as a second thing to hold alongside target,
such as a pace and a cadence. It needs a target, and a swim step takes only one.
{ "type": "pace", "low": 3.5, "high": 3.8 }
{ "type": "heart_rate", "low": 150, "high": 165 }
{ "type": "power", "low": 240, "high": 260 }
{ "type": "cadence", "low": 85, "high": 95 }
{ "type": "heart_rate_zone", "zone": 3 }
{ "type": "power_zone", "zone": 4 }
{ "type": "heart_rate_percent", "low": 80, "high": 85 }
{ "type": "power_percent", "low": 95, "high": 105 }
{ "type": "grade", "low": -2, "high": 4 }
{ "type": "resistance", "low": 5, "high": 7 }Repeat until
A repeat runs a number of times. A repeat_until runs until a condition is met:
until is one of time (seconds), distance (meters), calories (kcal),
heart_rate_below / heart_rate_above (bpm), power_below / power_above /
power_last_lap_below / max_power_last_lap_below (watts), or tss (tss). A swim
repeats until a time or a distance only.
{
"type": "repeat_until",
"until": { "type": "time", "seconds": 1200 },
"steps": [
{ "type": "active", "duration": { "type": "distance", "meters": 400 } },
{ "type": "recovery", "duration": { "type": "heart_rate_below", "bpm": 130 } }
]
}Sports
running, treadmill_running, cycling, indoor_cycling, swimming, hiking, cardio,
hiit, yoga, pilates, generic, strength and multi_sport. Not every provider takes
every one — a swim is unsupported on Wahoo — and the pushes array tells you which did.
indoor_cycling is a ride on a trainer and treadmill_running a run on a treadmill. Send
them when you know the session is indoors: Suunto, Wahoo and Apple Watch start their indoor
mode, with no GPS. Garmin and COROS have no indoor workout sport, so they get a plain ride
or run, and the athlete picks Bike Indoor or Treadmill when they start it. cycling and
running are unchanged.
cardio, hiit, yoga, pilates and generic are timed intervals. Garmin supports
generic on some devices only. A strength workout is written as exercises. See
Strength.
A swim carries four fields the others do not, and its targets behave differently. See
Swimming. A multi_sport workout carries segments in place of steps. See
Multi-sport.
Limits
A repeat may not contain another repeat. Watches render one level and we would rather
refuse than silently flatten a session into something you did not write. Beyond that:
200 passes per repeat, 500 steps once repeats are expanded. A repeat_until counts its
steps once.
Strength
A strength workout is a list of exercises, each with its sets, reps, load and rest:
| Field | ||
|---|---|---|
name | required | What the exercise is. Shown on the watch. |
sets | required | 1–50. |
reps | required | Reps in each set, 1–1,000. |
weight_kg | optional | The load in kilograms, up to 1,000. Leave it out for bodyweight. |
rest_seconds | optional | Rest after each set, up to 3,600. Leave it out for none. |
note | optional | A coaching cue, shown with the name. name and note together are at most 512 characters. |
An exercise step is refused in any sport but strength. A strength workout can mix
exercises with ordinary intervals, such as a timed warm-up or a reps interval.
{
"user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6",
"scheduled_date": "2026-09-02",
"name": "Lower body A",
"sport": "strength",
"steps": [
{ "type": "warmup", "duration": { "type": "time", "seconds": 300 } },
{
"type": "exercise",
"name": "Barbell back squat",
"sets": 4,
"reps": 8,
"weight_kg": 80,
"rest_seconds": 120,
"note": "Pause at the bottom"
},
{ "type": "exercise", "name": "Romanian deadlift", "sets": 3, "reps": 10, "weight_kg": 60, "rest_seconds": 90 },
{ "type": "exercise", "name": "Walking lunge", "sets": 3, "reps": 12, "rest_seconds": 60 }
]
}Circuits
Put exercises inside a repeat to make a circuit, one pass per round:
An exercise with more than one set inside a circuit has its sets written out one after another within each round, because watches don't repeat inside a repeat. Each set, and each rest after a set, counts toward the 500-step limit.
{
"type": "repeat",
"repeats": 3,
"steps": [
{ "type": "exercise", "name": "Kettlebell swing", "sets": 1, "reps": 15, "weight_kg": 24 },
{ "type": "exercise", "name": "Push-up", "sets": 1, "reps": 12 },
{ "type": "rest", "duration": { "type": "time", "seconds": 60 } }
]
}On the watch
Only Garmin takes strength workouts. It gets each exercise as a set of reps, with the load and the rest after it, repeated once per set. The watch counts down the sets and records them against the exercise.
We match name against Garmin's exercise library, so the watch can show Garmin's own
exercise and log your sets under it. Write names the way a gym would, like "Barbell back
squat" or "Dumbbell bench press".
- A name we can match arrives as Garmin's exercise.
- A name we can only place in a movement family, such as a squat or a row, arrives under that family. When Garmin has a generic exercise for the family, the watch shows that as the step's title: "Nordic hamstring curl" arrives as Garmin's "Leg Curl", and the sets are logged against it.
- A name we can't place at all still arrives, as a reps step labelled with your
name.
Your name, and your note after it, always go in the step's description, whatever title
the watch shows. The sets, reps, load and rest never change.
If Garmin refuses an exercise we chose, we send the workout again without Garmin's exercise names, and then without their families, rather than fail it. This applies to every step in the workout at once, not only the one Garmin refused.
COROS and Wahoo can't carry a load, so a workout with an exercise step comes back
unsupported from both rather than arriving as bare reps.
Multi-sport
A brick or a triathlon is one workout with a segment per sport. Each segment takes the same
steps a workout of that sport would, and a swim segment carries its own pool_length:
Between 2 and 25 segments, each running, treadmill_running, cycling, indoor_cycling,
swimming or hiking. Every rule for
that sport applies inside its segment, and an error names it:
segments[2].steps[0].stroke. The watch moves the athlete through a transition between
segments.
{
"user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6",
"name": "Sprint tri",
"sport": "multi_sport",
"segments": [
{
"sport": "swimming",
"pool_length": { "unit": "meters", "length": 50 },
"steps": [{ "type": "active", "duration": { "type": "distance", "meters": 750 } }]
},
{
"sport": "cycling",
"steps": [{ "type": "active", "duration": { "type": "distance", "meters": 20000 } }]
},
{
"sport": "running",
"steps": [{ "type": "active", "duration": { "type": "distance", "meters": 5000 } }]
}
]
}Swimming
A pool swim is the one sport where the step list is not enough on its own. A watch in the pool counts lengths, shows a stroke and a piece of equipment per step, and can time a rest as a send-off rather than a countdown. Four fields carry that.
{
"user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6",
"scheduled_date": "2026-09-03",
"name": "Technique 1,800",
"sport": "swimming",
"pool_length": { "unit": "meters", "length": 25 },
"steps": [
{
"type": "warmup",
"duration": { "type": "distance", "meters": 400 },
"stroke": "choice"
},
{
"type": "repeat",
"repeats": 8,
"steps": [
{
"type": "active",
"name": "Catch-up",
"duration": { "type": "distance", "meters": 50 },
"stroke": "butterfly",
"drill": "kick",
"equipment": "fins"
},
{ "type": "rest", "duration": { "type": "time", "seconds": 20 } }
]
},
{
"type": "repeat",
"repeats": 4,
"steps": [
{
"type": "active",
"duration": { "type": "distance", "meters": 100 },
"stroke": "freestyle",
"drill": "pull",
"equipment": "pull_buoy",
"target": { "type": "pace", "low": 17.0, "high": 18.0 }
},
{ "type": "rest", "duration": { "type": "time", "seconds": 30 } }
]
},
{ "type": "cooldown", "duration": { "type": "distance", "meters": 200 }, "stroke": "backstroke" }
]
}pool_length — on the workout, not the step
Every length in a session is the same length, so this belongs to the workout.
Optional — without it the workout can be swum in any pool. Required as soon as any step
has a target or a send-off, because the watch needs the length to turn a pace
into a send-off; otherwise it is a 422.
Use the unit painted on the pool: 25 yards stays 25 yards. Step distances are in whatever
unit the step says: "meters": 100 is 100 m in a yard pool too, so write
{ "type": "yards", "yards": 100 } for 100 yards.
{ "unit": "meters", "length": 25 }
{ "unit": "yards", "length": 25 }stroke — per step, optional
freestyle, backstroke, breaststroke, butterfly, individual_medley,
reverse_individual_medley (IM backwards), individual_medley_by_round (a different IM
stroke each round), reverse_individual_medley_by_round, drill, mixed, choice.
Omitted means no stroke was prescribed; choice means "swimmer's choice", and the watch
shows it.
drill — per step, optional
kick, pull, drill.
A drill layers over a stroke: freestyle + pull shows as Free Pull. Without a stroke it
applies to whatever the swimmer is doing.
butterfly is deprecated. It still works and is sent as stroke: "butterfly" with
drill: "drill", so write that pair instead.
equipment — per step, optional
fins, kickboard, paddles, pull_buoy, snorkel.
For no equipment, omit the field.
Targets in the pool
pace, heart_rate and two swim-only targets, each needing pool_length:
css_offsetis a pace relative to the swimmer's CSS (critical swim speed), in seconds per 100, from −60 to 60. Negative is faster.swim_instructionis an effort in words:recovery,very_easy,easy,moderate,hard,very_hard,all_out,fast,ascend(each repetition faster),descend.
power and cadence are accepted but dropped for a swim, because no pool sensor
measures them. Zones, percentages, grade, resistance, average and secondary_target
are a 422. So are durations that end on a heart rate, power, calorie or TSS threshold.
{ "type": "css_offset", "seconds": -3 }
{ "type": "swim_instruction", "effort": "descend" }Rests
A rest with a time duration is a fixed rest at the wall: "seconds": 20 is twenty
seconds of rest, however long the swim before it took. The last rest of a repeat block is
dropped.
Send-offs
"8 × 50 on 1:10" means each 50 starts 70 seconds after the last: swim faster, rest
longer. That is a rest step with a repetition_time duration, straight after the swim it
times:
To time each repetition off the swimmer's CSS instead, use css_repetition_time: the
send-off is the CSS time for the distance plus offset_seconds, from −60 to 60, and it
adjusts when their CSS changes.
A send-off of either kind must be a rest, must directly follow a non-rest step, and needs
pool_length. Anything else is a 422. A provider that has no send-off returns
unsupported rather than turning it into a fixed rest, which would be a different workout.
{
"type": "repeat",
"repeats": 8,
"steps": [
{ "type": "active", "duration": { "type": "distance", "meters": 50 }, "stroke": "freestyle" },
{ "type": "rest", "duration": { "type": "repetition_time", "seconds": 70 } }
]
}{ "type": "rest", "duration": { "type": "css_repetition_time", "offset_seconds": 10 } }main — the main set
A step typed main is a swim's main set, shown as such on Garmin. It is swim-only.
Where the swim fields are refused
All four are swim-only, and so are send-offs, main, css_offset and swim_instruction.
Sending one on a run is a 422, not a field we quietly drop.
What reaches each provider
| Provider | Swims |
|---|---|
| Garmin | Everything above, send-offs included. |
| COROS | Distances, rests and each step's name — not stroke, drill or equipment, so name your drill steps. A workout with a send-off or a swim-only target comes back unsupported. |
| Wahoo | Not supported; comes back unsupported. |
| Suunto | Times, distances and each step's name — not stroke, drill or equipment, so name your drill steps. A workout with a send-off or a swim-only target comes back unsupported. |
| Apple Watch | Times, distances and each step's name, where the phone's iOS has a custom swim workout — not stroke, drill or equipment. A workout with a send-off or a swim-only target comes back unsupported. |
What each provider can carry
Garmin carries everything on this page. COROS, Wahoo and Suunto carry time, distance, yards,
miles, reps and open durations, pace, heart_rate, power and cadence targets,
and plain repeat blocks. Anything else — a threshold duration, a zone, a percentage,
grade, resistance, an average, a secondary_target, a repeat_until, a send-off, a
multi_sport workout, a strength exercise — comes back unsupported from those three rather
than being turned into a different workout. The sport lists differ too: Wahoo takes
running, treadmill_running, cycling, indoor_cycling and hiking; COROS takes
running, treadmill_running, cycling, indoor_cycling and swimming; Suunto
takes any sport, and ends a reps step on a lap press because a watch cannot count them.
Apple Watch sits between: it adds calories, heart_rate_zone, power_zone and a lap
average to that list, takes every sport but strength and multi_sport, and needs a
scheduled_date. What an Apple Watch can carry
has the detail.
Rate limit
60 device pushes a minute per account — one workout to a user with three watches is three.
Apple pushes don't count: they call no provider.
Over it is a 429; wait a minute. Providers rate-limit us across every customer, so spread
a season's plan over a few minutes rather than sending it in one loop.
Errors name the step
An invalid workout is a 422 whose body names the step:
Unknown fields are rejected too, so a typo is a 422 rather than a silently untargeted step
on somebody's watch.
steps[1].steps[0].target: `low` must not be greater than `high`Something wrong or missing on this page? Tell us in Discord. Need something the API doesn’t do yet? Request it on the roadmap.