Stridee
Stridee Platform Docs

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.

post /v1/workouts
Reference

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.

Body parameters
namestringRequired
sportSportRequired
Possible enum values
runningtreadmill_runningcyclingindoor_cyclingswimminghikingcardiohiityogapilatesgenericstrengthmulti_sport
user_idstring · uuidRequired

The athlete, by our id for them — what POST /v1/connect returned and what every event about them carries as user_id.

descriptionstring

The coach's notes, shown beside the workout. See WorkoutSpec::description.

pool_lengthPoolLength

The 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 shapes
As Variant 1
lengthnumber · doubleRequired
unitstring, value is "meters"Required
As Variant 2
lengthnumber · doubleRequired
unitstring, value is "yards"Required
providerstring

Which 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 · date

The 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 attributes
sportSportRequired
Possible enum values
runningtreadmill_runningcyclingindoor_cyclingswimminghikingcardiohiityogapilatesgenericstrengthmulti_sport
stepsStep[]Required
Show 9 possible shapes
When type is repeat

A block run repeats times. Children may not themselves repeat — see MAX_REPEAT_DEPTH.

repeatsinteger · int32Required
stepsStep[]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.

When type is repeat_until

A 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[]Required

See Step::Repeat on why no_recursion must stay.

Same Step shape as above.

untilRepeatUntilRequired
Show 10 possible shapes
When type is time
secondsinteger · int32Required
When type is distance
metersinteger · int32Required
When type is calories
kcalinteger · int32Required
When type is heart_rate_below
bpminteger · int32Required
When type is heart_rate_above
bpminteger · int32Required
When type is power_below
wattsinteger · int32Required
When type is power_above
wattsinteger · int32Required
When type is power_last_lap_below

The last lap's average power fell below this.

wattsinteger · int32Required
When type is max_power_last_lap_below

The last lap's maximum power fell below this.

wattsinteger · int32Required
When type is tss

TrainingPeaks TSS.

tssnumber · doubleRequired
When type is warmup
durationstringRequired
drillDrill

Technique work over the top of Interval::stroke. Swim-only, same rule.

Possible enum values
kickpulldrillbutterfly
equipmentEquipment

What 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.

Possible enum values
finskickboardpaddlespull_buoysnorkel
namestring

Shown on the watch. Optional because most steps do not need one.

secondary_targetTarget

A 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 shapes
When type is pace

Minutes 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 · doubleRequired
lownumber · doubleRequired
averageAverage
Possible enum values
3s10s30slap
When type is heart_rate

Beats per minute. average: "lap" holds the lap's average.

highnumber · doubleRequired
lownumber · doubleRequired
averageAverage
Possible enum values
3s10s30slap
When type is power

Watts. average smooths it — 3s, 10s, 30s or lap.

highnumber · doubleRequired
lownumber · doubleRequired
averageAverage
Possible enum values
3s10s30slap
When type is cadence
highnumber · doubleRequired
lownumber · doubleRequired
When type is heart_rate_zone

One of the athlete's own heart-rate zones, 1–5, as set on their device.

zoneinteger · int32Required
When type is power_zone

One of the athlete's own power zones, 1–7, as set on their device.

zoneinteger · int32Required
When type is heart_rate_percent

Percent of maximum heart rate.

highnumber · doubleRequired
lownumber · doubleRequired
When type is power_percent

Percent of FTP.

highnumber · doubleRequired
lownumber · doubleRequired
When type is grade

Percent grade, for a treadmill or a smart trainer. May be negative.

highnumber · doubleRequired
lownumber · doubleRequired
When type is resistance

A trainer's resistance level.

highnumber · doubleRequired
lownumber · doubleRequired
When type is css_offset

Swim only: a pace relative to the swimmer's CSS, in seconds per 100, −60 to 60. Negative is faster.

secondsinteger · int32Required
When type is swim_instruction

Swim only: an effort in words rather than a number.

effortSwimEffortRequired
Possible enum values
recoveryvery_easyeasymoderatehardvery_hardall_outfastascenddescend
strokeStroke

What 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.

Possible enum values
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoice
targetTarget

Absent 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.

When type is active
durationstringRequired
drillDrill

Technique work over the top of Interval::stroke. Swim-only, same rule.

Possible enum values
kickpulldrillbutterfly
equipmentEquipment

What 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.

Possible enum values
finskickboardpaddlespull_buoysnorkel
namestring

Shown on the watch. Optional because most steps do not need one.

secondary_targetTarget

A 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.

strokeStroke

What 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.

Possible enum values
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoice
targetTarget

Absent 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.

When type is recovery
durationstringRequired
drillDrill

Technique work over the top of Interval::stroke. Swim-only, same rule.

Possible enum values
kickpulldrillbutterfly
equipmentEquipment

