Stridee
Stridee Platform Docs

API reference

The Stridee Platform API is organised around resources: the connections your users make to their devices, the users themselves, and what their watches record — plus the workouts and routes you push back. Bodies are JSON, and every endpoint here is generated from the spec the API publishes about itself.

There is no bearer token. Every request carries an Ed25519 signature over the request itself (RFC 9421). How to sign. Lists page with a cursor — Paging; status codes and limits — Errors & rate limits.

Base URL
https://api.stridee.com
OpenAPI spec

/platform/openapi.json — OpenAPI 3.1, if you would rather generate a client than read this.

What a developer is allowed to see about a connection.

What it leaves out is the point of the type: no access_token, no refresh_token, and no provider_user_id.

The tokens are obvious. provider_user_id is the one worth stating: it is the provider's identifier for a human being — a COROS openId, a Polar x_user_id, a Wahoo or Garmin user id — we obtained it because that human stood in front of a consent screen we hosted, and handing it to a developer would let two tenants who both serve that person correlate their users against each other. They asked to sync workouts, not to be joined on.

Attributes

connected_atstring · date-time

When consent finished. Not when the row was created: a reconnection after a revoke moves this forward, because the question it answers is "since when have we had access", and the old answer stopped being true.

external_user_idstring

The developer's own id for this person — echoed back so a list response is legible against their database without a second lookup.

idstring · uuid

What DELETE /v1/connections/{id} takes. Ours, and the id the platform's events name in connection_id.

providerstring

Which integration this authorization is at — coros, polar, wahoo or garmin.

statusstring

active | reauth_required | revoked.

reauth_required is the one to handle: the provider has stopped accepting our credentials, no more data will arrive, and the only fix is sending the athlete back through POST /v1/connect.

user_idstring · uuid

Our id for the person this authorization belongs to — what POST /v1/connect returned and what every event about them carries as user_id.

Here so that throwing it away stays as harmless as the docs promise. It is the id /v1/accounts/{id} takes, and without it on this response a developer who did not persist the connect response would have no way back to it. Safe to expose beside external_user_id: it is minted per tenant, so unlike provider_user_id it correlates nobody with anybody.

revoked_atstring · date-timenullable

Always null on GET /v1/connections, which lists live connections only. The field is here because a revoked connection is still a connection this type can describe, and a shape that changed depending on the endpoint would be two types wearing one name.

scopestringnullable

What the athlete consented to, as the provider worded it. Absent when the provider returned none — not every one of them says.

The Connection object
{
  "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"
}

Start a link

post /v1/connect

Returns a connect_url to send one of your users to. They read a consent page we host, approve at the provider, and land back on your return_uri; the account.connected event is what tells your backend it worked. You hold no provider credentials and implement no OAuth callback.

connect_url stops working after 30 minutes, so mint one per attempt rather than storing it. Retrying is safe: the same external_user_id always resolves to the same user_id.

Body parameters

external_user_idstringRequired

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.

providerstringRequired

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

zepp, hammerhead, fitbit, suunto and whoop 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 Suunto's and WHOOP's likewise.

langstring

The language of the pages we host for this user: the consent screen, and the confirmation after it when you pass no return_uri.

One of en, de, da, nb, sv, pl, cs, sk, es, zh, zh-Hant, ko or ja. Region subtags are ignored, so your user's device locale can be passed as it is — de-AT is German, es_MX is Spanish, and no is read as nb. Chinese is the exception: zh-Hant, zh-TW, zh-HK and zh-MO are Traditional, and every other zh tag is Simplified.

Omit it and the pages follow your user's browser language, falling back to English. Pass it when you know better than their browser does — it always wins. A language we have not translated yet is treated as omitted rather than a 400, so passing every user's locale is safe today and picks up new languages as they ship.

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). A mobile app can pass its own scheme, com.example.app:/callback, so the browser sheet it opened connect_url in closes back into the app. 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.

Returns

200A URL to send the user to
Show ConnectResponse attributes
connect_urlstring

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_atstring · 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_idstring · 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.

400Unknown provider, bad user id, or an unregistered return URI
POST /v1/connect
POST /v1/connect HTTP/1.1
Host: api.stridee.com
Content-Type: application/json
Content-Digest: sha-256=:…:
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:

{
  "external_user_id": "user_4821",
  "lang": "de",
  "provider": "coros",
  "return_uri": "https://app.yourapp.com/settings/devices"
}
Response · 200
{
  "connect_url": "https://api.stridee.com/connections/start?state=…",
  "expires_at": "2026-08-05T11:44:02Z",
  "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}

List connections

get /v1/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

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.

Returns

200Live connections, newest first

A page of Connection objects.

Show ConnectionsPage attributes
connectionsConnectionView[]
Show child attributes
connected_atstring · date-time

When consent finished. Not when the row was created: a reconnection after a revoke moves this forward, because the question it answers is "since when have we had access", and the old answer stopped being true.

external_user_idstring

The developer's own id for this person — echoed back so a list response is legible against their database without a second lookup.

idstring · uuid

What DELETE /v1/connections/{id} takes. Ours, and the id the platform's events name in connection_id.

providerstring

Which integration this authorization is at — coros, polar, wahoo or garmin.

statusstring

active | reauth_required | revoked.

reauth_required is the one to handle: the provider has stopped accepting our credentials, no more data will arrive, and the only fix is sending the athlete back through POST /v1/connect.

user_idstring · uuid

Our id for the person this authorization belongs to — what POST /v1/connect returned and what every event about them carries as user_id.

Here so that throwing it away stays as harmless as the docs promise. It is the id /v1/accounts/{id} takes, and without it on this response a developer who did not persist the connect response would have no way back to it. Safe to expose beside external_user_id: it is minted per tenant, so unlike provider_user_id it correlates nobody with anybody.

revoked_atstring · date-timenullable

Always null on GET /v1/connections, which lists live connections only. The field is here because a revoked connection is still a connection this type can describe, and a shape that changed depending on the endpoint would be two types wearing one name.

scopestringnullable

What the athlete consented to, as the provider worded it. Absent when the provider returned none — not every one of them says.

has_moreboolean

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

next_starting_afterstring · uuidnullable

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

totalinteger · int64nullable

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.

400`starting_after` is not a live connection of yours
GET /v1/connections
GET /v1/connections HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 200
{
  "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": false,
  "next_starting_after": null,
  "total": 1
}

Disconnect

delete /v1/connections/{id}

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

idstring · uuidRequired

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

Returns

204Disconnected
404No such connection on this account
DELETE /v1/connections/{id}
DELETE /v1/connections/a2f81b60-3c47-49d5-b8e2-70a4c9f13d85 HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 204
HTTP/1.1 204 No Content

Attributes

activitiesinteger · int64

activity.created events about them — what we have told this developer arrived, which is the number they can reconcile against their own database.

created_atstring · date-time

When they were first named to us.

external_user_idstring

The developer's own id for them. The only name we have: nobody here ever signed up with us, so there is no display name to show and none is invented.

idstring · uuid

Our id for this person — what POST /v1/connect returned, and what every event about them carries as user_id.

providersstring[]

Their live connections' providers. Empty when they have none — either they never finished consent, or every link has been revoked.

statusstring

active | reauth_required | revoked | pending, rolled up across their connections. pending is somebody who was sent a link and has not come back through it.

connected_atstring · date-timenullable

Consent on their most recent live connection. null when there is none.

last_event_atstring · date-timenullable

The newest event about them, of any type. null for someone nothing has happened to yet.

The Account object
{
  "activities": 0,
  "connected_at": null,
  "created_at": "2026-08-05T11:44:02Z",
  "external_user_id": "user_4821",
  "id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6",
  "last_event_at": null,
  "providers": [
    "string"
  ],
  "status": "active"
}

List your users

get /v1/accounts

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

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.

Returns

200Your users, most recently active first

A page of Account objects.

Show EndUsersPage attributes
accountsAccountView[]
Show child attributes
activitiesinteger · int64

activity.created events about them — what we have told this developer arrived, which is the number they can reconcile against their own database.

created_atstring · date-time

When they were first named to us.

external_user_idstring

The developer's own id for them. The only name we have: nobody here ever signed up with us, so there is no display name to show and none is invented.

idstring · uuid

Our id for this person — what POST /v1/connect returned, and what every event about them carries as user_id.

providersstring[]

Their live connections' providers. Empty when they have none — either they never finished consent, or every link has been revoked.

statusstring

active | reauth_required | revoked | pending, rolled up across their connections. pending is somebody who was sent a link and has not come back through it.

connected_atstring · date-timenullable

Consent on their most recent live connection. null when there is none.

last_event_atstring · date-timenullable

The newest event about them, of any type. null for someone nothing has happened to yet.

has_moreboolean

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

next_starting_afterstring · uuidnullable

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

totalinteger · int64nullable

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.

400`starting_after` is not one of your users
GET /v1/accounts
GET /v1/accounts HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 200
{
  "accounts": [
    {
      "activities": 0,
      "connected_at": null,
      "created_at": "2026-08-05T11:44:02Z",
      "external_user_id": "user_4821",
      "id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6",
      "last_event_at": null,
      "providers": [
        "string"
      ],
      "status": "active"
    }
  ],
  "has_more": false,
  "next_starting_after": null,
  "total": 1
}

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

idstring · uuidRequired

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

Returns

200The user
Show AccountDetail attributes
connectionsConnectionView[]

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.

Show child attributes
connected_atstring · date-time

When consent finished. Not when the row was created: a reconnection after a revoke moves this forward, because the question it answers is "since when have we had access", and the old answer stopped being true.

external_user_idstring

The developer's own id for this person — echoed back so a list response is legible against their database without a second lookup.

idstring · uuid

What DELETE /v1/connections/{id} takes. Ours, and the id the platform's events name in connection_id.

providerstring

Which integration this authorization is at — coros, polar, wahoo or garmin.

statusstring

active | reauth_required | revoked.

reauth_required is the one to handle: the provider has stopped accepting our credentials, no more data will arrive, and the only fix is sending the athlete back through POST /v1/connect.

user_idstring · uuid

Our id for the person this authorization belongs to — what POST /v1/connect returned and what every event about them carries as user_id.

Here so that throwing it away stays as harmless as the docs promise. It is the id /v1/accounts/{id} takes, and without it on this response a developer who did not persist the connect response would have no way back to it. Safe to expose beside external_user_id: it is minted per tenant, so unlike provider_user_id it correlates nobody with anybody.

revoked_atstring · date-timenullable

Always null on GET /v1/connections, which lists live connections only. The field is here because a revoked connection is still a connection this type can describe, and a shape that changed depending on the endpoint would be two types wearing one name.

scopestringnullable

What the athlete consented to, as the provider worded it. Absent when the provider returned none — not every one of them says.

created_atstring · date-time
eventsEventView[]

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

Show child attributes
created_atstring · date-time
dataobject

The event's data block, verbatim. {} for a ping, which carries nothing beyond having been asked for.

deliveredinteger · int64

How many of those answered 2xx.

deliveriesinteger · int64

Endpoints this event was sent to. 0 is a real answer: an account with no endpoints still accrues events.

idstring · uuid

Also the id inside the delivered body.

manualboolean

True when a human pressed a button rather than a provider causing it. Today that is exactly the ping/everything-else line.

typestring
end_user_idstring · uuidnullable

Who it is about — the tenant's own end user, the same id the delivered body carries as user_id.

Nullable for the same reason provider is: a ping has no subject, and the console renders that absence rather than attributing the test delivery to whoever happens to be first in the list.

providerstringnullable

Which integration caused it, or null when nothing did.

Nullable because ping exists: a person pressed a button, no provider was involved, and naming one would be an invention. The console renders the absence rather than substituting a default — a ping attributed to COROS would be indistinguishable on screen from a real COROS event.

external_user_idstring
idstring · uuid
404No such user on this account
GET /v1/accounts/{id}
GET /v1/accounts/4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6 HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 200
{
  "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": {},
      "delivered": 0,
      "deliveries": 0,
      "end_user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6",
      "id": "9f2c1e7a-4b83-4d21-9a6e-3c5f0d8b71a4",
      "manual": true,
      "provider": "coros",
      "type": "string"
    }
  ],
  "external_user_id": "user_4821",
  "id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}

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

