Stridee
Stridee Docs

API reference

Every endpoint on the platform API, generated from the spec the API publishes about itself.

Base URL
https://api.stridee.com
Auth
An Ed25519 signature over every request — there is no bearer token. How to sign.
Spec
/platform/openapi.json — OpenAPI 3.1, if you would rather generate a client than read this.

List your users.

Everyone you have ever named to us, including people who were sent a link and never came back through it — those have no connections and appear nowhere in GET /v1/connections, which is the difference between the two endpoints. One row per person, most recently active first.

Paged with a cursor: send starting_after = the id of the last row you received, and keep going while has_more. Most recently active first means the order changes as your users train, so a page is a position in the list rather than a count into it — which is exactly what offset could not be.

Query parameters

FieldTypeDescription
external_user_idstring

Narrow to exactly one of your users, by your id for them. The way back from an id in your database to ours.

starting_afterstring · uuid

The id of the last user on the previous page. Omit for the first one.

limitinteger · int64

Defaults to 50, capped at 200.

Responses

200Your users, most recently active first
FieldTypeDescription
accountsRequiredAccountView[]
has_moreRequiredboolean

Whether another page exists. Keep going while it is true.

next_starting_afterstring · uuid

What to send as starting_after for the next page. null at the end.

totalinteger · int64

How many match the filter, on the first page only; null afterwards. Not the number in accounts, and not a paging control — has_more is.

EndUsersPage
{
  "accounts": [
    {
      "activities": 0,
      "connected_at": null,
      "created_at": "2026-08-05T11:44:02Z",
      "external_user_id": "user_4821",
      "id": "a2f81b60-3c47-49d5-b8e2-70a4c9f13d85",
      "last_event_at": null,
      "providers": [
        "string"
      ],
      "status": "active"
    }
  ],
  "has_more": true,
  "next_starting_after": null,
  "total": null
}
400`starting_after` is not one of your users

One user: their connections, and what has happened to them.

Connections here include revoked ones, unlike GET /v1/connections — "they disconnected COROS on Tuesday" is the answer to the question that actually gets asked.

Path parameters

FieldTypeDescription
idRequiredstring · uuid

Our id for the user, as returned by POST /v1/connect

Responses

200The user
FieldTypeDescription
connectionsRequiredConnectionView[]

Every connection, including revoked ones. "They disconnected COROS on Tuesday" is the answer to the support question that actually gets asked, and a list that dropped the row would show an athlete who appears never to have connected anything.

created_atRequiredstring · date-time
eventsRequiredEventView[]

Their recent events, newest first — the same rows the stream page shows, narrowed to this person.

external_user_idRequiredstring
idRequiredstring · uuid
AccountDetail
{
  "connections": [
    {
      "connected_at": "2026-08-05T11:44:02Z",
      "external_user_id": "user_4821",
      "id": "a2f81b60-3c47-49d5-b8e2-70a4c9f13d85",
      "provider": "coros",
      "revoked_at": null,
      "scope": "workout",
      "status": "active",
      "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
    }
  ],
  "created_at": "2026-08-05T11:44:02Z",
  "events": [
    {
      "created_at": "2026-08-05T11:44:02Z",
      "data": "string",
      "delivered": 0,
      "deliveries": 0,
      "end_user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6",
      "id": "a2f81b60-3c47-49d5-b8e2-70a4c9f13d85",
      "manual": true,
      "provider": "coros",
      "type": "string"
    }
  ],
  "external_user_id": "user_4821",
  "id": "a2f81b60-3c47-49d5-b8e2-70a4c9f13d85"
}
404No such user on this account

Change your id for one of your users.

For when your own user table was re-keyed and the id you linked somebody under is not the id you have any more. Our user_id does not change, so no connection moves, no activity grant is affected, no event is rewritten, and nobody goes back through consent. The next POST /v1/connect with the new id finds this person rather than creating a second one.

This is a rename, not a merge. If another of your users already goes by the id you send, you get a 409 and nothing changes — deciding what should happen to two sets of connections at the same provider is not a guess this API is willing to make on your behalf.