What 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.

Possible enum values
finskickboardpaddlespull_buoysnorkel
namestring

Shown on the watch. Optional because most steps do not need one.

secondary_targetTarget

A 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.

strokeStroke

What 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.

Possible enum values
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoice
targetTarget

Absent 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.

When type is rest
durationstringRequired
drillDrill

Technique work over the top of Interval::stroke. Swim-only, same rule.

Possible enum values
kickpulldrillbutterfly
equipmentEquipment

What 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.

Possible enum values
finskickboardpaddlespull_buoysnorkel
namestring

Shown on the watch. Optional because most steps do not need one.

secondary_targetTarget

A 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.

strokeStroke

What 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.

Possible enum values
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoice
targetTarget

Absent 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.

When type is cooldown
durationstringRequired
drillDrill

Technique work over the top of Interval::stroke. Swim-only, same rule.

Possible enum values
kickpulldrillbutterfly
equipmentEquipment

What 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.

Possible enum values
finskickboardpaddlespull_buoysnorkel
namestring

Shown on the watch. Optional because most steps do not need one.

secondary_targetTarget

A 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.

strokeStroke

What 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.

Possible enum values
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoice
targetTarget

Absent 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.

When type is main

The main set of a swim — Garmin's MAIN intensity, which it defines for swims only. Refused on any other sport.

durationstringRequired
drillDrill

Technique work over the top of Interval::stroke. Swim-only, same rule.

Possible enum values
kickpulldrillbutterfly
equipmentEquipment

What 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.

Possible enum values
finskickboardpaddlespull_buoysnorkel
namestring

Shown on the watch. Optional because most steps do not need one.

secondary_targetTarget

A 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.

strokeStroke

What 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.

Possible enum values
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoice
targetTarget

Absent 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.

When type is exercise

One strength exercise: "4 × 8 back squat at 80 kg, 2 min rest". Strength only. May sit inside a repeat, which makes a circuit.

namestringRequired

What 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 · int32Required

Reps in each set.

setsinteger · int32Required
notestring

A coaching cue — "pause at the bottom". Shown with the name.

rest_secondsinteger · int32

Rest after each set. Absent for none.

weight_kgnumber · double

The load, in kilograms. Absent for bodyweight.

pool_lengthPoolLength

Same 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:

POST /v1/workouts
{
  "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" } }
  ]
}
JSON
{
  "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.

JSON
"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
syncedOn the watch. external_id is the provider's own id for it.
pendingAccepted, not on the device yet. Needs nothing from you — see below.
unsupportedThis provider cannot represent this workout. Terminal; retrying changes nothing.
failedIt 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:

reasonWhat happenedWhat fixes it
not_permittedThe 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_connectedThere is no live connection to this provider any more.The athlete connects again.
unsupportedThis 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_errorThe 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_failedThe provider has the workout but did not accept the date. Status stays pending.Nothing — we retry the schedule.
JSON
{ "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.

  • open runs until the athlete presses lap.
  • yards and miles show on the watch in the unit you wrote.
  • reps is for cardio, hiit and strength workouts only.
  • heart_rate_below / _above and power_below / _above end the step when the athlete crosses the threshold: "recover until 130".
  • tss is TrainingPeaks TSS.
  • repetition_time and css_repetition_time are swim send-offs. See Send-offs.
JSON
{ "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_percent is percent of maximum heart rate. power_percent is percent of FTP.

  • grade is percent, for a treadmill or smart trainer, and may be negative.

  • average smooths pace, heart_rate or power before 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.

JSON
{ "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.

JSON
{
  "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
namerequiredWhat the exercise is. Shown on the watch.
setsrequired1–50.
repsrequiredReps in each set, 1–1,000.
weight_kgoptionalThe load in kilograms, up to 1,000. Leave it out for bodyweight.
rest_secondsoptionalRest after each set, up to 3,600. Leave it out for none.
noteoptionalA 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.

POST /v1/workouts
{
  "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.

JSON
{
  "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.

POST /v1/workouts
{
  "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.

POST /v1/workouts
{
  "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.

JSON
{ "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_offset is a pace relative to the swimmer's CSS (critical swim speed), in seconds per 100, from −60 to 60. Negative is faster.
  • swim_instruction is 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.

JSON
{ "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.

JSON
{
  "type": "repeat",
  "repeats": 8,
  "steps": [
    { "type": "active", "duration": { "type": "distance", "meters": 50 }, "stroke": "freestyle" },
    { "type": "rest",   "duration": { "type": "repetition_time", "seconds": 70 } }
  ]
}
JSON
{ "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

ProviderSwims
GarminEverything above, send-offs included.
COROSDistances, 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.
WahooNot supported; comes back unsupported.
SuuntoTimes, 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 WatchTimes, 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.

Text
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.