idstring · uuidRequired

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

Body parameters

external_user_idstringRequired

What you want to call this person from now on.

Returns

200Renamed
Show AccountRef attributes
created_atstring · date-time
external_user_idstring

Yours, as it now stands.

idstring · uuid

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

updated_atstring · date-time
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
PATCH /v1/accounts/{id}
PATCH /v1/accounts/4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6 HTTP/1.1
Host: api.stridee.com
Content-Type: application/json
Content-Digest: sha-256=:…:
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:

{
  "external_user_id": "user_4821"
}
Response · 200
{
  "created_at": "2026-08-05T11:44:02Z",
  "external_user_id": "user_4821",
  "id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6",
  "updated_at": "2026-08-05T11:44:02Z"
}

Stop referring to one of your users

delete /v1/accounts/{id}

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

idstring · uuidRequired

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

Returns

204Forgotten
403This account's users cannot be deleted
404No such user on this account
DELETE /v1/accounts/{id}
DELETE /v1/accounts/4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6 HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 204
HTTP/1.1 204 No Content

Push Apple Health wellness data from your own server

post /v1/accounts/{id}/apple/wellness

The relay counterpart of POST /v1/device/apple/wellness: same body, same behavior, signed. Connects the user if they are not already.

Path parameters

idstring · uuidRequired

The user's user_id

Body parameters

itemsany[]Required

One object per measurement. Every item carries kind, calendarDate and startTime; the rest depends on the kind.

formatstring

healthkit.wellness.v1.

Returns

202Stored and queued
Show WellnessAccepted attributes
itemsinteger

How many items the batch carried. All of them were accepted — a batch with one bad item is refused whole, with the item named.

statusstring

Always accepted. Each item becomes a wellness.created (or wellness.updated, if it revised one you already have) on your webhook.

400Not a healthkit.wellness.v1 batch; the bad item is named
404No such user on this account
413Larger than 10 MB
503Could not be stored; retry
POST /v1/accounts/{id}/apple/wellness
POST /v1/accounts/4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6/apple/wellness HTTP/1.1
Host: api.stridee.com
Content-Type: application/json
Content-Digest: sha-256=:…:
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:

{
  "format": null,
  "items": [
    null
  ]
}
Response · 202
{
  "items": 0,
  "status": "active"
}

Push a HealthKit workout from your own server

post /v1/accounts/{id}/apple/workouts

For apps that read HealthKit themselves and relay through their backend rather than using the SDK's direct upload. Same body and same behavior as POST /v1/device/apple/workouts, signed like the rest of /v1/*.

Relaying connects the user if they are not already — your server sending their workouts is the consent — and sends account.connected the first time.

Path parameters

idstring · uuidRequired

The user's user_id

Body parameters

activityTypeinteger · int32Required

HKWorkoutActivityType's raw value, kept for reference.

durationnumber · doubleRequired

Seconds, as HKWorkout.duration reports it (paused time excluded).

endDatestring · date-timeRequired
serverTypestringRequired

The discipline: RUNNING, CYCLING, SWIMMING, WALKING, HIKING, ROWING, CROSS_COUNTRY_SKIING, ALPINE_SKIING, SNOWBOARDING, PADDLING, ROCK_CLIMBING, TRAINING or GENERIC. Becomes the FIT file's sport.

startDatestring · date-timeRequired
uuidstringRequired

The HKWorkout's UUID. Its identity: sending the same one twice delivers it once.

cyclingCadenceRawSample[]

Revolutions per minute.

Show child attributes
tstring · date-timeRequired

When it was measured.

vnumber · doubleRequired

The value, in the stream's unit.

cyclingPowerRawSample[]

Watts.

Same RawSample shape as above.

deviceNamestring

The recording device's name or model, when HealthKit has one.

formatstring

healthkit.v1. Optional, but anything else is refused.

heartRateRawSample[]

Beats per minute.

Same RawSample shape as above.

routeRawLocation[]

GPS points, HKWorkoutRoute's CLLocations in time order. Empty for an indoor workout.

Show child attributes
latnumber · doubleRequired

Degrees.

lonnumber · doubleRequired

Degrees.

tstring · date-timeRequired
altitudenumber · double

Meters above sea level.

coursenumber · double

Degrees from true north.

horizontalAccuracynumber · double

Meters.

speednumber · double

Meters per second.

verticalAccuracynumber · double

Meters.

runningPowerRawSample[]

Watts.

Same RawSample shape as above.

sourceBundleIdstring

That app's bundle id — com.apple.health for an Apple Watch workout.

sourceNamestring

The app that wrote the workout into HealthKit, by name.

totalDistanceMetersnumber · double
totalEnergyKcalnumber · double

Active energy, kilocalories.

workoutPlanIdstring

The WorkoutKit WorkoutPlan this workout was started from — HKWorkout.workoutPlan?.id — when there was one. For a workout you pushed to the athlete's Apple Watch it is the plan_id the schedule listed, and it is what puts workout_id on the resulting activity.created. Omit it for a workout started any other way.

Returns

202Stored and queued
Show WorkoutAccepted attributes
statusstring

Always accepted. The workout is stored and queued; activity.created follows on your webhook once its FIT file exists, usually within seconds.

uuidstring · uuid

The HealthKit workout's UUID, as received.

400Not a healthkit.v1 workout
404No such user on this account
413Larger than 30 MB
503Could not be stored; retry
POST /v1/accounts/{id}/apple/workouts
POST /v1/accounts/4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6/apple/workouts HTTP/1.1
Host: api.stridee.com
Content-Type: application/json
Content-Digest: sha-256=:…:
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:

{
  "activityType": 0,
  "cyclingCadence": [
    {
      "t": "2026-08-05T11:44:02Z",
      "v": 0
    }
  ],
  "cyclingPower": [
    {
      "t": "2026-08-05T11:44:02Z",
      "v": 0
    }
  ],
  "deviceName": null,
  "duration": 0,
  "endDate": "2026-08-05T11:44:02Z",
  "format": null,
  "heartRate": [
    {
      "t": "2026-08-05T11:44:02Z",
      "v": 0
    }
  ],
  "route": [
    {
      "altitude": null,
      "course": null,
      "horizontalAccuracy": null,
      "lat": 0,
      "lon": 0,
      "speed": null,
      "t": "2026-08-05T11:44:02Z",
      "verticalAccuracy": null
    }
  ],
  "runningPower": [
    {
      "t": "2026-08-05T11:44:02Z",
      "v": 0
    }
  ],
  "serverType": "string",
  "sourceBundleId": null,
  "sourceName": null,
  "startDate": "2026-08-05T11:44:02Z",
  "totalDistanceMeters": null,
  "totalEnergyKcal": null,
  "uuid": "string",
  "workoutPlanId": null
}
Response · 202
{
  "status": "active",
  "uuid": "string"
}

One activity, as GET /v1/activities lists it.

Every field below provider is the data block of that activity's activity.created, verbatim, and user_id/provider are the two envelope fields that describe the same thing. That parity is the point: a row from this endpoint can be handed to the function that already handles a delivery, which is what makes reconciling a missed webhook a loop rather than a second integration.

The one addition is received_at, which a delivery does not need to carry because its arrival was the timestamp.

Attributes

external_user_idstring

Your id for the same person, so a row can be matched to your database without a call per activity.

idstring · uuid

The activity id — data.id on the event, and what GET /v1/activities/{id}/file takes.

providerstring

coros | polar | wahoo | garmin | zepp | hammerhead | fitbit | suunto | whoop | apple.

provider_activity_idstring

The provider's own id for the workout. Unique within one athlete at some providers and within the partner account at others — ours is id.

received_atstring · date-time

When you were granted this activity — within a second or so of when your endpoint was called about it, and the axis this listing is ordered and windowed on. Page against this, not start_time.

user_idstring · uuid

Our id for the athlete. The event's user_id.

devicestringnullable
fileActivityFilenullable
Show child attributes
formatstring

fit | tcx | gpx — what the device wrote, unmodified. Two providers have no file of their own, and theirs is a FIT we write: apple from the HealthKit workout, whoop from WHOOP's workout summary.

sizeinteger · int64

The file's size in bytes. Not on the event, which predates anyone having a use for it; here because a list is where somebody decides what to download.

urlstring

GET /v1/activities/{id}/file, the same URL the event carried. It expires never and is worth nothing without your signing key — the short-lived URL is what that endpoint redirects to.

namestringnullable
sportstringnullable
start_timestring · date-timenullable

When the workout started, if the provider said so.

workout_idstring · uuidnullable

The workout this activity was done from — the id POST /v1/workouts returned — when the provider can tell. Apple can: a session started from a workout you pushed to an Apple Watch comes back naming it. Absent everywhere else, which means unknown rather than no.

The Activity object
{
  "device": null,
  "external_user_id": "user_4821",
  "file": null,
  "id": "7e14a9c3-2b60-4f85-9d3a-6c081ef47b52",
  "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",
  "workout_id": null
}

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

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 | suunto | whoop. 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.

Returns

200Activities you may read, newest first

A page of Activity objects.

Show ActivitiesPage attributes
activitiesActivityView[]
Show child attributes
external_user_idstring

Your id for the same person, so a row can be matched to your database without a call per activity.

idstring · uuid

The activity id — data.id on the event, and what GET /v1/activities/{id}/file takes.

providerstring

coros | polar | wahoo | garmin | zepp | hammerhead | fitbit | suunto | whoop | apple.

provider_activity_idstring

The provider's own id for the workout. Unique within one athlete at some providers and within the partner account at others — ours is id.

received_atstring · date-time

When you were granted this activity — within a second or so of when your endpoint was called about it, and the axis this listing is ordered and windowed on. Page against this, not start_time.

user_idstring · uuid

Our id for the athlete. The event's user_id.

devicestringnullable
fileActivityFilenullable
Show child attributes
formatstring

fit | tcx | gpx — what the device wrote, unmodified. Two providers have no file of their own, and theirs is a FIT we write: apple from the HealthKit workout, whoop from WHOOP's workout summary.

sizeinteger · int64

The file's size in bytes. Not on the event, which predates anyone having a use for it; here because a list is where somebody decides what to download.

urlstring

GET /v1/activities/{id}/file, the same URL the event carried. It expires never and is worth nothing without your signing key — the short-lived URL is what that endpoint redirects to.

namestringnullable
sportstringnullable
start_timestring · date-timenullable

When the workout started, if the provider said so.

workout_idstring · uuidnullable

The workout this activity was done from — the id POST /v1/workouts returned — when the provider can tell. Apple can: a session started from a workout you pushed to an Apple Watch comes back naming it. Absent everywhere else, which means unknown rather than no.

has_moreboolean

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 · uuidnullable

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

totalinteger · int64nullable

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.

400A malformed `since`/`until`, or a `starting_after` that is not an activity of yours
GET /v1/activities
GET /v1/activities HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 200
{
  "activities": [
    {
      "device": null,
      "external_user_id": "user_4821",
      "file": null,
      "id": "7e14a9c3-2b60-4f85-9d3a-6c081ef47b52",
      "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",
      "workout_id": null
    }
  ],
  "has_more": false,
  "next_starting_after": null,
  "total": 1
}

Download the raw file for an activity

get /v1/activities/{id}/file

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

idstring · uuidRequired

The activity, as data.id on activity.created

Returns

302Redirect to a short-lived download URL
404No such activity on your account, or it has no file
GET /v1/activities/{id}/file
GET /v1/activities/7e14a9c3-2b60-4f85-9d3a-6c081ef47b52/file HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 302
HTTP/1.1 302 Found

Sleep, daily activity, HRV, stress and fitness summaries — what arrives, what it contains, and why corrections are a separate event.

Read the Wellness guide

One health summary, as GET /v1/wellness lists it.

The same shape wellness.created and wellness.updated deliver, so a row from this endpoint can be handed to the function that already handles a delivery — which is what makes reconciling a missed webhook a loop rather than a second integration.

Attributes

calendar_datestring · date

The day this belongs to, in the athlete's own local reckoning. The axis to query on — from_date/to_date window it.

external_user_idstring

Your id for the same person, so a row can be matched to your database without a call per summary.

idstring · uuid

The summary id — data.id on the event, and what GET /v1/wellness/{id}/series takes.

kindstring

daily | sleep | hrv | stress | fitness | epoch | respiration | pulse_ox | body_composition | blood_pressure | skin_temperature | health_snapshot.

stress, epoch and health_snapshot arrive from Garmin only; the other health kinds from Garmin and Apple Health. epoch is far and away the highest-volume kind: it is a 15-minute bucket, so an athlete wearing a watch all day produces up to 96 of these against one daily. Filter on kind rather than paging the whole listing if you do not want them.

metricsMetrics

What was measured. Only the keys this kind carries; see Metrics.

Show child attributes
active_kilocaloriesnumber · doublenullable
active_time_secondsinteger · int32nullable
avg_hrinteger · int32nullable
avg_stress_levelinteger · int32nullable
awake_secondsnumber · doublenullable
bmr_kilocaloriesnumber · doublenullable
body_batteryinteger · int32nullable
body_battery_chargedinteger · int32nullable
body_battery_drainedinteger · int32nullable
body_fat_percentnumber · doublenullable
body_mass_indexnumber · doublenullable
body_water_percentnumber · doublenullable
bone_mass_gramsinteger · int32nullable
deep_sleep_secondsnumber · doublenullable
diastolicinteger · int32nullable
distance_metersnumber · doublenullable
epoch_activity_typestringnullable
epoch_intensitystringnullable
fitness_ageinteger · int32nullable
fitness_age_enhancedbooleannullable
floors_climbedinteger · int32nullable
hrv_avginteger · int32nullable
hrv_rmssdnumber · doublenullable
hrv_sdrrnumber · doublenullable
light_sleep_secondsnumber · doublenullable
max_hrinteger · int32nullable
metnumber · doublenullable
min_hrinteger · int32nullable
moderate_intensity_secondsinteger · int32nullable
muscle_mass_gramsinteger · int32nullable
nap_secondsnumber · doublenullable
pulseinteger · int32nullable
rem_sleep_secondsnumber · doublenullable
respiration_avgnumber · doublenullable
respiration_maxnumber · doublenullable
respiration_minnumber · doublenullable
resting_hrinteger · int32nullable
skin_temp_celsiusnumber · doublenullable
skin_temp_deviation_celsiusnumber · doublenullable
sleep_scoreinteger · int32nullable
spo2_avgnumber · doublenullable
spo2_minnumber · doublenullable
spo2_on_demandbooleannullable
stepsinteger · int32nullable
systolicinteger · int32nullable
total_sleep_secondsnumber · doublenullable
vigorous_intensity_secondsinteger · int32nullable
vo2_maxnumber · doublenullable
vo2_max_cyclingnumber · doublenullable
weight_gramsinteger · int32nullable
providerstring