If what you actually want is to stop referring to someone at all, that is DELETE /v1/accounts/{id}.

Path parameters

FieldTypeDescription
idRequiredstring · uuid

Our id for the user, as returned by POST /v1/connect

Request body

FieldTypeDescription
external_user_idRequiredstring

What you want to call this person from now on.

RenameEndUserRequest · application/json
{
  "external_user_id": "user_4821"
}

Responses

200Renamed
FieldTypeDescription
created_atRequiredstring · date-time
external_user_idRequiredstring

Yours, as it now stands.

idRequiredstring · uuid

Ours. Unchanged by a rename — that is the point of one.

updated_atRequiredstring · date-time
AccountRef
{
  "created_at": "2026-08-05T11:44:02Z",
  "external_user_id": "user_4821",
  "id": "a2f81b60-3c47-49d5-b8e2-70a4c9f13d85",
  "updated_at": "2026-08-05T11:44:02Z"
}
400Empty or over-long `external_user_id`
403This account's users cannot be renamed
404No such user on this account
409Another of your users already has that id

Stop referring to one of your users.

This does not delete a person, and it cannot. What it deletes is your reference to them: your id for them, their authorizations through you, the consents behind those, any outstanding management link, their event history on your stream, and your permission to read the activities they shared with you. Their workouts are not yours and stay exactly where they are — as does every other developer's separate reference to the same athlete, which you could never see and still cannot.

Live connections are revoked first, so the athlete's grant at the provider is withdrawn on the way out under the usual rule: only if this was their last connection through Stridee, since the same person may use another product that also integrates with us.

You get one account.deleted event rather than an account.disconnected per connection — a handler seeing four disconnections and then silence cannot tell that from someone who merely unplugged everything. It carries the ids in data and has no user_id of its own, because the user it would name no longer exists.

Afterwards the id is free. POST /v1/connect with the same external_user_id creates a new person with a new user_id, connected to nothing, and none of the old history comes back.

Idempotent in the only sense that matters: deleting someone already deleted is a 404.

Path parameters

FieldTypeDescription
idRequiredstring · uuid

Our id for the user, as returned by POST /v1/connect

Responses

204Forgotten
403This account's users cannot be deleted
404No such user on this account

List the activities you have been granted.

Every workout one of your users was connected to you for, newest first — the same rows that were announced to your endpoint as activity.created, in the same shape. This is the reconciliation path: if a delivery was missed, this is how you find it, and file.url is the same download URL the event carried.

Page against received_at, not start_time. Providers backfill and correct, so a workout from March can arrive today; the order here is the order you were told, which is the only one that is monotonic. since and until window that same axis, half-open, so a nightly job can ask for since=<last run> and get exactly what it has not seen.

Paged with a cursor: send starting_after = the id of the last row you received, and keep going while has_more. next_starting_after is that id, so a loop never has to reach into the array. A cursor names a row rather than a distance from one, so activities arriving while you page cannot shift the boundary under you — which an offset would, and did.

Access is the grant that was recorded when each activity arrived — the same rule GET /v1/activities/{id}/file applies. Disconnecting stops new activities; it does not remove the ones from the period your user had agreed to share, so they stay listed here. An activity whose provider offered no downloadable file has "file": null, and so does one whose bytes have not landed yet.

Query parameters

FieldTypeDescription
user_idstring · uuid

Narrow to one athlete, by our id for them — what POST /v1/connect returned and what events carry as user_id. GET /v1/accounts is the way back from your own id to this one.

providerstring

coros | polar | wahoo | garmin | zepp | hammerhead | fitbit. An unknown provider is an empty page, not a 400.

sincestring

Only activities granted at or after this RFC 3339 instant.

untilstring

Only activities granted strictly before it. Half-open, so one window's until is the next one's since and nothing is counted twice.

starting_afterstring · uuid

The id of the last activity on the previous page. Omit for the first one.

limitinteger · int64

Defaults to 50, capped at 200.

Responses

200Activities you may read, newest first
FieldTypeDescription
activitiesRequiredActivityView[]
has_moreRequiredboolean

Whether another page exists. The loop is while has_more, and it is exact: it comes from a row we fetched and discarded rather than from arithmetic on a count.

