Stridee
Stridee 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
{
  "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:

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

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:

JSON
"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
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:

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" }
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.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.

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.

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

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 }

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.

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

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

ProviderSwims
GarminEverything above.
COROSDistances, rests and each step's name — not stroke, drill or equipment, so name your drill steps.
WahooNot 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:

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