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.
{
"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" } }
]
}You get back the workout and one entry per provider that was tried:
{
"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
Only Garmin, Wahoo and COROS 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.
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:
"pushes": [
{ "provider": "garmin", "status": "synced", "external_id": "1284410973" },
{ "provider": "wahoo", "status": "unsupported",
"error": "Wahoo structured plans cover running and cycling, not 'swimming'" }
]You only get a non-2xx when nothing could have worked: a user who is not yours, a provider they have not connected, or a workout that does not validate.
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:
{ "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" }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. | 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. |
pending is normal, and it is Wahoo
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.
Editing
PATCH /v1/workouts/{id} takes the whole workout, not a partial one. It updates in place
where the provider can (Garmin keeps its id, so no duplicate appears) and re-pushes to every
capable connected provider — including one your user connected after the first push.
DELETE /v1/workouts/{id} removes it from the providers, then from us, and answers 204
even if one provider refuses. COROS only deletes workouts dated today or later that haven't
been done.
The step format
A workout is a list of steps. A step is either an interval or a repeat of intervals.
Intervals are typed by their role — warmup, active, recovery, rest, cooldown —
which is what the watch shows and how it colours the screen.
Duration
Every interval ends one of four ways. Seconds and metres, not minutes and kilometres.
{ "type": "time", "seconds": 600 }
{ "type": "distance", "meters": 1000 }
{ "type": "reps", "count": 12 }
{ "type": "open" }open runs until the athlete presses lap.
Target
Optional. Absent means an untargeted step — a warm-up, a float, a rest.
{ "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 }Always a band. 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.
Sports
running, cycling, swimming, hiking. Not every provider takes every one — a swim is
unsupported on Wahoo — and the pushes array tells you which did.
A swim carries four fields the others do not, and its targets behave differently. See Swimming.
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.
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 treats a rest as a send-off rather than a timer. 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
{ "unit": "meters", "length": 25 }
{ "unit": "yards", "length": 25 }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, 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 still metres —
"meters": 100 is 100 m in a yard pool too.
stroke — per step, optional
freestyle, backstroke, breaststroke, butterfly, individual_medley,
reverse_individual_medley (IM backwards), mixed, choice.
Omitted means no stroke was prescribed; choice means "swimmer's choice", and the watch
shows it.
drill — per step, optional
kick, pull, butterfly.
A drill layers over a stroke: freestyle + pull shows as Free Pull. Without a stroke it
applies to whatever the swimmer is doing.
equipment — per step, optional
fins, kickboard, paddles, pull_buoy, snorkel.
For no equipment, omit the field.
Targets in the pool
pace and heart_rate only. Both work, and both need pool_length set.
power and cadence are accepted but dropped for a swim — no pool sensor measures them.
Rests are send-offs
Write rests normally. In a swim they become fixed rests at the wall, and the last rest of a repeat block is dropped.
Where the swim fields are refused
All four are swim-only. Sending one on a run is a 422, not a field we quietly drop.
What reaches each provider
| Provider | Swims |
|---|---|
| Garmin | Everything above. |
| COROS | Distances, rests and each step's name — not stroke, drill or equipment, so name your drill steps. |
| Wahoo | Not supported; comes back unsupported. |
Rate limit
60 device pushes a minute per account — one workout to a user with three watches is three.
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:
steps[1].steps[0].target: `low` must not be greater than `high`Unknown fields are rejected too, so a typo is a 422 rather than a silently untargeted step
on somebody's watch.
Something wrong or missing on this page? Tell us in Discord. Need something the API doesn’t do yet? Request it on the roadmap.