next_starting_afterstring · uuid

What to send as starting_after for the next page — the last row's id. null when has_more is false.

totalinteger · int64

How many match the filter, on the first page only; null on every page after it.

Not a paging control — has_more is. This is the number a table puts above itself, it does not change as you page, and counting a quarter of a million grants again per page is a cost with no reader.

ActivitiesPage
{
  "activities": [
    {
      "device": null,
      "external_user_id": "user_4821",
      "file": "string",
      "id": "a2f81b60-3c47-49d5-b8e2-70a4c9f13d85",
      "name": null,
      "provider": "coros",
      "provider_activity_id": "string",
      "received_at": "2026-08-05T11:44:02Z",
      "sport": null,
      "start_time": null,
      "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
    }
  ],
  "has_more": true,
  "next_starting_after": null,
  "total": null
}
400A malformed `since`/`until`, or a `starting_after` that is not an activity of yours

Download the raw file for an activity.

The data.file.url on an activity.created points here. It is an ordinary signed request — sign it exactly the way you sign every other call — and it answers 302 with a short-lived URL that needs no credential of its own. Follow the redirect; do not store what it points at. That URL expires in minutes, while the one in the event body does not, so keep the event's and call this again whenever you need the bytes.

What you get is what the device wrote: the provider's own FIT, TCX or GPX, unmodified and unparsed. data.file.format on the event tells you which, and the redirect target carries the matching content type.

Access is the grant that was recorded when the activity arrived, so this answers for exactly the workouts your user was connected to you for. Disconnecting stops new activities; it does not take away files from the period they had agreed to share. An activity whose provider offered no downloadable file — "file": null on the event — has nothing to serve and answers 404.

Path parameters

FieldTypeDescription
idRequiredstring · uuid

The activity, as data.id on activity.created

Responses

302Redirect to a short-lived download URL
404No such activity on your account, or it has no file

Start a link.

Request body

FieldTypeDescription
external_user_idRequiredstring

Your id for this person, from your own database.

The only identifier you have to send, and the only one you have to store. Call this twice for the same value and you get the same user back rather than a second one — which is what makes retrying safe.

providerRequiredstring

coros, polar, wahoo, garmin, zepp, hammerhead or fitbit. A provider this deployment has no credentials for is a 400, not a 404 — the path exists, the argument does not.

zepp, hammerhead and fitbit are the ones that may answer that way on a healthy deployment. Zepp's credentials come from a partner agreement with Huami rather than a self-serve signup, so an API that has not been issued any cannot start the flow and says so. Hammerhead's are self-serve but new enough that not every deployment has been given them yet, and Fitbit's likewise.

return_uristring

Where to send their browser when consent is finished, with status and user_id appended as query parameters. status is success, denied or error.

Must be registered on your account first (POST /platform/redirect-uris). Omit it and your user lands on a plain page we host, which tells them the connection succeeded and nothing else — register a URI if you would rather they end up back in your product.

ConnectRequest · application/json
{
  "external_user_id": "user_4821",
  "provider": "coros",
  "return_uri": "https://app.yourapp.com/settings/devices"
}

Responses

200A URL to send the user to
FieldTypeDescription
connect_urlRequiredstring

Send your user here. Redirect their browser — this is a consent screen a person has to read, not something to fetch server-side.

It is a page we host, not the provider's authorize endpoint. It names you, names us, and carries our terms, because your user is about to authorize their watch data flowing through infrastructure they have never heard of and every provider agreement behind this API assumes they were told. One click later they are at the provider, and the rest of the flow is as it was.

The name your user reads on it is the one on your Stridee account. Without one the page still works and simply names no product.

expires_atRequiredstring · date-time

When connect_url stops working. Mint a fresh one rather than caching this — it is a single-use URL, not a per-user constant.

user_idRequiredstring · uuid

Our id for the person you named. Stable across every call for the same external_user_id, and what connection rows and events refer to. You do not have to store it.