garmin | coros | zepp | fitbit | suunto | whoop | apple — the providers that send wellness. Which kinds each one sends is on /docs/providers.

received_atstring · date-time

When you were granted this summary — the axis this listing is ordered and windowed on. Page against this, not calendar_date.

revisioninteger · int32

1 the first time you are told about a measurement, higher every time the provider revised it. Compare it against what you stored: a wellness.updated with a revision you already hold is a redelivery, not new information.

start_timestring · date-time

Start of the measurement window, UTC.

For fitness this is midnight of calendar_date rather than an observed instant: Garmin's VO₂max record carries no start time, because it is a daily snapshot rather than a window. duration_seconds is null for that kind for the same reason.

summaryobject

The provider's own payload, minus its time series. Everything we did not promote to a metric, unmodified.

user_idstring · uuid

Our id for the athlete. The event's user_id.

duration_secondsnumber · doublenullable
finalitystringnullable

final | tentative | null.

Only sleep has this. Garmin sends a night tentatively and settles it hours later; a tentative reading is one it may still revise, and it will never overwrite a final one on our side.

provider_summary_idstringnullable

The provider's own id for this record. Recorded for support, and deliberately not something to dedupe on: Garmin issues a new one when it revises the same night. Dedupe on id.

revised_atstring · date-timenullable

When a correction last touched a summary you already held, or null.

series_urlstringnullable

GET /v1/wellness/{id}/series, or null when this measurement has no time series — every fitness row, and every COROS row.

tz_offset_secondsinteger · int32nullable

The capturing device's UTC offset, in seconds — what turns start_time into the wall-clock time the athlete experienced.