ConnectResponse
{
  "connect_url": "https://api.stridee.com/connections/start?state=…",
  "expires_at": "2026-08-05T11:44:02Z",
  "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}
400Unknown provider, bad user id, or an unregistered return URI

List connections.

Live authorizations, newest first — one row per watch rather than per person, so somebody who linked Garmin and COROS is two.

This is a page. It used to be every connection on the account in one response, which is fine at ten and is several megabytes at ten thousand. Send starting_after = the id of the last row you received and keep going while has_more; next_starting_after carries that id for you. A revoked connection is not in this list and cannot be a cursor into it.

Query parameters

FieldTypeDescription
external_user_idstring

Narrow to one of your users. Omit for every connection on the account.

starting_afterstring · uuid

The id of the last connection on the previous page. Omit for the first one.

limitinteger · int64

Defaults to 50, capped at 200.

Responses

200Live connections, newest first
FieldTypeDescription
connectionsRequiredConnectionView[]
has_moreRequiredboolean

Whether another page exists. Keep going while it is true.

next_starting_afterstring · uuid

What to send as starting_after for the next page. null at the end.

totalinteger · int64

How many live connections match, on the first page only; null afterwards. has_more is the paging control — this is the count a screen puts above a table.

ConnectionsPage
{
  "connections": [
    {
      "connected_at": "2026-08-05T11:44:02Z",
      "external_user_id": "user_4821",
      "id": "a2f81b60-3c47-49d5-b8e2-70a4c9f13d85",
      "provider": "coros",
      "revoked_at": null,
      "scope": "workout",
      "status": "active",
      "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
    }
  ],
  "has_more": true,
  "next_starting_after": null,
  "total": null
}
400`starting_after` is not a live connection of yours

Disconnect.

The connection stops working immediately and its tokens are dropped.

Your access ends here. The athlete's authorization at the provider is only withdrawn if this was their last connection through Stridee — the same person may use two products that both integrate with us, and revoking the grant on your behalf would take the other one down without either of you knowing.

Idempotent: disconnecting something already disconnected is a 404, not a second revocation.

Path parameters

FieldTypeDescription
idRequiredstring · uuid

The connection to disconnect, as returned by GET /v1/connections

Responses

204Disconnected
404No such connection on this account

List the health summaries you have been granted.

Every sleep, daily, HRV, stress and fitness record one of your users was connected to you for, newest first — the same rows that were announced to your endpoint as wellness.created, in the same shape. This is the reconciliation path: if a delivery was missed, this is how you find it.

Two different axes, and they answer different questions. calendar_date (with from_date/to_date) is what the measurement is about, and it is what you want for "show me last week's sleep". received_at (with since/until) is when we told you, it is what this listing is ordered on, and it is the only one that is monotonic — providers backfill and correct, so a night from March can arrive today. Page and reconcile on received_at; query and display on calendar_date.

Summaries change. Unlike an activity, a measurement can be revised after you are told about it: Garmin sends a night tentatively and settles it hours later. revision starts at 1 and increments whenever the payload actually changed, and a revision arrives as wellness.updated rather than a second wellness.created. Upsert on id and take the higher revision.

summary is the whole provider payload except its time series. The minute-by-minute data — heart-rate samples, sleep stages, the stress curve — is behind series_url, because it is around ninety percent of the bytes and almost nothing needs it. A row whose series_url is null has none at all.

Access is the grant that was recorded when each summary arrived. Disconnecting stops new ones; it does not remove those from the period your user had agreed to share.

Query parameters

FieldTypeDescription
user_idstring · uuid

Narrow to one athlete, by our id for them — what POST /v1/connect returned and what events carry as user_id.

providerstring

garmin | coros. An unknown provider is an empty page, not a 400.

kindstring

daily | sleep | hrv | stress | fitness. Unlike provider, an unknown value here is a 400 — see the note on the parser.

from_datestring

Only summaries for this calendar day or later. Inclusive.

to_datestring

Only summaries for this calendar day or earlier. Inclusive, unlike until — a developer asking for a month means its last day included.

sincestring

Only summaries granted at or after this RFC 3339 instant.

untilstring

Only summaries granted strictly before it. Half-open, so one window's until is the next one's since and nothing is counted twice.

starting_afterstring · uuid

The id of the last summary on the previous page. Omit for the first one.

limitinteger · int64

Defaults to 50, capped at 200.

Responses

200Summaries you may read, newest first
FieldTypeDescription
has_moreRequiredboolean

Whether another page exists. Exact — it comes from a row we fetched and discarded rather than from arithmetic on a count.

wellnessRequiredWellnessView[]
next_starting_afterstring · uuid

What to send as starting_after for the next page. null when has_more is false.

totalinteger · int64

How many match the filter, on the first page only; null after it.

WellnessPage
{
  "has_more": true,
  "next_starting_after": null,
  "total": null,
  "wellness": [
    {
      "calendar_date": "string",
      "duration_seconds": null,
      "external_user_id": "user_4821",
      "finality": null,
      "id": "a2f81b60-3c47-49d5-b8e2-70a4c9f13d85",
      "kind": "string",
      "metrics": {
        "active_kilocalories": null,
        "active_time_seconds": null,
        "avg_hr": null,
        "avg_stress_level": null,
        "awake_seconds": null,
        "bmr_kilocalories": null,
        "body_battery": null,
        "body_battery_charged": null,
        "body_battery_drained": null,
        "body_fat_percent": null,
        "body_mass_index": null,
        "body_water_percent": null,
        "bone_mass_grams": null,
        "deep_sleep_seconds": null,
        "diastolic": null,
        "distance_meters": null,
        "epoch_activity_type": null,
        "epoch_intensity": null,
        "fitness_age": null,
        "fitness_age_enhanced": null,
        "floors_climbed": null,
        "hrv_avg": null,
        "hrv_rmssd": null,
        "hrv_sdrr": null,
        "light_sleep_seconds": null,
        "max_hr": null,
        "met": null,
        "min_hr": null,
        "moderate_intensity_seconds": null,
        "muscle_mass_grams": null,
        "nap_seconds": null,
        "pulse": null,
        "rem_sleep_seconds": null,
        "respiration_avg": null,
        "respiration_max": null,
        "respiration_min": null,
        "resting_hr": null,
        "skin_temp_celsius": null,
        "skin_temp_deviation_celsius": null,
        "sleep_score": null,
        "spo2_avg": null,
        "spo2_min": null,
        "spo2_on_demand": null,
        "steps": null,
        "systolic": null,
        "total_sleep_seconds": null,
        "vigorous_intensity_seconds": null,
        "vo2_max": null,
        "vo2_max_cycling": null,
        "weight_grams": null
      },
      "provider": "coros",
      "provider_summary_id": null,
      "received_at": "2026-08-05T11:44:02Z",
      "revised_at": null,
      "revision": 0,
      "series_url": null,
      "start_time": "2026-08-05T11:44:02Z",
      "summary": "string",
      "tz_offset_seconds": null,
      "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
    }
  ]
}
400A malformed date/timestamp, an unknown `kind`, or a `starting_after` that is not a summary of yours

Fetch the time series behind one summary.

The minute-level data the listing deliberately leaves out: Garmin's heart-rate samples on a daily, the sleep-stage map and respiration and SpO₂ traces on a sleep, the stress curve and body-battery events on a stress, the beat-to-beat values on an hrv. Returned as the provider wrote it, unmodified — the keys are theirs.

It is a separate call because it is around ninety percent of the payload's bytes and most integrations never draw a curve. A fitness summary has none at all, and neither does anything from COROS; those rows carry "series_url": null and this answers 404 for them.

One 404 covers every way this can fail — someone else's summary, an id that never existed, and one with no series — for the reason GET /v1/activities/{id}/file gives: these ids travel in webhook bodies and a status code that confirms an id exists is an oracle somebody can walk.

Path parameters

FieldTypeDescription
idRequiredstring · uuid

The summary, as data.id on wellness.created

Responses

200The provider's time series, unmodified
object
{}
404No such summary on your account, or it has no series

The endpoint a client is pointed at while it is being written.

It exists because the first signed request anybody makes is always wrong, and the failures are hard to tell apart from outside: a clock, a stale key, a @target-uri built with the wrong scheme, a body digested after a framework re-encoded it. All four look like "401" against a real endpoint. Here the only thing being tested is the signature, so the error is about the signature — and a 200 means the client is correct, not that this particular call happened to be.

It reads nothing and changes nothing, so it is also the one endpoint safe to leave in a customer's health check.

Responses

200The account this signature belongs to
FieldTypeDescription
account_idRequiredstring · uuid

The account the signature authenticated as.

Whoami
{
  "account_id": "0b6f2d14-59c3-4e7a-8d05-1af38b62c9e7"
}
401The signature was missing, stale, replayed or wrong

The workouts on this account, newest first.

Ordered and paged on when the workout was created, not when it is scheduled: the scheduled date is content, it can be edited or absent, and a cursor over a value that moves either serves rows twice or steps over them. from and to still filter on it, because "next week" is the question a calendar asks.

Query parameters

FieldTypeDescription
user_idstring · uuid

One athlete, by our id for them.

fromstring · date

Scheduled on or after this day, YYYY-MM-DD.

tostring · date

Scheduled on or before this day. Inclusive — a calendar day is a unit you name, not a boundary between windows.

starting_afterstring · uuid

The id of the last workout on the previous page.

limitinteger · int64

Defaults to 50, capped at 200.

Responses

200Workouts, newest first
FieldTypeDescription
has_moreRequiredboolean

Whether another page exists. Exact — it comes from a row we fetched and discarded, not from arithmetic on a count.

workoutsRequiredWorkoutView[]
next_starting_afterstring · uuid

What to send as starting_after next. null when has_more is false.

totalinteger · int64

How many match the filter, on the first page only.

WorkoutsPage
{
  "has_more": true,
  "next_starting_after": null,
  "total": null,
  "workouts": [
    {
      "created_at": "2026-08-05T11:44:02Z",
      "id": "a2f81b60-3c47-49d5-b8e2-70a4c9f13d85",
      "name": "string",
      "pool_length": "string",
      "pushes": [
        {
          "error": null,
          "external_id": null,
          "provider": "coros",
          "pushed_at": null,
          "reason": null,
          "status": "active"
        }
      ],
      "scheduled_date": null,
      "sport": "string",
      "steps": "string",
      "updated_at": "2026-08-05T11:44:02Z",
      "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
    }
  ]
}
400A `starting_after` that is not a workout of yours

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.

Request body

FieldTypeDescription
nameRequiredstring
sportRequiredSport
stepsRequiredStep[]
user_idRequiredstring · uuid

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

pool_lengthunknown
providerstring

Which provider to push to. 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.

scheduled_datestring · date

The day this workout is for. Omit to leave it in the athlete's library without a calendar entry.

CreateWorkoutBody · application/json
{
  "name": "string",
  "pool_length": "string",
  "provider": "coros",
  "scheduled_date": null,
  "sport": {},
  "steps": [
    {}
  ],
  "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}

Responses

201Created, with one entry per provider attempted
FieldTypeDescription
created_atRequiredstring · date-time
idRequiredstring · uuid
nameRequiredstring
pushesRequiredPushView[]
sportRequiredstring
updated_atRequiredstring · date-time
user_idRequiredstring · uuid
pool_lengthunknown
scheduled_datestring · date
stepsunknown

The steps as you sent them.

Absent — rather than wrong — for a workout whose stored steps are not expressible in this contract. That cannot happen to a workout you authored; it is here because the same table holds first-party strength sessions, which are exercise blocks rather than intervals, and inventing an interval for one of those would be worse than omitting it.

WorkoutView
{
  "created_at": "2026-08-05T11:44:02Z",
  "id": "a2f81b60-3c47-49d5-b8e2-70a4c9f13d85",
  "name": "string",
  "pool_length": "string",
  "pushes": [
    {
      "error": null,
      "external_id": null,
      "provider": "coros",
      "pushed_at": null,
      "reason": null,
      "status": "active"
    }
  ],
  "scheduled_date": null,
  "sport": "string",
  "steps": "string",
  "updated_at": "2026-08-05T11:44:02Z",
  "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}
404No such user
409That user has no connected device that can receive workouts
422The workout is not valid — the body names the step
429Too many device pushes in the last minute

One workout, and where it got to.

Path parameters

FieldTypeDescription
idRequiredstring · uuid

The workout id

Responses

200The workout
FieldTypeDescription
created_atRequiredstring · date-time
idRequiredstring · uuid
nameRequiredstring
pushesRequiredPushView[]
sportRequiredstring
updated_atRequiredstring · date-time
user_idRequiredstring · uuid
pool_lengthunknown
scheduled_datestring · date
stepsunknown

The steps as you sent them.

Absent — rather than wrong — for a workout whose stored steps are not expressible in this contract. That cannot happen to a workout you authored; it is here because the same table holds first-party strength sessions, which are exercise blocks rather than intervals, and inventing an interval for one of those would be worse than omitting it.

WorkoutView
{
  "created_at": "2026-08-05T11:44:02Z",
  "id": "a2f81b60-3c47-49d5-b8e2-70a4c9f13d85",
  "name": "string",
  "pool_length": "string",
  "pushes": [
    {
      "error": null,
      "external_id": null,
      "provider": "coros",
      "pushed_at": null,
      "reason": null,
      "status": "active"
    }
  ],
  "scheduled_date": null,
  "sport": "string",
  "steps": "string",
  "updated_at": "2026-08-05T11:44:02Z",
  "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}
404No such workout

Replace a workout and re-push it.

Providers that can update in place do — Garmin keeps the same workoutId, so the athlete's calendar entry shows the new content rather than gaining a duplicate beside it.

This re-pushes to every capable connected provider, including ones this workout never reached. That is deliberate and it is what the first-party app does: a workout created before your user connected their watch should land on it when they edit, rather than staying invisible forever because the original push had nowhere to go.

Path parameters

FieldTypeDescription
idRequiredstring · uuid

The workout id

Request body

FieldTypeDescription
nameRequiredstring
sportRequiredSport
stepsRequiredStep[]
pool_lengthunknown
scheduled_datestring · date

Settable to null, unlike on create: that is how a workout comes off the calendar and stays in the library.

UpdateWorkoutBody · application/json
{
  "name": "string",
  "pool_length": "string",
  "scheduled_date": null,
  "sport": {},
  "steps": [
    {}
  ]
}

Responses

200Updated and re-pushed
FieldTypeDescription
created_atRequiredstring · date-time
idRequiredstring · uuid
nameRequiredstring
pushesRequiredPushView[]
sportRequiredstring
updated_atRequiredstring · date-time
user_idRequiredstring · uuid
pool_lengthunknown
scheduled_datestring · date
stepsunknown

The steps as you sent them.

Absent — rather than wrong — for a workout whose stored steps are not expressible in this contract. That cannot happen to a workout you authored; it is here because the same table holds first-party strength sessions, which are exercise blocks rather than intervals, and inventing an interval for one of those would be worse than omitting it.

WorkoutView
{
  "created_at": "2026-08-05T11:44:02Z",
  "id": "a2f81b60-3c47-49d5-b8e2-70a4c9f13d85",
  "name": "string",
  "pool_length": "string",
  "pushes": [
    {
      "error": null,
      "external_id": null,
      "provider": "coros",
      "pushed_at": null,
      "reason": null,
      "status": "active"
    }
  ],
  "scheduled_date": null,
  "sport": "string",
  "steps": "string",
  "updated_at": "2026-08-05T11:44:02Z",
  "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}
404No such workout
409That user has no connected device that can receive workouts
422The workout is not valid — the body names the step
429Too many device pushes in the last minute

Delete a workout and take it off the athlete's devices.

The provider objects go first, because the rows are the only record of what to remove — dropping them first would leave a workout on a watch with nothing left that knows about it. A provider that refuses the delete does not stop ours: the alternative is a workout nobody can remove because one provider is having a bad day.

Path parameters

FieldTypeDescription
idRequiredstring · uuid

The workout id

Responses

204Deleted
404No such workout

Something wrong or missing on this page? Tell us in Discord. Need something the API doesn’t do yet? Request it on the roadmap.