The Wellness object
{
  "calendar_date": "string",
  "duration_seconds": null,
  "external_user_id": "user_4821",
  "finality": null,
  "id": "3f07d5b2-9e14-42a8-8c63-1d5a0be93f72",
  "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": {},
  "tz_offset_seconds": null,
  "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}

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

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 | zepp | fitbit | suunto | whoop. An unknown provider is an empty page, not a 400.

kindstring

daily | sleep | hrv | stress | fitness | epoch | respiration | pulse_ox | body_composition | blood_pressure | skin_temperature | health_snapshot. 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.

Returns

200Summaries you may read, newest first

A page of Wellness objects.

Show WellnessPage attributes
has_moreboolean

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

wellnessWellnessView[]
Show child attributes
calendar_datestring · date

The day this belongs to, in the athlete's own local reckoning. The axis to query on — from_date/to_date window it.

external_user_idstring

Your id for the same person, so a row can be matched to your database without a call per summary.

idstring · uuid

The summary id — data.id on the event, and what GET /v1/wellness/{id}/series takes.

kindstring

daily | sleep | hrv | stress | fitness | epoch | respiration | pulse_ox | body_composition | blood_pressure | skin_temperature | health_snapshot.

stress, epoch and health_snapshot arrive from Garmin only; the other health kinds from Garmin and Apple Health. epoch is far and away the highest-volume kind: it is a 15-minute bucket, so an athlete wearing a watch all day produces up to 96 of these against one daily. Filter on kind rather than paging the whole listing if you do not want them.

metricsMetrics

What was measured. Only the keys this kind carries; see Metrics.

Show child attributes
active_kilocaloriesnumber · doublenullable
active_time_secondsinteger · int32nullable
avg_hrinteger · int32nullable
avg_stress_levelinteger · int32nullable
awake_secondsnumber · doublenullable
bmr_kilocaloriesnumber · doublenullable
body_batteryinteger · int32nullable
body_battery_chargedinteger · int32nullable
body_battery_drainedinteger · int32nullable
body_fat_percentnumber · doublenullable
body_mass_indexnumber · doublenullable
body_water_percentnumber · doublenullable
bone_mass_gramsinteger · int32nullable
deep_sleep_secondsnumber · doublenullable
diastolicinteger · int32nullable
distance_metersnumber · doublenullable
epoch_activity_typestringnullable
epoch_intensitystringnullable
fitness_ageinteger · int32nullable
fitness_age_enhancedbooleannullable
floors_climbedinteger · int32nullable
hrv_avginteger · int32nullable
hrv_rmssdnumber · doublenullable
hrv_sdrrnumber · doublenullable
light_sleep_secondsnumber · doublenullable
max_hrinteger · int32nullable
metnumber · doublenullable
min_hrinteger · int32nullable
moderate_intensity_secondsinteger · int32nullable
muscle_mass_gramsinteger · int32nullable
nap_secondsnumber · doublenullable
pulseinteger · int32nullable
rem_sleep_secondsnumber · doublenullable
respiration_avgnumber · doublenullable
respiration_maxnumber · doublenullable
respiration_minnumber · doublenullable
resting_hrinteger · int32nullable
skin_temp_celsiusnumber · doublenullable
skin_temp_deviation_celsiusnumber · doublenullable
sleep_scoreinteger · int32nullable
spo2_avgnumber · doublenullable
spo2_minnumber · doublenullable
spo2_on_demandbooleannullable
stepsinteger · int32nullable
systolicinteger · int32nullable
total_sleep_secondsnumber · doublenullable
vigorous_intensity_secondsinteger · int32nullable
vo2_maxnumber · doublenullable
vo2_max_cyclingnumber · doublenullable
weight_gramsinteger · int32nullable
providerstring

garmin | coros | zepp | fitbit | suunto | whoop | apple — the providers that send wellness. Which kinds each one sends is on /docs/providers.

received_atstring · date-time

When you were granted this summary — the axis this listing is ordered and windowed on. Page against this, not calendar_date.

revisioninteger · int32

1 the first time you are told about a measurement, higher every time the provider revised it. Compare it against what you stored: a wellness.updated with a revision you already hold is a redelivery, not new information.

start_timestring · date-time

Start of the measurement window, UTC.

For fitness this is midnight of calendar_date rather than an observed instant: Garmin's VO₂max record carries no start time, because it is a daily snapshot rather than a window. duration_seconds is null for that kind for the same reason.

summaryobject

The provider's own payload, minus its time series. Everything we did not promote to a metric, unmodified.

user_idstring · uuid

Our id for the athlete. The event's user_id.

duration_secondsnumber · doublenullable
finalitystringnullable

final | tentative | null.

Only sleep has this. Garmin sends a night tentatively and settles it hours later; a tentative reading is one it may still revise, and it will never overwrite a final one on our side.

provider_summary_idstringnullable

The provider's own id for this record. Recorded for support, and deliberately not something to dedupe on: Garmin issues a new one when it revises the same night. Dedupe on id.

revised_atstring · date-timenullable

When a correction last touched a summary you already held, or null.

series_urlstringnullable

GET /v1/wellness/{id}/series, or null when this measurement has no time series — every fitness row, and every COROS row.

tz_offset_secondsinteger · int32nullable

The capturing device's UTC offset, in seconds — what turns start_time into the wall-clock time the athlete experienced.

next_starting_afterstring · uuidnullable

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

totalinteger · int64nullable

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

400A malformed date/timestamp, an unknown `kind`, or a `starting_after` that is not a summary of yours
GET /v1/wellness
GET /v1/wellness HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 200
{
  "has_more": false,
  "next_starting_after": null,
  "total": 1,
  "wellness": [
    {
      "calendar_date": "string",
      "duration_seconds": null,
      "external_user_id": "user_4821",
      "finality": null,
      "id": "3f07d5b2-9e14-42a8-8c63-1d5a0be93f72",
      "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": {},
      "tz_offset_seconds": null,
      "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
    }
  ]
}

Fetch the time series behind one summary

get /v1/wellness/{id}/series

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

idstring · uuidRequired

The summary, as data.id on wellness.created

Returns

200The provider's time series, unmodified
404No such summary on your account, or it has no series
GET /v1/wellness/{id}/series
GET /v1/wellness/3f07d5b2-9e14-42a8-8c63-1d5a0be93f72/series HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 200
{}

A workout, and where it got to.

Attributes

created_atstring · date-time
idstring · uuid
namestring
pushesPushView[]
Show child attributes
providerstring
statusstring

synced — on the watch. pending — accepted, not on the device yet, and it needs nothing from you: a Wahoo plan outside its display window, or an Apple workout waiting for the athlete's phone to sync. unsupported — this provider cannot represent this workout, and retrying will not change that. failed — did not land; reason says whether retrying can help.

errorstringnullable

One sentence, for a person. Never parse it — branch on reason.

external_idstringnullable

The provider's own id, once there is one. Absent for pending.

pushed_atstring · date-timenullable
reasonstringnullable

Why it did not land cleanly, as a stable code to branch on. Absent on a clean push.

not_connected — the athlete has no live connection to this provider. not_permitted — they connected but did not grant workout access (Garmin's Workout Import, Wahoo's plans); they must reconnect and allow it, and retrying before then fails the same way. On Apple it is the athlete not having allowed your app to add workouts, and it clears itself the moment they do. unsupported — this provider cannot represent this workout. provider_error — the provider refused or could not be reached; worth retrying with PATCH. schedule_failed — the provider has the workout but did not accept the date.

sportstring
updated_atstring · date-time
user_idstring · uuid
descriptionstringnullable
pool_lengthPoolLengthnullable

The pool this swim is written for, as you sent it. Absent for every other sport, and for a first-party swim authored before the app asked for one.

Show 2 possible shapes
As Variant 1
lengthnumber · double
unitstring, value is "meters"
As Variant 2
lengthnumber · double
unitstring, value is "yards"
scheduled_datestring · datenullable
segmentsanynullable

The segments as you sent them, for a multi_sport workout.

stepsanynullable

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.

The Workout object
{
  "created_at": "2026-08-05T11:44:02Z",
  "description": null,
  "id": "0f31a8c4-59d2-4e07-b6a1-8c74e2f95d30",
  "name": "5 × 1km @ threshold",
  "pool_length": null,
  "pushes": [
    {
      "error": null,
      "external_id": null,
      "provider": "coros",
      "pushed_at": null,
      "reason": null,
      "status": "synced"
    }
  ],
  "scheduled_date": null,
  "segments": null,
  "sport": "running",
  "steps": null,
  "updated_at": "2026-08-05T11:44:02Z",
  "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}

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

Returns

201Created, with one entry per provider attempted

The Workout object.

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
POST /v1/workouts
POST /v1/workouts HTTP/1.1
Host: api.stridee.com
Content-Type: application/json
Content-Digest: sha-256=:…:
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:

{
  "description": null,
  "name": "5 × 1km @ threshold",
  "pool_length": null,
  "provider": "coros",
  "scheduled_date": null,
  "segments": null,
  "sport": "running",
  "steps": [
    {
      "drill": null,
      "duration": {
        "type": "time",
        "seconds": 600
      },
      "equipment": null,
      "name": null,
      "secondary_target": null,
      "stroke": null,
      "target": null,
      "type": "warmup"
    }
  ],
  "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}
Response · 201
{
  "created_at": "2026-08-05T11:44:02Z",
  "description": null,
  "id": "0f31a8c4-59d2-4e07-b6a1-8c74e2f95d30",
  "name": "5 × 1km @ threshold",
  "pool_length": null,
  "pushes": [
    {
      "error": null,
      "external_id": null,
      "provider": "coros",
      "pushed_at": null,
      "reason": null,
      "status": "synced"
    }
  ],
  "scheduled_date": null,
  "segments": null,
  "sport": "running",
  "steps": null,
  "updated_at": "2026-08-05T11:44:02Z",
  "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}

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

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.

Returns

200Workouts, newest first

A page of Workout objects.

Show WorkoutsPage attributes
has_moreboolean

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

workoutsWorkoutView[]
Show child attributes
created_atstring · date-time
idstring · uuid
namestring
pushesPushView[]
Show child attributes
providerstring
statusstring

synced — on the watch. pending — accepted, not on the device yet, and it needs nothing from you: a Wahoo plan outside its display window, or an Apple workout waiting for the athlete's phone to sync. unsupported — this provider cannot represent this workout, and retrying will not change that. failed — did not land; reason says whether retrying can help.

errorstringnullable

One sentence, for a person. Never parse it — branch on reason.

external_idstringnullable

The provider's own id, once there is one. Absent for pending.

pushed_atstring · date-timenullable
reasonstringnullable

Why it did not land cleanly, as a stable code to branch on. Absent on a clean push.

not_connected — the athlete has no live connection to this provider. not_permitted — they connected but did not grant workout access (Garmin's Workout Import, Wahoo's plans); they must reconnect and allow it, and retrying before then fails the same way. On Apple it is the athlete not having allowed your app to add workouts, and it clears itself the moment they do. unsupported — this provider cannot represent this workout. provider_error — the provider refused or could not be reached; worth retrying with PATCH. schedule_failed — the provider has the workout but did not accept the date.

sportstring
updated_atstring · date-time
user_idstring · uuid
descriptionstringnullable
pool_lengthPoolLengthnullable

The pool this swim is written for, as you sent it. Absent for every other sport, and for a first-party swim authored before the app asked for one.

Show 2 possible shapes
As Variant 1
lengthnumber · double
unitstring, value is "meters"
As Variant 2
lengthnumber · double
unitstring, value is "yards"
scheduled_datestring · datenullable
segmentsanynullable

The segments as you sent them, for a multi_sport workout.

stepsanynullable

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.

next_starting_afterstring · uuidnullable

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

totalinteger · int64nullable

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

400A `starting_after` that is not a workout of yours
GET /v1/workouts
GET /v1/workouts HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 200
{
  "has_more": false,
  "next_starting_after": null,
  "total": 1,
  "workouts": [
    {
      "created_at": "2026-08-05T11:44:02Z",
      "description": null,
      "id": "0f31a8c4-59d2-4e07-b6a1-8c74e2f95d30",
      "name": "5 × 1km @ threshold",
      "pool_length": null,
      "pushes": [
        {
          "error": null,
          "external_id": null,
          "provider": "coros",
          "pushed_at": null,
          "reason": null,
          "status": "synced"
        }
      ],
      "scheduled_date": null,
      "segments": null,
      "sport": "running",
      "steps": null,
      "updated_at": "2026-08-05T11:44:02Z",
      "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
    }
  ]
}

Path parameters

idstring · uuidRequired

The workout id

Returns

200The workout

The Workout object.

404No such workout
GET /v1/workouts/{id}
GET /v1/workouts/0f31a8c4-59d2-4e07-b6a1-8c74e2f95d30 HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 200
{
  "created_at": "2026-08-05T11:44:02Z",
  "description": null,
  "id": "0f31a8c4-59d2-4e07-b6a1-8c74e2f95d30",
  "name": "5 × 1km @ threshold",
  "pool_length": null,
  "pushes": [
    {
      "error": null,
      "external_id": null,
      "provider": "coros",
      "pushed_at": null,
      "reason": null,
      "status": "synced"
    }
  ],
  "scheduled_date": null,
  "segments": null,
  "sport": "running",
  "steps": null,
  "updated_at": "2026-08-05T11:44:02Z",
  "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}

Replace a workout and re-push it

patch /v1/workouts/{id}

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

idstring · uuidRequired

The workout id

Body parameters

namestringRequired
sportSportRequired
Possible enum values
runningcyclingswimminghikingcardiohiityogapilatesgenericstrengthmulti_sport
descriptionstring

A whole-document replace, like everything here: omitting it clears it.

pool_lengthPoolLength

See CreateWorkoutBody::pool_length. A whole-document replace, so a swim that omits it here loses the one it had rather than keeping it — send the workout you want, pool included.

Show 2 possible shapes
As Variant 1
lengthnumber · doubleRequired
unitstring, value is "meters"Required
As Variant 2
lengthnumber · doubleRequired
unitstring, value is "yards"Required
scheduled_datestring · date

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

segmentsSegment[]
Show child attributes
sportSportRequired
Possible enum values
runningcyclingswimminghikingcardiohiityogapilatesgenericstrengthmulti_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[]

Same Step shape as above.

Returns

200Updated and re-pushed

The Workout object.

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
PATCH /v1/workouts/{id}
PATCH /v1/workouts/0f31a8c4-59d2-4e07-b6a1-8c74e2f95d30 HTTP/1.1
Host: api.stridee.com
Content-Type: application/json
Content-Digest: sha-256=:…:
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:

{
  "description": null,
  "name": "5 × 1km @ threshold",
  "pool_length": null,
  "scheduled_date": null,
  "segments": null,
  "sport": "running",
  "steps": [
    {
      "drill": null,
      "duration": {
        "type": "time",
        "seconds": 600
      },
      "equipment": null,
      "name": null,
      "secondary_target": null,
      "stroke": null,
      "target": null,
      "type": "warmup"
    }
  ]
}
Response · 200
{
  "created_at": "2026-08-05T11:44:02Z",
  "description": null,
  "id": "0f31a8c4-59d2-4e07-b6a1-8c74e2f95d30",
  "name": "5 × 1km @ threshold",
  "pool_length": null,
  "pushes": [
    {
      "error": null,
      "external_id": null,
      "provider": "coros",
      "pushed_at": null,
      "reason": null,
      "status": "synced"
    }
  ],
  "scheduled_date": null,
  "segments": null,
  "sport": "running",
  "steps": null,
  "updated_at": "2026-08-05T11:44:02Z",
  "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}

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.

An Apple Watch copy comes off at the athlete's phone's next sync.

Path parameters

idstring · uuidRequired

The workout id

Returns

204Deleted
404No such workout
DELETE /v1/workouts/{id}
DELETE /v1/workouts/0f31a8c4-59d2-4e07-b6a1-8c74e2f95d30 HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 204
HTTP/1.1 204 No Content

A route, and where it got to.

Attributes

created_atstring · date-time
idstring · uuid
namestring
pushesRoutePushView[]
Show child attributes
providerstring
statusstring

synced, unsupported or failed. Routes have no pending today — Garmin's course upload is one call that either lands or does not.

errorstringnullable

One sentence, for a person. Branch on reason.

external_idstringnullable

The provider's own id — Garmin's courseId.

pushed_atstring · date-timenullable
reasonstringnullable

not_connected, not_permitted (Garmin's Course Import was not granted), unsupported or provider_error. Absent on a clean push.

updated_atstring · date-time
user_idstring · uuid
descriptionstringnullable
distancenumber · doublenullable

Metres along the track, computed by us.

elevation_gainnumber · doublenullable

Metres climbed, computed by us. Absent without elevation.

pointsPoint[]nullable

The track as we read it from the file, at most 10,000 points. Only on a single route — a listing omits them, because a page of fifty ten-thousand-point routes is not a page.

Show child attributes
latnumber · double

Degrees, WGS 84.

lngnumber · double

Degrees, WGS 84.

elevationnumber · doublenullable

Metres above sea level. On every point or on none — a profile with holes in it is not something a device can draw, and guessing the gaps is not ours to do.

sportRouteSportnullable

Absent when you sent none, or for a first-party route whose sport is not one this contract names.

Possible enum values
runningtrail_runningwalkinghikingcyclingroad_cyclingmountain_bikinggravel_cycling
The Route object
{
  "created_at": "2026-08-05T11:44:02Z",
  "description": null,
  "distance": null,
  "elevation_gain": null,
  "id": "e2a47c19-3b8d-4f05-9a61-d7c3e80b5f24",
  "name": "Lakeside loop",
  "points": null,
  "pushes": [
    {
      "error": null,
      "external_id": null,
      "provider": "coros",
      "pushed_at": null,
      "reason": null,
      "status": "synced"
    }
  ],
  "sport": "running",
  "updated_at": "2026-08-05T11:44:02Z",
  "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}

The route is a GPX, TCX or FIT file. Its own name, description and sport are used unless you send them. A track over 10,000 points is thinned to fit, and an elevation profile with gaps is dropped rather than refused.

Stored, then pushed, then answered for — so pushes is what actually happened. Distance and elevation gain are computed from the track.

Garmin receives it as a Course, which needs the athlete to have granted Course Import; without it the push comes back failed with reason not_permitted and the route is still stored.

Body parameters

filestringRequired

The route: a GPX, TCX or FIT file, standard base64, up to 10 MB before encoding. The format is read from the content.

user_idstring · uuidRequired

The athlete, by our id for them.

descriptionstring

Defaults to the file's own (GPX <desc>, a TCX course's <Notes>).

namestring

What the athlete sees on the device. Defaults to the name in the file; required when the file has none.

providerstring

Which provider to push to. Omit to push to every connected provider that takes routes.

sportRouteSport

Defaults to the sport the file names, when it is one of these. Neither, and the device shows the course as "other".

Possible enum values
runningtrail_runningwalkinghikingcyclingroad_cyclingmountain_bikinggravel_cycling

Returns

201Created, with one entry per provider attempted

The Route object.

404No such user
409That user has no connected device that can receive routes
413The file is over 10 MB
422The file is not a readable route, or it has no name and none was sent — the body says which
429Too many device pushes in the last minute
POST /v1/routes
POST /v1/routes HTTP/1.1
Host: api.stridee.com
Content-Type: application/json
Content-Digest: sha-256=:…:
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:

{
  "description": null,
  "file": "string",
  "name": "Lakeside loop",
  "provider": "coros",
  "sport": null,
  "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}
Response · 201
{
  "created_at": "2026-08-05T11:44:02Z",
  "description": null,
  "distance": null,
  "elevation_gain": null,
  "id": "e2a47c19-3b8d-4f05-9a61-d7c3e80b5f24",
  "name": "Lakeside loop",
  "points": null,
  "pushes": [
    {
      "error": null,
      "external_id": null,
      "provider": "coros",
      "pushed_at": null,
      "reason": null,
      "status": "synced"
    }
  ],
  "sport": "running",
  "updated_at": "2026-08-05T11:44:02Z",
  "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}

Without points — fetch one route for its path. Everything else is here.

Query parameters

user_idstring · uuid

One athlete, by our id for them.

starting_afterstring · uuid

The id of the last route on the previous page.

limitinteger · int64

Defaults to 50, capped at 200.

Returns

200Routes, newest first

A page of Route objects.

Show RoutesPage attributes
has_moreboolean
routesRouteView[]
Show child attributes
created_atstring · date-time
idstring · uuid
namestring
pushesRoutePushView[]
Show child attributes
providerstring
statusstring

synced, unsupported or failed. Routes have no pending today — Garmin's course upload is one call that either lands or does not.

errorstringnullable

One sentence, for a person. Branch on reason.

external_idstringnullable

The provider's own id — Garmin's courseId.

pushed_atstring · date-timenullable
reasonstringnullable

not_connected, not_permitted (Garmin's Course Import was not granted), unsupported or provider_error. Absent on a clean push.

updated_atstring · date-time
user_idstring · uuid
descriptionstringnullable
distancenumber · doublenullable

Metres along the track, computed by us.

elevation_gainnumber · doublenullable

Metres climbed, computed by us. Absent without elevation.

pointsPoint[]nullable

The track as we read it from the file, at most 10,000 points. Only on a single route — a listing omits them, because a page of fifty ten-thousand-point routes is not a page.

Show child attributes
latnumber · double

Degrees, WGS 84.

lngnumber · double

Degrees, WGS 84.

elevationnumber · doublenullable

Metres above sea level. On every point or on none — a profile with holes in it is not something a device can draw, and guessing the gaps is not ours to do.

sportRouteSportnullable

Absent when you sent none, or for a first-party route whose sport is not one this contract names.

Possible enum values
runningtrail_runningwalkinghikingcyclingroad_cyclingmountain_bikinggravel_cycling
next_starting_afterstring · uuidnullable
totalinteger · int64nullable

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

400A `starting_after` that is not a route of yours
GET /v1/routes
GET /v1/routes HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 200
{
  "has_more": false,
  "next_starting_after": null,
  "routes": [
    {
      "created_at": "2026-08-05T11:44:02Z",
      "description": null,
      "distance": null,
      "elevation_gain": null,
      "id": "e2a47c19-3b8d-4f05-9a61-d7c3e80b5f24",
      "name": "Lakeside loop",
      "points": null,
      "pushes": [
        {
          "error": null,
          "external_id": null,
          "provider": "coros",
          "pushed_at": null,
          "reason": null,
          "status": "synced"
        }
      ],
      "sport": "running",
      "updated_at": "2026-08-05T11:44:02Z",
      "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
    }
  ],
  "total": 1
}

Path parameters

idstring · uuidRequired

The route id

Returns

200The route

The Route object.

404No such route
GET /v1/routes/{id}
GET /v1/routes/e2a47c19-3b8d-4f05-9a61-d7c3e80b5f24 HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 200
{
  "created_at": "2026-08-05T11:44:02Z",
  "description": null,
  "distance": null,
  "elevation_gain": null,
  "id": "e2a47c19-3b8d-4f05-9a61-d7c3e80b5f24",
  "name": "Lakeside loop",
  "points": null,
  "pushes": [
    {
      "error": null,
      "external_id": null,
      "provider": "coros",
      "pushed_at": null,
      "reason": null,
      "status": "synced"
    }
  ],
  "sport": "running",
  "updated_at": "2026-08-05T11:44:02Z",
  "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}

Replace a route and re-push it

patch /v1/routes/{id}

The same body as a create, less user_id and provider.

Garmin's course is replaced in place, keeping its courseId. Like a workout, this re-pushes to every capable connected provider, including ones the route never reached.

Path parameters

idstring · uuidRequired

The route id

Body parameters

filestringRequired

The route: a GPX, TCX or FIT file, standard base64, up to 10 MB before encoding. The format is read from the content.

descriptionstring

Defaults to the file's own (GPX <desc>, a TCX course's <Notes>).

namestring

What the athlete sees on the device. Defaults to the name in the file; required when the file has none.

sportRouteSport

Defaults to the sport the file names, when it is one of these. Neither, and the device shows the course as "other".

Possible enum values
runningtrail_runningwalkinghikingcyclingroad_cyclingmountain_bikinggravel_cycling

Returns

200Updated and re-pushed

The Route object.

404No such route
409That user has no connected device that can receive routes
413The file is over 10 MB
422The file is not a readable route, or it has no name and none was sent — the body says which
429Too many device pushes in the last minute
PATCH /v1/routes/{id}
PATCH /v1/routes/e2a47c19-3b8d-4f05-9a61-d7c3e80b5f24 HTTP/1.1
Host: api.stridee.com
Content-Type: application/json
Content-Digest: sha-256=:…:
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:

{
  "description": null,
  "file": "string",
  "name": "Lakeside loop",
  "sport": null
}
Response · 200
{
  "created_at": "2026-08-05T11:44:02Z",
  "description": null,
  "distance": null,
  "elevation_gain": null,
  "id": "e2a47c19-3b8d-4f05-9a61-d7c3e80b5f24",
  "name": "Lakeside loop",
  "points": null,
  "pushes": [
    {
      "error": null,
      "external_id": null,
      "provider": "coros",
      "pushed_at": null,
      "reason": null,
      "status": "synced"
    }
  ],
  "sport": "running",
  "updated_at": "2026-08-05T11:44:02Z",
  "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}

Provider objects first, because the rows are the only record of what to remove. A provider that refuses the delete does not stop ours.

Path parameters

idstring · uuidRequired

The route id

Returns

204Deleted
404No such route
DELETE /v1/routes/{id}
DELETE /v1/routes/e2a47c19-3b8d-4f05-9a61-d7c3e80b5f24 HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 204
HTTP/1.1 204 No Content

What a developer is allowed to see about a connection.

What it leaves out is the point of the type: no access_token, no refresh_token, and no provider_user_id.

The tokens are obvious. provider_user_id is the one worth stating: it is the provider's identifier for a human being — a COROS openId, a Polar x_user_id, a Wahoo or Garmin user id — we obtained it because that human stood in front of a consent screen we hosted, and handing it to a developer would let two tenants who both serve that person correlate their users against each other. They asked to sync workouts, not to be joined on.

Attributes

connected_atstring · date-time

When consent finished. Not when the row was created: a reconnection after a revoke moves this forward, because the question it answers is "since when have we had access", and the old answer stopped being true.

external_user_idstring

The developer's own id for this person — echoed back so a list response is legible against their database without a second lookup.

idstring · uuid

What DELETE /v1/connections/{id} takes. Ours, and the id the platform's events name in connection_id.

providerstring

Which integration this authorization is at — coros, polar, wahoo or garmin.

statusstring

active | reauth_required | revoked.

reauth_required is the one to handle: the provider has stopped accepting our credentials, no more data will arrive, and the only fix is sending the athlete back through POST /v1/connect.

user_idstring · uuid

Our id for the person this authorization belongs to — what POST /v1/connect returned and what every event about them carries as user_id.

Here so that throwing it away stays as harmless as the docs promise. It is the id /v1/accounts/{id} takes, and without it on this response a developer who did not persist the connect response would have no way back to it. Safe to expose beside external_user_id: it is minted per tenant, so unlike provider_user_id it correlates nobody with anybody.

revoked_atstring · date-timenullable

Always null on GET /v1/connections, which lists live connections only. The field is here because a revoked connection is still a connection this type can describe, and a shape that changed depending on the endpoint would be two types wearing one name.

scopestringnullable

What the athlete consented to, as the provider worded it. Absent when the provider returned none — not every one of them says.

The Connection object
{
  "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"
}

Mint a device token

post /v1/device-tokens

Call this from your server, when your app asks for one on behalf of a user who is signed in to it. The token lets the app push that user's Apple Health data directly to Stridee — and fetch the workouts you scheduled for their Apple Watch — so your signing key never has to leave your server and workouts never have to pass through it.

Mint freely: every call returns a new token, tokens expire after an hour, and a person may hold several at once — one per device.

Body parameters

external_user_idstringRequired

Your id for the person whose phone this is — the same value you would pass to POST /v1/connect. Creates them on first sight, so there is no separate call to register a user.

Returns

201A token for this user's device
Show DeviceTokenResponse attributes
expires_atstring · date-time

When the token stops working, an hour from now. The SDK asks for a fresh one on its own when it gets there.

tokenstring

Hand this to the Stridee SDK in your app, or send it yourself as Authorization: Bearer <token> to /v1/device/*. Starts with sdt_.

It can push this one person's data and nothing else — it cannot read what they recorded, only the workouts you scheduled for their Apple Watch, and it cannot act for anyone else on your account. Do not store it server-side; mint one when the app asks.

user_idstring · uuid

Our id for the person, stable across calls for the same external_user_id.

400Missing or oversized `external_user_id`
POST /v1/device-tokens
POST /v1/device-tokens HTTP/1.1
Host: api.stridee.com
Content-Type: application/json
Content-Digest: sha-256=:…:
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:

{
  "external_user_id": "user_4821"
}
Response · 201
{
  "expires_at": "2026-08-05T11:44:02Z",
  "token": "string",
  "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}

The workouts that should be on this athlete's Apple Watch

get /v1/device/apple/scheduled-workouts

The Stridee SDK calls this on every sync and makes WorkoutKit's schedule match it: new workouts are added, edited ones replaced, and ones no longer listed — deleted, taken off the calendar, or dated in the past — removed.

Each workout arrives already encoded as workoutkit.v1, the shape WorkoutKit's CustomWorkout takes, so the device builds it without interpreting anything.

Returns

200What should be scheduled, soonest first
Show ScheduledWorkouts attributes
workoutsScheduledWorkout[]

Soonest first. Anything on the watch that this app scheduled and that is not in this list should come off it.

Show child attributes
idstring · uuid

The workout, as POST /v1/workouts returned it. What the report names.

plan_idstring · uuid

The id to schedule the WorkoutKit WorkoutPlan under. Use it as given: it is how a workout is rescheduled in place rather than duplicated, and how a finished HKWorkout is matched back to this workout.

revisionstring

Which version of the workout this is. Opaque; send it back unchanged in the report.

scheduled_datestring · date

The athlete's calendar day, YYYY-MM-DD.

statusstring

Where the push stands: pending, synced or failed.

workoutWorkoutKitWorkout

The workout itself, as workoutkit.v1.

Show child attributes
activitystring

An HKWorkoutActivityType case, by name: running, cycling, swimming, hiking, walking, rowing, mixedCardio, highIntensityIntervalTraining, yoga, pilates or other.

blocksWorkoutKitBlock[]
Show child attributes
iterationsinteger · int32
stepsWorkoutKitStep[]
Show child attributes
goalWorkoutKitGoal
purposestring

work or recovery. A warm-up or cool-down has no purpose of its own; the field is there so every step is one shape.

alertWorkoutKitAlertnullable
displayNamestringnullable

Shown on the watch from watchOS 11; ignored before.

displayNamestring

What the Workout app lists it as.

formatstring

Always workoutkit.v1.

locationstring

outdoor, indoor or unknown.

cooldownWorkoutKitStepnullable

Same WorkoutKitStep shape as above.

warmupWorkoutKitStepnullable

Same WorkoutKitStep shape as above.

reasonstringnullable

Why it is failed, when it is.

401Missing, expired or revoked device token
409This user is not connected to Apple Health
GET /v1/device/apple/scheduled-workouts
GET /v1/device/apple/scheduled-workouts HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 200
{
  "workouts": [
    {
      "id": "a2f81b60-3c47-49d5-b8e2-70a4c9f13d85",
      "plan_id": "string",
      "reason": null,
      "revision": "string",
      "scheduled_date": "string",
      "status": "active",
      "workout": {
        "activity": "string",
        "blocks": [
          {
            "iterations": 0,
            "steps": [
              {
                "alert": null,
                "displayName": null,
                "goal": {
                  "type": "open"
                },
                "purpose": "string"
              }
            ]
          }
        ],
        "cooldown": null,
        "displayName": "string",
        "format": "string",
        "location": "string",
        "warmup": null
      }
    }
  ]
}

Report what happened to one scheduled workout

put /v1/device/apple/scheduled-workouts/{id}

The SDK calls this after scheduling. It is what moves the workout's apple push from pending to synced — or to failed or unsupported, with the reason — in GET /v1/workouts/{id}.

A report carrying an old revision is a 412 and changes nothing: the workout was edited after the device read it, and the next sync will schedule the new version and report on that.

Path parameters

idstring · uuidRequired

The workout id

Body parameters

outcomeScheduleOutcomeRequired
Possible enum values
syncednot_permittedunsupportedfailed
revisionstringRequired

The revision the workout was listed with.

errorstring

One sentence about why, for unsupported and failed. Shown to you as the push's error.

Returns

204Recorded
401Missing, expired or revoked device token
404No such workout scheduled for this user
409This user is not connected to Apple Health
412The workout changed since this revision
PUT /v1/device/apple/scheduled-workouts/{id}
PUT /v1/device/apple/scheduled-workouts/{id} HTTP/1.1
Host: api.stridee.com
Content-Type: application/json
Content-Digest: sha-256=:…:
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:

{
  "error": null,
  "outcome": "synced",
  "revision": "string"
}
Response · 204
HTTP/1.1 204 No Content

A batch of healthkit.wellness.v1 items — daily totals, sleep, HRV, VO₂max, respiration, blood oxygen, body composition, blood pressure and wrist temperature. The Stridee SDK assembles these from HealthKit and calls this for you. Each item is stored like any provider's wellness summary and announced on your webhook.

An item's kind and startTime are its identity: sending it again replaces it, which is how a day still in progress stays current. Mark those "final": false.

Body parameters

itemsany[]Required

One object per measurement. Every item carries kind, calendarDate and startTime; the rest depends on the kind.

formatstring

healthkit.wellness.v1.

Returns

202Stored and queued
Show WellnessAccepted attributes
itemsinteger

How many items the batch carried. All of them were accepted — a batch with one bad item is refused whole, with the item named.

statusstring

Always accepted. Each item becomes a wellness.created (or wellness.updated, if it revised one you already have) on your webhook.

400Not a healthkit.wellness.v1 batch; the bad item is named
401Missing, expired or revoked device token
409This user is not connected to Apple Health
413Larger than 10 MB
503Could not be stored; retry
POST /v1/device/apple/wellness
POST /v1/device/apple/wellness HTTP/1.1
Host: api.stridee.com
Content-Type: application/json
Content-Digest: sha-256=:…:
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:

{
  "format": null,
  "items": [
    null
  ]
}
Response · 202
{
  "items": 0,
  "status": "active"
}

Say whether this device can schedule workouts

put /v1/device/apple/workout-scheduling

The SDK calls this for you whenever WorkoutKit's authorization changes. Once a device reports authorized, the athlete's Apple connection carries the workout_scheduling scope and POST /v1/workouts treats it as a device to push to. Any other value takes the scope away again.

Body parameters

authorizationSchedulingAuthorizationRequired
Possible enum values
authorizeddeniedrestrictednot_determined

Returns

204Recorded
401Missing, expired or revoked device token
409This user is not connected to Apple Health
PUT /v1/device/apple/workout-scheduling
PUT /v1/device/apple/workout-scheduling HTTP/1.1
Host: api.stridee.com
Content-Type: application/json
Content-Digest: sha-256=:…:
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:

{
  "authorization": "authorized"
}
Response · 204
HTTP/1.1 204 No Content

The Stridee SDK calls this for every new workout HealthKit reports. The body is the healthkit.v1 JSON: the workout, its sample streams and its route. We turn it into a FIT file and deliver it like any provider's workout — activity.created on your webhook, the file on GET /v1/activities/{id}/file.

Idempotent on the workout's uuid: sending one twice delivers it once. A 409 means the user is not connected (never connected, or disconnected since); a 503 means retry later.

Body parameters

activityTypeinteger · int32Required

HKWorkoutActivityType's raw value, kept for reference.

durationnumber · doubleRequired

Seconds, as HKWorkout.duration reports it (paused time excluded).

endDatestring · date-timeRequired
serverTypestringRequired

The discipline: RUNNING, CYCLING, SWIMMING, WALKING, HIKING, ROWING, CROSS_COUNTRY_SKIING, ALPINE_SKIING, SNOWBOARDING, PADDLING, ROCK_CLIMBING, TRAINING or GENERIC. Becomes the FIT file's sport.

startDatestring · date-timeRequired
uuidstringRequired

The HKWorkout's UUID. Its identity: sending the same one twice delivers it once.

cyclingCadenceRawSample[]

Revolutions per minute.

Show child attributes
tstring · date-timeRequired

When it was measured.

vnumber · doubleRequired

The value, in the stream's unit.

cyclingPowerRawSample[]

Watts.

Same RawSample shape as above.

deviceNamestring

The recording device's name or model, when HealthKit has one.

formatstring

healthkit.v1. Optional, but anything else is refused.

heartRateRawSample[]

Beats per minute.

Same RawSample shape as above.

routeRawLocation[]

GPS points, HKWorkoutRoute's CLLocations in time order. Empty for an indoor workout.

Show child attributes
latnumber · doubleRequired

Degrees.

lonnumber · doubleRequired

Degrees.

tstring · date-timeRequired
altitudenumber · double

Meters above sea level.

coursenumber · double

Degrees from true north.

horizontalAccuracynumber · double

Meters.

speednumber · double

Meters per second.

verticalAccuracynumber · double

Meters.

runningPowerRawSample[]

Watts.

Same RawSample shape as above.

sourceBundleIdstring

That app's bundle id — com.apple.health for an Apple Watch workout.

sourceNamestring

The app that wrote the workout into HealthKit, by name.

totalDistanceMetersnumber · double
totalEnergyKcalnumber · double

Active energy, kilocalories.

workoutPlanIdstring

The WorkoutKit WorkoutPlan this workout was started from — HKWorkout.workoutPlan?.id — when there was one. For a workout you pushed to the athlete's Apple Watch it is the plan_id the schedule listed, and it is what puts workout_id on the resulting activity.created. Omit it for a workout started any other way.

Returns

202Stored and queued
Show WorkoutAccepted attributes
statusstring

Always accepted. The workout is stored and queued; activity.created follows on your webhook once its FIT file exists, usually within seconds.

uuidstring · uuid

The HealthKit workout's UUID, as received.

400Not a healthkit.v1 workout
401Missing, expired or revoked device token
409This user is not connected to Apple Health
413Larger than 30 MB
503Could not be stored; retry
POST /v1/device/apple/workouts
POST /v1/device/apple/workouts HTTP/1.1
Host: api.stridee.com
Content-Type: application/json
Content-Digest: sha-256=:…:
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:

{
  "activityType": 0,
  "cyclingCadence": [
    {
      "t": "2026-08-05T11:44:02Z",
      "v": 0
    }
  ],
  "cyclingPower": [
    {
      "t": "2026-08-05T11:44:02Z",
      "v": 0
    }
  ],
  "deviceName": null,
  "duration": 0,
  "endDate": "2026-08-05T11:44:02Z",
  "format": null,
  "heartRate": [
    {
      "t": "2026-08-05T11:44:02Z",
      "v": 0
    }
  ],
  "route": [
    {
      "altitude": null,
      "course": null,
      "horizontalAccuracy": null,
      "lat": 0,
      "lon": 0,
      "speed": null,
      "t": "2026-08-05T11:44:02Z",
      "verticalAccuracy": null
    }
  ],
  "runningPower": [
    {
      "t": "2026-08-05T11:44:02Z",
      "v": 0
    }
  ],
  "serverType": "string",
  "sourceBundleId": null,
  "sourceName": null,
  "startDate": "2026-08-05T11:44:02Z",
  "totalDistanceMeters": null,
  "totalEnergyKcal": null,
  "uuid": "string",
  "workoutPlanId": null
}
Response · 202
{
  "status": "active",
  "uuid": "string"
}

Whether this device's user is connected

get /v1/device/connections/{provider}

A 404 means they are not — never connected, turned off on the device, or disconnected by your server with DELETE /v1/connections/{id}. The SDK reads this on launch so an app can show the truth rather than what it last did.

Path parameters

providerstringRequired

apple

Returns

200Connected

The Connection object.

401Missing, expired or revoked device token
404Not connected, or not a device provider
GET /v1/device/connections/{provider}
GET /v1/device/connections/{provider} HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 200
{
  "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"
}

Connect this device's user

put /v1/device/connections/{provider}

Call once the athlete has answered the permission sheet — the SDK does this for you. Idempotent: the first call creates the connection, answers 201 and sends account.connected to your webhooks; every later one answers 200 and sends nothing.

Path parameters

providerstringRequired

apple

Returns

200Already connected

The Connection object.

201Newly connected

The Connection object.

401Missing, expired or revoked device token
404Not a device provider
PUT /v1/device/connections/{provider}
PUT /v1/device/connections/{provider} HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 200
{
  "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"
}

Disconnect this device's user

delete /v1/device/connections/{provider}

For when the athlete turns syncing off inside your app. Their connection is revoked, every device token for them stops working, and your webhooks get account.disconnected. Data already delivered stays yours.

Answers 404 when there is nothing to disconnect, as DELETE /v1/connections/{id} does.

Path parameters

providerstringRequired

apple

Returns

204Disconnected
401Missing, expired or revoked device token
404Not connected, or not a device provider
DELETE /v1/device/connections/{provider}
DELETE /v1/device/connections/{provider} HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 204
HTTP/1.1 204 No Content

Requests are authenticated with an Ed25519 signature over the request itself — RFC 9421 HTTP Message Signatures. There is no API key.

Read the Signing requests guide

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.

Returns

200The account this signature belongs to
Show Whoami attributes
account_idstring · uuid

The account the signature authenticated as.

401The signature was missing, stale, replayed or wrong
GET /v1/whoami
GET /v1/whoami HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 200
{
  "account_id": "0b6f2d14-59c3-4e7a-8d05-1af38b62c9e7"
}

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