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.
https://api.stridee.com/platform/openapi.json — OpenAPI 3.1, if you would rather generate a client than read this.
Link one of your users to their watch, without holding a provider client secret or implementing a callback.
Read the Connect a device guideWhat 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-timeWhen 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_idstringThe developer's own id for this person — echoed back so a list response is legible against their database without a second lookup.
idstring · uuidWhat DELETE /v1/connections/{id} takes. Ours, and the id the platform's
events name in connection_id.
providerstringWhich integration this authorization is at — coros, polar, wahoo or
garmin.
statusstringactive | 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 · uuidOur 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-timenullableAlways 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.
scopestringnullableWhat the athlete consented to, as the provider worded it. Absent when the provider returned none — not every one of them says.
{
"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
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_idstringRequiredYour 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.
providerstringRequiredcoros, 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.
langstringThe 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_uristringWhere 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
Show ConnectResponse attributesHide ConnectResponse attributes
connect_urlstringSend 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-timeWhen 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 · uuidOur 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.
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"
}{
"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
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_idstringNarrow to one of your users. Omit for every connection on the account.
starting_afterstring · uuidThe id of the last connection on the previous page. Omit for the first one.
limitinteger · int64Defaults to 50, capped at 200.
Returns
A page of Connection objects.
Show ConnectionsPage attributesHide ConnectionsPage attributes
connectionsConnectionView[]Show child attributesHide child attributes
connected_atstring · date-timeWhen 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_idstringThe developer's own id for this person — echoed back so a list response is legible against their database without a second lookup.
idstring · uuidWhat DELETE /v1/connections/{id} takes. Ours, and the id the platform's
events name in connection_id.
providerstringWhich integration this authorization is at — coros, polar, wahoo or
garmin.
statusstringactive | 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 · uuidOur 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-timenullableAlways 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.
scopestringnullableWhat the athlete consented to, as the provider worded it. Absent when the provider returned none — not every one of them says.
has_morebooleanWhether another page exists. Keep going while it is true.
next_starting_afterstring · uuidnullableWhat to send as starting_after for the next page. null at the end.
totalinteger · int64nullableHow 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.
GET /v1/connections HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:{
"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
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 · uuidRequiredThe connection to disconnect, as returned by GET /v1/connections
Returns
DELETE /v1/connections/a2f81b60-3c47-49d5-b8e2-70a4c9f13d85 HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:HTTP/1.1 204 No ContentMint a link that lets one of your users manage their own connections
The page it opens lists that person's live connections with you and lets them disconnect any of them. It shows nothing about any other developer they may also be connected through, and disconnecting here does not touch those.
Why this exists rather than just DELETE /v1/connections/{id}: the athlete
has no Stridee account and never will, so there is nothing to log them in as.
The link is the credential. If your product already has its own settings
screen, use the DELETE and skip this entirely.
Body parameters
external_user_idstringRequiredYour id for the person, the same value you passed to /v1/connect.
langstringThe language of the page, exactly as on POST /v1/connect: en, de,
da, nb, sv, pl, cs, sk, es, zh, zh-Hant, ko or ja.
Omitted or not yet translated, the page follows your user's browser
language, then English.
Returns
Show ManageLinkResponse attributesHide ManageLinkResponse attributes
expires_atstring · date-timemanage_urlstringPut this in front of your user — a settings screen, an email, a support reply. Anyone holding it can disconnect that person's watches from your product, so treat it like a password reset link rather than a page URL.
POST /v1/connections/manage-link 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"
}{
"expires_at": "2026-08-05T11:44:02Z",
"manage_url": "https://api.stridee.com/connections/manage?token=…"
}- List your usersget /v1/accounts
- One user: their connections, and what has happened to themget /v1/accounts/{id}
- Change your id for one of your userspatch /v1/accounts/{id}
- Stop referring to one of your usersdelete /v1/accounts/{id}
- Push Apple Health wellness data from your own serverpost /v1/accounts/{id}/apple/wellness
- Push a HealthKit workout from your own serverpost /v1/accounts/{id}/apple/workouts
Attributes
activitiesinteger · int64activity.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-timeWhen they were first named to us.
external_user_idstringThe 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 · uuidOur 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.
statusstringactive | 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-timenullableConsent on their most recent live connection. null when there is none.
last_event_atstring · date-timenullableThe newest event about them, of any type. null for someone nothing has
happened to yet.
{
"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
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_idstringNarrow to exactly one of your users, by your id for them. The way back from an id in your database to ours.
starting_afterstring · uuidThe id of the last user on the previous page. Omit for the first one.
limitinteger · int64Defaults to 50, capped at 200.
Returns
A page of Account objects.
Show EndUsersPage attributesHide EndUsersPage attributes
accountsAccountView[]Show child attributesHide child attributes
activitiesinteger · int64activity.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-timeWhen they were first named to us.
external_user_idstringThe 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 · uuidOur 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.
statusstringactive | 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-timenullableConsent on their most recent live connection. null when there is none.
last_event_atstring · date-timenullableThe newest event about them, of any type. null for someone nothing has
happened to yet.
has_morebooleanWhether another page exists. Keep going while it is true.
next_starting_afterstring · uuidnullableWhat to send as starting_after for the next page. null at the end.
totalinteger · int64nullableHow many match the filter, on the first page only; null afterwards.
Not the number in accounts, and not a paging control — has_more is.
GET /v1/accounts HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:{
"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
}One user: their connections, and what has happened to them
Connections here include revoked ones, unlike GET /v1/connections — "they
disconnected COROS on Tuesday" is the answer to the question that actually
gets asked.
Path parameters
idstring · uuidRequiredOur id for the user, as returned by POST /v1/connect
Returns
Show AccountDetail attributesHide 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 attributesHide child attributes
connected_atstring · date-timeWhen 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_idstringThe developer's own id for this person — echoed back so a list response is legible against their database without a second lookup.
idstring · uuidWhat DELETE /v1/connections/{id} takes. Ours, and the id the platform's
events name in connection_id.
providerstringWhich integration this authorization is at — coros, polar, wahoo or
garmin.
statusstringactive | 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 · uuidOur 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-timenullableAlways 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.
scopestringnullableWhat the athlete consented to, as the provider worded it. Absent when the provider returned none — not every one of them says.
created_atstring · date-timeeventsEventView[]Their recent events, newest first — the same rows the stream page shows, narrowed to this person.
Show child attributesHide child attributes
created_atstring · date-timedataobjectThe event's data block, verbatim. {} for a ping, which carries nothing
beyond having been asked for.
deliveredinteger · int64How many of those answered 2xx.
deliveriesinteger · int64Endpoints this event was sent to. 0 is a real answer: an account with no
endpoints still accrues events.
idstring · uuidAlso the id inside the delivered body.
manualbooleanTrue when a human pressed a button rather than a provider causing it. Today that is exactly the ping/everything-else line.
typestringend_user_idstring · uuidnullableWho 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.
providerstringnullableWhich 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_idstringidstring · uuidGET /v1/accounts/4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6 HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:{
"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"
}Change your id for one of your users
For when your own user table was re-keyed and the id you linked somebody
under is not the id you have any more. Our user_id does not change, so no
connection moves, no activity grant is affected, no event is rewritten, and
nobody goes back through consent. The next POST /v1/connect with the new id
finds this person rather than creating a second one.
This is a rename, not a merge. If another of your users already goes by the id you send, you get a 409 and nothing changes — deciding what should happen to two sets of connections at the same provider is not a guess this API is willing to make on your behalf.
If what you actually want is to stop referring to someone at all, that is
DELETE /v1/accounts/{id}.
Path parameters
idstring · uuidRequiredOur id for the user, as returned by POST /v1/connect
Body parameters
external_user_idstringRequiredWhat you want to call this person from now on.
Returns
Show AccountRef attributesHide AccountRef attributes
created_atstring · date-timeexternal_user_idstringYours, as it now stands.
idstring · uuidOurs. Unchanged by a rename — that is the point of one.
updated_atstring · date-timePATCH /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"
}{
"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
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 · uuidRequiredOur id for the user, as returned by POST /v1/connect
Returns
DELETE /v1/accounts/4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6 HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:HTTP/1.1 204 No ContentPush Apple Health wellness data from your own server
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 · uuidRequiredThe user's user_id
Body parameters
itemsany[]RequiredOne object per measurement. Every item carries kind, calendarDate
and startTime; the rest depends on the kind.
formatstringhealthkit.wellness.v1.
Returns
Show WellnessAccepted attributesHide WellnessAccepted attributes
itemsintegerHow many items the batch carried. All of them were accepted — a batch with one bad item is refused whole, with the item named.
statusstringAlways accepted. Each item becomes a wellness.created (or
wellness.updated, if it revised one you already have) on your webhook.
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
]
}{
"items": 0,
"status": "active"
}Push a HealthKit workout from your own server
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 · uuidRequiredThe user's user_id
Body parameters
activityTypeinteger · int32RequiredHKWorkoutActivityType's raw value, kept for reference.
durationnumber · doubleRequiredSeconds, as HKWorkout.duration reports it (paused time excluded).
endDatestring · date-timeRequiredserverTypestringRequiredThe 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-timeRequireduuidstringRequiredThe HKWorkout's UUID. Its identity: sending the same one twice
delivers it once.
cyclingCadenceRawSample[]Revolutions per minute.
Show child attributesHide child attributes
tstring · date-timeRequiredWhen it was measured.
vnumber · doubleRequiredThe value, in the stream's unit.
cyclingPowerRawSample[]Watts.
Same RawSample shape as above.
deviceNamestringThe recording device's name or model, when HealthKit has one.
formatstringhealthkit.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 attributesHide child attributes
latnumber · doubleRequiredDegrees.
lonnumber · doubleRequiredDegrees.
tstring · date-timeRequiredaltitudenumber · doubleMeters above sea level.
coursenumber · doubleDegrees from true north.
horizontalAccuracynumber · doubleMeters.
speednumber · doubleMeters per second.
verticalAccuracynumber · doubleMeters.
runningPowerRawSample[]Watts.
Same RawSample shape as above.
sourceBundleIdstringThat app's bundle id — com.apple.health for an Apple Watch workout.
sourceNamestringThe app that wrote the workout into HealthKit, by name.
totalDistanceMetersnumber · doubletotalEnergyKcalnumber · doubleActive energy, kilocalories.
workoutPlanIdstringThe 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
Show WorkoutAccepted attributesHide WorkoutAccepted attributes
statusstringAlways accepted. The workout is stored and queued; activity.created
follows on your webhook once its FIT file exists, usually within seconds.
uuidstring · uuidThe HealthKit workout's UUID, as received.
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
}{
"status": "active",
"uuid": "string"
}What the platform emits, what an event body contains, and why it contains so little.
Read the Events guideOne 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_idstringYour id for the same person, so a row can be matched to your database without a call per activity.
idstring · uuidThe activity id — data.id on the event, and what
GET /v1/activities/{id}/file takes.
providerstringcoros | polar | wahoo | garmin | zepp | hammerhead | fitbit | suunto | whoop | apple.
provider_activity_idstringThe 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-timeWhen 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 · uuidOur id for the athlete. The event's user_id.
devicestringnullablefileActivityFilenullableShow child attributesHide child attributes
formatstringfit | 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 · int64The 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.
urlstringGET /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.
namestringnullablesportstringnullablestart_timestring · date-timenullableWhen the workout started, if the provider said so.
workout_idstring · uuidnullableThe 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.
{
"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
}List the activities you have been granted
Every workout one of your users was connected to you for, newest first — the
same rows that were announced to your endpoint as activity.created, in the
same shape. This is the reconciliation path: if a delivery was missed, this
is how you find it, and file.url is the same download URL the event
carried.
Page against received_at, not start_time. Providers backfill and
correct, so a workout from March can arrive today; the order here is the
order you were told, which is the only one that is monotonic. since and
until window that same axis, half-open, so a nightly job can ask for
since=<last run> and get exactly what it has not seen.
Paged with a cursor: send starting_after = the id of the last row you
received, and keep going while has_more. next_starting_after is that id,
so a loop never has to reach into the array. A cursor names a row rather than
a distance from one, so activities arriving while you page cannot shift the
boundary under you — which an offset would, and did.
Access is the grant that was recorded when each activity arrived — the same
rule GET /v1/activities/{id}/file applies. Disconnecting stops new
activities; it does not remove the ones from the period your user had agreed
to share, so they stay listed here. An activity whose provider offered no
downloadable file has "file": null, and so does one whose bytes have not
landed yet.
Query parameters
user_idstring · uuidNarrow 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.
providerstringcoros | polar | wahoo | garmin | zepp | hammerhead | fitbit | suunto | whoop. An unknown provider is an empty
page, not a 400.
sincestringOnly activities granted at or after this RFC 3339 instant.
untilstringOnly 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 · uuidThe id of the last activity on the previous page. Omit for the first one.
limitinteger · int64Defaults to 50, capped at 200.
Returns
A page of Activity objects.
Show ActivitiesPage attributesHide ActivitiesPage attributes
activitiesActivityView[]Show child attributesHide child attributes
external_user_idstringYour id for the same person, so a row can be matched to your database without a call per activity.
idstring · uuidThe activity id — data.id on the event, and what
GET /v1/activities/{id}/file takes.
providerstringcoros | polar | wahoo | garmin | zepp | hammerhead | fitbit | suunto | whoop | apple.
provider_activity_idstringThe 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-timeWhen 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 · uuidOur id for the athlete. The event's user_id.
devicestringnullablefileActivityFilenullableShow child attributesHide child attributes
formatstringfit | 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 · int64The 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.
urlstringGET /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.
namestringnullablesportstringnullablestart_timestring · date-timenullableWhen the workout started, if the provider said so.
workout_idstring · uuidnullableThe 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_morebooleanWhether 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 · uuidnullableWhat to send as starting_after for the next page — the last row's id.
null when has_more is false.
totalinteger · int64nullableHow 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.
GET /v1/activities HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:{
"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
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 · uuidRequiredThe activity, as data.id on activity.created
Returns
GET /v1/activities/7e14a9c3-2b60-4f85-9d3a-6c081ef47b52/file HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:HTTP/1.1 302 FoundSleep, daily activity, HRV, stress and fitness summaries — what arrives, what it contains, and why corrections are a separate event.
Read the Wellness guideOne 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 · dateThe day this belongs to, in the athlete's own local reckoning. The axis to
query on — from_date/to_date window it.
external_user_idstringYour id for the same person, so a row can be matched to your database without a call per summary.
idstring · uuidThe summary id — data.id on the event, and what
GET /v1/wellness/{id}/series takes.
kindstringdaily | 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.
metricsMetricsWhat was measured. Only the keys this kind carries; see Metrics.
Show child attributesHide child attributes
active_kilocaloriesnumber · doublenullableactive_time_secondsinteger · int32nullableavg_hrinteger · int32nullableavg_stress_levelinteger · int32nullableawake_secondsnumber · doublenullablebmr_kilocaloriesnumber · doublenullablebody_batteryinteger · int32nullablebody_battery_chargedinteger · int32nullablebody_battery_drainedinteger · int32nullablebody_fat_percentnumber · doublenullablebody_mass_indexnumber · doublenullablebody_water_percentnumber · doublenullablebone_mass_gramsinteger · int32nullabledeep_sleep_secondsnumber · doublenullablediastolicinteger · int32nullabledistance_metersnumber · doublenullableepoch_activity_typestringnullableepoch_intensitystringnullablefitness_ageinteger · int32nullablefitness_age_enhancedbooleannullablefloors_climbedinteger · int32nullablehrv_avginteger · int32nullablehrv_rmssdnumber · doublenullablehrv_sdrrnumber · doublenullablelight_sleep_secondsnumber · doublenullablemax_hrinteger · int32nullablemetnumber · doublenullablemin_hrinteger · int32nullablemoderate_intensity_secondsinteger · int32nullablemuscle_mass_gramsinteger · int32nullablenap_secondsnumber · doublenullablepulseinteger · int32nullablerem_sleep_secondsnumber · doublenullablerespiration_avgnumber · doublenullablerespiration_maxnumber · doublenullablerespiration_minnumber · doublenullableresting_hrinteger · int32nullableskin_temp_celsiusnumber · doublenullableskin_temp_deviation_celsiusnumber · doublenullablesleep_scoreinteger · int32nullablespo2_avgnumber · doublenullablespo2_minnumber · doublenullablespo2_on_demandbooleannullablestepsinteger · int32nullablesystolicinteger · int32nullabletotal_sleep_secondsnumber · doublenullablevigorous_intensity_secondsinteger · int32nullablevo2_maxnumber · doublenullablevo2_max_cyclingnumber · doublenullableweight_gramsinteger · int32nullableproviderstringgarmin | coros | zepp | fitbit | suunto | whoop | apple — the providers that
send wellness. Which kinds each one sends is on /docs/providers.
received_atstring · date-timeWhen you were granted this summary — the axis this listing is ordered and
windowed on. Page against this, not calendar_date.
revisioninteger · int321 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-timeStart 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.
summaryobjectThe provider's own payload, minus its time series. Everything we did not promote to a metric, unmodified.
user_idstring · uuidOur id for the athlete. The event's user_id.
duration_secondsnumber · doublenullablefinalitystringnullablefinal | 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_idstringnullableThe 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-timenullableWhen a correction last touched a summary you already held, or null.
series_urlstringnullableGET /v1/wellness/{id}/series, or null when this measurement has no time
series — every fitness row, and every COROS row.
tz_offset_secondsinteger · int32nullableThe capturing device's UTC offset, in seconds — what turns start_time
into the wall-clock time the athlete experienced.
{
"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"
}List the health summaries you have been granted
Every sleep, daily, HRV, stress and fitness record one of your users was
connected to you for, newest first — the same rows that were announced to
your endpoint as wellness.created, in the same shape. This is the
reconciliation path: if a delivery was missed, this is how you find it.
Two different axes, and they answer different questions. calendar_date
(with from_date/to_date) is what the measurement is about, and it is
what you want for "show me last week's sleep". received_at (with
since/until) is when we told you, it is what this listing is ordered on,
and it is the only one that is monotonic — providers backfill and correct, so
a night from March can arrive today. Page and reconcile on received_at;
query and display on calendar_date.
Summaries change. Unlike an activity, a measurement can be revised after
you are told about it: Garmin sends a night tentatively and settles it hours
later. revision starts at 1 and increments whenever the payload actually
changed, and a revision arrives as wellness.updated rather than a second
wellness.created. Upsert on id and take the higher revision.
summary is the whole provider payload except its time series. The
minute-by-minute data — heart-rate samples, sleep stages, the stress curve —
is behind series_url, because it is around ninety percent of the bytes and
almost nothing needs it. A row whose series_url is null has none at all.
Access is the grant that was recorded when each summary arrived. Disconnecting stops new ones; it does not remove those from the period your user had agreed to share.
Query parameters
user_idstring · uuidNarrow to one athlete, by our id for them — what POST /v1/connect
returned and what events carry as user_id.
providerstringgarmin | coros | zepp | fitbit | suunto | whoop. An unknown provider is an empty
page, not a 400.
kindstringdaily | 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_datestringOnly summaries for this calendar day or later. Inclusive.
to_datestringOnly summaries for this calendar day or earlier. Inclusive, unlike
until — a developer asking for a month means its last day included.
sincestringOnly summaries granted at or after this RFC 3339 instant.
untilstringOnly 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 · uuidThe id of the last summary on the previous page. Omit for the first one.
limitinteger · int64Defaults to 50, capped at 200.
Returns
A page of Wellness objects.
Show WellnessPage attributesHide WellnessPage attributes
has_morebooleanWhether another page exists. Exact — it comes from a row we fetched and discarded rather than from arithmetic on a count.
wellnessWellnessView[]Show child attributesHide child attributes
calendar_datestring · dateThe day this belongs to, in the athlete's own local reckoning. The axis to
query on — from_date/to_date window it.
external_user_idstringYour id for the same person, so a row can be matched to your database without a call per summary.
idstring · uuidThe summary id — data.id on the event, and what
GET /v1/wellness/{id}/series takes.
kindstringdaily | 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.
metricsMetricsWhat was measured. Only the keys this kind carries; see Metrics.
Show child attributesHide child attributes
active_kilocaloriesnumber · doublenullableactive_time_secondsinteger · int32nullableavg_hrinteger · int32nullableavg_stress_levelinteger · int32nullableawake_secondsnumber · doublenullablebmr_kilocaloriesnumber · doublenullablebody_batteryinteger · int32nullablebody_battery_chargedinteger · int32nullablebody_battery_drainedinteger · int32nullablebody_fat_percentnumber · doublenullablebody_mass_indexnumber · doublenullablebody_water_percentnumber · doublenullablebone_mass_gramsinteger · int32nullabledeep_sleep_secondsnumber · doublenullablediastolicinteger · int32nullabledistance_metersnumber · doublenullableepoch_activity_typestringnullableepoch_intensitystringnullablefitness_ageinteger · int32nullablefitness_age_enhancedbooleannullablefloors_climbedinteger · int32nullablehrv_avginteger · int32nullablehrv_rmssdnumber · doublenullablehrv_sdrrnumber · doublenullablelight_sleep_secondsnumber · doublenullablemax_hrinteger · int32nullablemetnumber · doublenullablemin_hrinteger · int32nullablemoderate_intensity_secondsinteger · int32nullablemuscle_mass_gramsinteger · int32nullablenap_secondsnumber · doublenullablepulseinteger · int32nullablerem_sleep_secondsnumber · doublenullablerespiration_avgnumber · doublenullablerespiration_maxnumber · doublenullablerespiration_minnumber · doublenullableresting_hrinteger · int32nullableskin_temp_celsiusnumber · doublenullableskin_temp_deviation_celsiusnumber · doublenullablesleep_scoreinteger · int32nullablespo2_avgnumber · doublenullablespo2_minnumber · doublenullablespo2_on_demandbooleannullablestepsinteger · int32nullablesystolicinteger · int32nullabletotal_sleep_secondsnumber · doublenullablevigorous_intensity_secondsinteger · int32nullablevo2_maxnumber · doublenullablevo2_max_cyclingnumber · doublenullableweight_gramsinteger · int32nullableproviderstringgarmin | coros | zepp | fitbit | suunto | whoop | apple — the providers that
send wellness. Which kinds each one sends is on /docs/providers.
received_atstring · date-timeWhen you were granted this summary — the axis this listing is ordered and
windowed on. Page against this, not calendar_date.
revisioninteger · int321 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-timeStart 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.
summaryobjectThe provider's own payload, minus its time series. Everything we did not promote to a metric, unmodified.
user_idstring · uuidOur id for the athlete. The event's user_id.
duration_secondsnumber · doublenullablefinalitystringnullablefinal | 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_idstringnullableThe 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-timenullableWhen a correction last touched a summary you already held, or null.
series_urlstringnullableGET /v1/wellness/{id}/series, or null when this measurement has no time
series — every fitness row, and every COROS row.
tz_offset_secondsinteger · int32nullableThe capturing device's UTC offset, in seconds — what turns start_time
into the wall-clock time the athlete experienced.
next_starting_afterstring · uuidnullableWhat to send as starting_after for the next page. null when has_more
is false.
totalinteger · int64nullableHow many match the filter, on the first page only; null after it.
GET /v1/wellness HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:{
"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
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 · uuidRequiredThe summary, as data.id on wellness.created
Returns
GET /v1/wellness/3f07d5b2-9e14-42a8-8c63-1d5a0be93f72/series HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:{}Send a structured session to your athlete's watch, and know whether it landed.
Read the Workouts guide- Create a structured workout and push it to your user's devicepost /v1/workouts
- The workouts on this account, newest firstget /v1/workouts
- One workout, and where it got toget /v1/workouts/{id}
- Replace a workout and re-push itpatch /v1/workouts/{id}
- Delete a workout and take it off the athlete's devicesdelete /v1/workouts/{id}
A workout, and where it got to.
Attributes
created_atstring · date-timeidstring · uuidnamestringpushesPushView[]Show child attributesHide child attributes
providerstringstatusstringsynced — 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.
errorstringnullableOne sentence, for a person. Never parse it — branch on reason.
external_idstringnullableThe provider's own id, once there is one. Absent for pending.
pushed_atstring · date-timenullablereasonstringnullableWhy 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.
sportstringupdated_atstring · date-timeuser_idstring · uuiddescriptionstringnullablepool_lengthPoolLengthnullableThe 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 shapesHide possible shapes
Variant 1lengthnumber · doubleunitstring, value is "meters"Variant 2lengthnumber · doubleunitstring, value is "yards"scheduled_datestring · datenullablesegmentsanynullableThe segments as you sent them, for a multi_sport workout.
stepsanynullableThe 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.
{
"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"
}Create a structured workout and push it to your user's device
The workout is stored, then pushed, then answered for — so the pushes array
is what actually happened rather than what was intended.
Omit provider to reach every connected device that can carry the workout.
A provider that cannot represent it — Wahoo and a swim — comes back
unsupported beside the ones that took it, and the call still succeeds:
the workout exists and is on a watch. You get a non-2xx only when nothing
could have worked.
Apple Watch is reached through your own iOS app, so its entry comes back
pending and turns synced once the athlete's phone has scheduled it.
Body parameters
namestringRequiredsportSportRequiredrunningcyclingswimminghikingcardiohiityogapilatesgenericstrengthmulti_sportuser_idstring · uuidRequiredThe athlete, by our id for them — what POST /v1/connect returned and what
every event about them carries as user_id.
descriptionstringThe coach's notes, shown beside the workout. See WorkoutSpec::description.
pool_lengthPoolLengthThe 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 shapesHide possible shapes
Variant 1lengthnumber · doubleRequiredunitstring, value is "meters"RequiredVariant 2lengthnumber · doubleRequiredunitstring, value is "yards"RequiredproviderstringWhich 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 · dateThe 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 attributesHide child attributes
sportSportRequiredrunningcyclingswimminghikingcardiohiityogapilatesgenericstrengthmulti_sportstepsStep[]RequiredShow 9 possible shapesHide possible shapes
type is repeatA block run repeats times. Children may not themselves repeat — see
MAX_REPEAT_DEPTH.
repeatsinteger · int32RequiredstepsStep[]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.
type is repeat_untilA 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[]RequiredSee Step::Repeat on why no_recursion must stay.
Same Step shape as above.
untilRepeatUntilRequiredShow 10 possible shapesHide possible shapes
type is timesecondsinteger · int32Requiredtype is distancemetersinteger · int32Requiredtype is calorieskcalinteger · int32Requiredtype is heart_rate_belowbpminteger · int32Requiredtype is heart_rate_abovebpminteger · int32Requiredtype is power_belowwattsinteger · int32Requiredtype is power_abovewattsinteger · int32Requiredtype is power_last_lap_belowThe last lap's average power fell below this.
wattsinteger · int32Requiredtype is max_power_last_lap_belowThe last lap's maximum power fell below this.
wattsinteger · int32Requiredtype is tssTrainingPeaks TSS.
tssnumber · doubleRequiredtype is warmupdurationstringRequireddrillDrillTechnique work over the top of Interval::stroke. Swim-only, same rule.
kickpulldrillbutterflyequipmentEquipmentWhat 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.
finskickboardpaddlespull_buoysnorkelnamestringShown on the watch. Optional because most steps do not need one.
secondary_targetTargetA 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 shapesHide possible shapes
type is paceMinutes 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 · doubleRequiredlownumber · doubleRequiredaverageAverage3s10s30slaptype is heart_rateBeats per minute. average: "lap" holds the lap's average.
highnumber · doubleRequiredlownumber · doubleRequiredaverageAverage3s10s30slaptype is powerWatts. average smooths it — 3s, 10s, 30s or lap.
highnumber · doubleRequiredlownumber · doubleRequiredaverageAverage3s10s30slaptype is cadencehighnumber · doubleRequiredlownumber · doubleRequiredtype is heart_rate_zoneOne of the athlete's own heart-rate zones, 1–5, as set on their device.
zoneinteger · int32Requiredtype is power_zoneOne of the athlete's own power zones, 1–7, as set on their device.
zoneinteger · int32Requiredtype is heart_rate_percentPercent of maximum heart rate.
highnumber · doubleRequiredlownumber · doubleRequiredtype is power_percentPercent of FTP.
highnumber · doubleRequiredlownumber · doubleRequiredtype is gradePercent grade, for a treadmill or a smart trainer. May be negative.
highnumber · doubleRequiredlownumber · doubleRequiredtype is resistanceA trainer's resistance level.
highnumber · doubleRequiredlownumber · doubleRequiredtype is css_offsetSwim only: a pace relative to the swimmer's CSS, in seconds per 100, −60 to 60. Negative is faster.
secondsinteger · int32Requiredtype is swim_instructionSwim only: an effort in words rather than a number.
effortSwimEffortRequiredrecoveryvery_easyeasymoderatehardvery_hardall_outfastascenddescendstrokeStrokeWhat 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.
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoicetargetTargetAbsent 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.
type is activedurationstringRequireddrillDrillTechnique work over the top of Interval::stroke. Swim-only, same rule.
kickpulldrillbutterflyequipmentEquipmentWhat 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.
finskickboardpaddlespull_buoysnorkelnamestringShown on the watch. Optional because most steps do not need one.
secondary_targetTargetA 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.
strokeStrokeWhat 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.
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoicetargetTargetAbsent 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.
type is recoverydurationstringRequireddrillDrillTechnique work over the top of Interval::stroke. Swim-only, same rule.
kickpulldrillbutterflyequipmentEquipmentWhat 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.
finskickboardpaddlespull_buoysnorkelnamestringShown on the watch. Optional because most steps do not need one.
secondary_targetTargetA 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.
strokeStrokeWhat 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.
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoicetargetTargetAbsent 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.
type is restdurationstringRequireddrillDrillTechnique work over the top of Interval::stroke. Swim-only, same rule.
kickpulldrillbutterflyequipmentEquipmentWhat 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.
finskickboardpaddlespull_buoysnorkelnamestringShown on the watch. Optional because most steps do not need one.
secondary_targetTargetA 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.
strokeStrokeWhat 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.
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoicetargetTargetAbsent 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.
type is cooldowndurationstringRequireddrillDrillTechnique work over the top of Interval::stroke. Swim-only, same rule.
kickpulldrillbutterflyequipmentEquipmentWhat 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.
finskickboardpaddlespull_buoysnorkelnamestringShown on the watch. Optional because most steps do not need one.
secondary_targetTargetA 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.
strokeStrokeWhat 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.
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoicetargetTargetAbsent 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.
type is mainThe main set of a swim — Garmin's MAIN intensity, which it defines for
swims only. Refused on any other sport.
durationstringRequireddrillDrillTechnique work over the top of Interval::stroke. Swim-only, same rule.
kickpulldrillbutterflyequipmentEquipmentWhat 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.
finskickboardpaddlespull_buoysnorkelnamestringShown on the watch. Optional because most steps do not need one.
secondary_targetTargetA 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.
strokeStrokeWhat 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.
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoicetargetTargetAbsent 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.
type is exerciseOne strength exercise: "4 × 8 back squat at 80 kg, 2 min rest". Strength
only. May sit inside a repeat, which makes a circuit.
namestringRequiredWhat 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 · int32RequiredReps in each set.
setsinteger · int32RequirednotestringA coaching cue — "pause at the bottom". Shown with the name.
rest_secondsinteger · int32Rest after each set. Absent for none.
weight_kgnumber · doubleThe load, in kilograms. Absent for bodyweight.
pool_lengthPoolLengthSame PoolLength shape as above.
stepsStep[]Every sport but multi_sport.
Same Step shape as above.
Returns
The Workout object.
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"
}{
"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 workouts on this account, newest first
Ordered and paged on when the workout was created, not when it is
scheduled: the scheduled date is content, it can be edited or absent, and a
cursor over a value that moves either serves rows twice or steps over them.
from and to still filter on it, because "next week" is the question a
calendar asks.
Query parameters
user_idstring · uuidOne athlete, by our id for them.
fromstring · dateScheduled on or after this day, YYYY-MM-DD.
tostring · dateScheduled on or before this day. Inclusive — a calendar day is a unit you name, not a boundary between windows.
starting_afterstring · uuidThe id of the last workout on the previous page.
limitinteger · int64Defaults to 50, capped at 200.
Returns
A page of Workout objects.
Show WorkoutsPage attributesHide WorkoutsPage attributes
has_morebooleanWhether another page exists. Exact — it comes from a row we fetched and discarded, not from arithmetic on a count.
workoutsWorkoutView[]Show child attributesHide child attributes
created_atstring · date-timeidstring · uuidnamestringpushesPushView[]Show child attributesHide child attributes
providerstringstatusstringsynced — 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.
errorstringnullableOne sentence, for a person. Never parse it — branch on reason.
external_idstringnullableThe provider's own id, once there is one. Absent for pending.
pushed_atstring · date-timenullablereasonstringnullableWhy 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.
sportstringupdated_atstring · date-timeuser_idstring · uuiddescriptionstringnullablepool_lengthPoolLengthnullableThe 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 shapesHide possible shapes
Variant 1lengthnumber · doubleunitstring, value is "meters"Variant 2lengthnumber · doubleunitstring, value is "yards"scheduled_datestring · datenullablesegmentsanynullableThe segments as you sent them, for a multi_sport workout.
stepsanynullableThe 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 · uuidnullableWhat to send as starting_after next. null when has_more is false.
totalinteger · int64nullableHow many match the filter, on the first page only.
GET /v1/workouts HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:{
"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"
}
]
}One workout, and where it got to
Path parameters
idstring · uuidRequiredThe workout id
Returns
The Workout object.
GET /v1/workouts/0f31a8c4-59d2-4e07-b6a1-8c74e2f95d30 HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:{
"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
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 · uuidRequiredThe workout id
Body parameters
namestringRequiredsportSportRequiredrunningcyclingswimminghikingcardiohiityogapilatesgenericstrengthmulti_sportdescriptionstringA whole-document replace, like everything here: omitting it clears it.
pool_lengthPoolLengthSee 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 shapesHide possible shapes
Variant 1lengthnumber · doubleRequiredunitstring, value is "meters"RequiredVariant 2lengthnumber · doubleRequiredunitstring, value is "yards"Requiredscheduled_datestring · dateSettable to null, unlike on create: that is how a workout comes off the
calendar and stays in the library.
segmentsSegment[]Show child attributesHide child attributes
sportSportRequiredrunningcyclingswimminghikingcardiohiityogapilatesgenericstrengthmulti_sportstepsStep[]RequiredShow 9 possible shapesHide possible shapes
type is repeatA block run repeats times. Children may not themselves repeat — see
MAX_REPEAT_DEPTH.
repeatsinteger · int32RequiredstepsStep[]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.
type is repeat_untilA 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[]RequiredSee Step::Repeat on why no_recursion must stay.
Same Step shape as above.
untilRepeatUntilRequiredShow 10 possible shapesHide possible shapes
type is timesecondsinteger · int32Requiredtype is distancemetersinteger · int32Requiredtype is calorieskcalinteger · int32Requiredtype is heart_rate_belowbpminteger · int32Requiredtype is heart_rate_abovebpminteger · int32Requiredtype is power_belowwattsinteger · int32Requiredtype is power_abovewattsinteger · int32Requiredtype is power_last_lap_belowThe last lap's average power fell below this.
wattsinteger · int32Requiredtype is max_power_last_lap_belowThe last lap's maximum power fell below this.
wattsinteger · int32Requiredtype is tssTrainingPeaks TSS.
tssnumber · doubleRequiredtype is warmupdurationstringRequireddrillDrillTechnique work over the top of Interval::stroke. Swim-only, same rule.
kickpulldrillbutterflyequipmentEquipmentWhat 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.
finskickboardpaddlespull_buoysnorkelnamestringShown on the watch. Optional because most steps do not need one.
secondary_targetTargetA 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 shapesHide possible shapes
type is paceMinutes 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 · doubleRequiredlownumber · doubleRequiredaverageAverage3s10s30slaptype is heart_rateBeats per minute. average: "lap" holds the lap's average.
highnumber · doubleRequiredlownumber · doubleRequiredaverageAverage3s10s30slaptype is powerWatts. average smooths it — 3s, 10s, 30s or lap.
highnumber · doubleRequiredlownumber · doubleRequiredaverageAverage3s10s30slaptype is cadencehighnumber · doubleRequiredlownumber · doubleRequiredtype is heart_rate_zoneOne of the athlete's own heart-rate zones, 1–5, as set on their device.
zoneinteger · int32Requiredtype is power_zoneOne of the athlete's own power zones, 1–7, as set on their device.
zoneinteger · int32Requiredtype is heart_rate_percentPercent of maximum heart rate.
highnumber · doubleRequiredlownumber · doubleRequiredtype is power_percentPercent of FTP.
highnumber · doubleRequiredlownumber · doubleRequiredtype is gradePercent grade, for a treadmill or a smart trainer. May be negative.
highnumber · doubleRequiredlownumber · doubleRequiredtype is resistanceA trainer's resistance level.
highnumber · doubleRequiredlownumber · doubleRequiredtype is css_offsetSwim only: a pace relative to the swimmer's CSS, in seconds per 100, −60 to 60. Negative is faster.
secondsinteger · int32Requiredtype is swim_instructionSwim only: an effort in words rather than a number.
effortSwimEffortRequiredrecoveryvery_easyeasymoderatehardvery_hardall_outfastascenddescendstrokeStrokeWhat 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.
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoicetargetTargetAbsent 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.
type is activedurationstringRequireddrillDrillTechnique work over the top of Interval::stroke. Swim-only, same rule.
kickpulldrillbutterflyequipmentEquipmentWhat 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.
finskickboardpaddlespull_buoysnorkelnamestringShown on the watch. Optional because most steps do not need one.
secondary_targetTargetA 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.
strokeStrokeWhat 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.
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoicetargetTargetAbsent 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.
type is recoverydurationstringRequireddrillDrillTechnique work over the top of Interval::stroke. Swim-only, same rule.
kickpulldrillbutterflyequipmentEquipmentWhat 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.
finskickboardpaddlespull_buoysnorkelnamestringShown on the watch. Optional because most steps do not need one.
secondary_targetTargetA 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.
strokeStrokeWhat 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.
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoicetargetTargetAbsent 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.
type is restdurationstringRequireddrillDrillTechnique work over the top of Interval::stroke. Swim-only, same rule.
kickpulldrillbutterflyequipmentEquipmentWhat 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.
finskickboardpaddlespull_buoysnorkelnamestringShown on the watch. Optional because most steps do not need one.
secondary_targetTargetA 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.
strokeStrokeWhat 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.
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoicetargetTargetAbsent 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.
type is cooldowndurationstringRequireddrillDrillTechnique work over the top of Interval::stroke. Swim-only, same rule.
kickpulldrillbutterflyequipmentEquipmentWhat 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.
finskickboardpaddlespull_buoysnorkelnamestringShown on the watch. Optional because most steps do not need one.
secondary_targetTargetA 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.
strokeStrokeWhat 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.
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoicetargetTargetAbsent 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.
type is mainThe main set of a swim — Garmin's MAIN intensity, which it defines for
swims only. Refused on any other sport.
durationstringRequireddrillDrillTechnique work over the top of Interval::stroke. Swim-only, same rule.
kickpulldrillbutterflyequipmentEquipmentWhat 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.
finskickboardpaddlespull_buoysnorkelnamestringShown on the watch. Optional because most steps do not need one.
secondary_targetTargetA 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.
strokeStrokeWhat 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.
freestylebackstrokebreaststrokebutterflyindividual_medleyreverse_individual_medleyindividual_medley_by_roundreverse_individual_medley_by_rounddrillmixedchoicetargetTargetAbsent 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.
type is exerciseOne strength exercise: "4 × 8 back squat at 80 kg, 2 min rest". Strength
only. May sit inside a repeat, which makes a circuit.
namestringRequiredWhat 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 · int32RequiredReps in each set.
setsinteger · int32RequirednotestringA coaching cue — "pause at the bottom". Shown with the name.
rest_secondsinteger · int32Rest after each set. Absent for none.
weight_kgnumber · doubleThe load, in kilograms. Absent for bodyweight.
pool_lengthPoolLengthSame PoolLength shape as above.
stepsStep[]Same Step shape as above.
Returns
The Workout object.
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"
}
]
}{
"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"
}Delete a workout and take it off the athlete's devices
The provider objects go first, because the rows are the only record of what to remove — dropping them first would leave a workout on a watch with nothing left that knows about it. A provider that refuses the delete does not stop ours: the alternative is a workout nobody can remove because one provider is having a bad day.
An Apple Watch copy comes off at the athlete's phone's next sync.
Path parameters
idstring · uuidRequiredThe workout id
Returns
DELETE /v1/workouts/0f31a8c4-59d2-4e07-b6a1-8c74e2f95d30 HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:HTTP/1.1 204 No ContentSend a route to your athlete's device as a course to follow, and know whether it landed.
Read the Routes guide- Create a route and push it to your user's devicepost /v1/routes
- The routes on this account, newest firstget /v1/routes
- One route, its points, and where it got toget /v1/routes/{id}
- Replace a route and re-push itpatch /v1/routes/{id}
- Delete a route and take it off the athlete's devicesdelete /v1/routes/{id}
A route, and where it got to.
Attributes
created_atstring · date-timeidstring · uuidnamestringpushesRoutePushView[]Show child attributesHide child attributes
providerstringstatusstringsynced, unsupported or failed. Routes have no pending today —
Garmin's course upload is one call that either lands or does not.
errorstringnullableOne sentence, for a person. Branch on reason.
external_idstringnullableThe provider's own id — Garmin's courseId.
pushed_atstring · date-timenullablereasonstringnullablenot_connected, not_permitted (Garmin's Course Import was not granted),
unsupported or provider_error. Absent on a clean push.
updated_atstring · date-timeuser_idstring · uuiddescriptionstringnullabledistancenumber · doublenullableMetres along the track, computed by us.
elevation_gainnumber · doublenullableMetres climbed, computed by us. Absent without elevation.
pointsPoint[]nullableThe 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 attributesHide child attributes
latnumber · doubleDegrees, WGS 84.
lngnumber · doubleDegrees, WGS 84.
elevationnumber · doublenullableMetres 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.
sportRouteSportnullableAbsent when you sent none, or for a first-party route whose sport is not one this contract names.
runningtrail_runningwalkinghikingcyclingroad_cyclingmountain_bikinggravel_cycling{
"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"
}Create a route and push it to your user's device
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
filestringRequiredThe route: a GPX, TCX or FIT file, standard base64, up to 10 MB before encoding. The format is read from the content.
user_idstring · uuidRequiredThe athlete, by our id for them.
descriptionstringDefaults to the file's own (GPX <desc>, a TCX course's <Notes>).
namestringWhat the athlete sees on the device. Defaults to the name in the file; required when the file has none.
providerstringWhich provider to push to. Omit to push to every connected provider that takes routes.
sportRouteSportDefaults to the sport the file names, when it is one of these. Neither, and the device shows the course as "other".
runningtrail_runningwalkinghikingcyclingroad_cyclingmountain_bikinggravel_cyclingReturns
The Route object.
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"
}{
"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 routes on this account, newest first
Without points — fetch one route for its path. Everything else is here.
Query parameters
user_idstring · uuidOne athlete, by our id for them.
starting_afterstring · uuidThe id of the last route on the previous page.
limitinteger · int64Defaults to 50, capped at 200.
Returns
A page of Route objects.
Show RoutesPage attributesHide RoutesPage attributes
has_morebooleanroutesRouteView[]Show child attributesHide child attributes
created_atstring · date-timeidstring · uuidnamestringpushesRoutePushView[]Show child attributesHide child attributes
providerstringstatusstringsynced, unsupported or failed. Routes have no pending today —
Garmin's course upload is one call that either lands or does not.
errorstringnullableOne sentence, for a person. Branch on reason.
external_idstringnullableThe provider's own id — Garmin's courseId.
pushed_atstring · date-timenullablereasonstringnullablenot_connected, not_permitted (Garmin's Course Import was not granted),
unsupported or provider_error. Absent on a clean push.
updated_atstring · date-timeuser_idstring · uuiddescriptionstringnullabledistancenumber · doublenullableMetres along the track, computed by us.
elevation_gainnumber · doublenullableMetres climbed, computed by us. Absent without elevation.
pointsPoint[]nullableThe 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 attributesHide child attributes
latnumber · doubleDegrees, WGS 84.
lngnumber · doubleDegrees, WGS 84.
elevationnumber · doublenullableMetres 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.
sportRouteSportnullableAbsent when you sent none, or for a first-party route whose sport is not one this contract names.
runningtrail_runningwalkinghikingcyclingroad_cyclingmountain_bikinggravel_cyclingnext_starting_afterstring · uuidnullabletotalinteger · int64nullableHow many match the filter, on the first page only.
GET /v1/routes HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:{
"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
}One route, its points, and where it got to
Path parameters
idstring · uuidRequiredThe route id
Returns
The Route object.
GET /v1/routes/e2a47c19-3b8d-4f05-9a61-d7c3e80b5f24 HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:{
"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
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 · uuidRequiredThe route id
Body parameters
filestringRequiredThe route: a GPX, TCX or FIT file, standard base64, up to 10 MB before encoding. The format is read from the content.
descriptionstringDefaults to the file's own (GPX <desc>, a TCX course's <Notes>).
namestringWhat the athlete sees on the device. Defaults to the name in the file; required when the file has none.
sportRouteSportDefaults to the sport the file names, when it is one of these. Neither, and the device shows the course as "other".
runningtrail_runningwalkinghikingcyclingroad_cyclingmountain_bikinggravel_cyclingReturns
The Route object.
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
}{
"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"
}Delete a route and take it off the athlete's devices
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 · uuidRequiredThe route id
Returns
DELETE /v1/routes/e2a47c19-3b8d-4f05-9a61-d7c3e80b5f24 HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:HTTP/1.1 204 No ContentApple Watch and iPhone workouts and wellness from HealthKit, pushed from your own iOS app by the StrideeHealth SDK and delivered like any other provider's — and the workouts you push, scheduled on the watch by the same SDK.
Read the Apple Health guide- Mint a device tokenpost /v1/device-tokens
- The workouts that should be on this athlete's Apple Watchget /v1/device/apple/scheduled-workouts
- Report what happened to one scheduled workoutput /v1/device/apple/scheduled-workouts/{id}
- Push Apple Health wellness data from the athlete's devicepost /v1/device/apple/wellness
- Say whether this device can schedule workoutsput /v1/device/apple/workout-scheduling
- Push a HealthKit workout from the athlete's devicepost /v1/device/apple/workouts
- Whether this device's user is connectedget /v1/device/connections/{provider}
- Connect this device's userput /v1/device/connections/{provider}
- Disconnect this device's userdelete /v1/device/connections/{provider}
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-timeWhen 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_idstringThe developer's own id for this person — echoed back so a list response is legible against their database without a second lookup.
idstring · uuidWhat DELETE /v1/connections/{id} takes. Ours, and the id the platform's
events name in connection_id.
providerstringWhich integration this authorization is at — coros, polar, wahoo or
garmin.
statusstringactive | 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 · uuidOur 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-timenullableAlways 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.
scopestringnullableWhat the athlete consented to, as the provider worded it. Absent when the provider returned none — not every one of them says.
{
"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
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_idstringRequiredYour 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
Show DeviceTokenResponse attributesHide DeviceTokenResponse attributes
expires_atstring · date-timeWhen the token stops working, an hour from now. The SDK asks for a fresh one on its own when it gets there.
tokenstringHand 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 · uuidOur id for the person, stable across calls for the same external_user_id.
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"
}{
"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
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
Show ScheduledWorkouts attributesHide 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 attributesHide child attributes
idstring · uuidThe workout, as POST /v1/workouts returned it. What the report names.
plan_idstring · uuidThe 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.
revisionstringWhich version of the workout this is. Opaque; send it back unchanged in the report.
scheduled_datestring · dateThe athlete's calendar day, YYYY-MM-DD.
statusstringWhere the push stands: pending, synced or failed.
workoutWorkoutKitWorkoutThe workout itself, as workoutkit.v1.
Show child attributesHide child attributes
activitystringAn HKWorkoutActivityType case, by name: running, cycling,
swimming, hiking, walking, rowing, mixedCardio,
highIntensityIntervalTraining, yoga, pilates or other.
blocksWorkoutKitBlock[]Show child attributesHide child attributes
iterationsinteger · int32stepsWorkoutKitStep[]Show child attributesHide child attributes
goalWorkoutKitGoalpurposestringwork or recovery. A warm-up or cool-down has no purpose of its own;
the field is there so every step is one shape.
alertWorkoutKitAlertnullabledisplayNamestringnullableShown on the watch from watchOS 11; ignored before.
displayNamestringWhat the Workout app lists it as.
formatstringAlways workoutkit.v1.
locationstringoutdoor, indoor or unknown.
cooldownWorkoutKitStepnullableSame WorkoutKitStep shape as above.
warmupWorkoutKitStepnullableSame WorkoutKitStep shape as above.
reasonstringnullableWhy it is failed, when it is.
GET /v1/device/apple/scheduled-workouts HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:{
"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
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 · uuidRequiredThe workout id
Body parameters
outcomeScheduleOutcomeRequiredsyncednot_permittedunsupportedfailedrevisionstringRequiredThe revision the workout was listed with.
errorstringOne sentence about why, for unsupported and failed. Shown to you as
the push's error.
Returns
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"
}HTTP/1.1 204 No ContentPush Apple Health wellness data from the athlete's device
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[]RequiredOne object per measurement. Every item carries kind, calendarDate
and startTime; the rest depends on the kind.
formatstringhealthkit.wellness.v1.
Returns
Show WellnessAccepted attributesHide WellnessAccepted attributes
itemsintegerHow many items the batch carried. All of them were accepted — a batch with one bad item is refused whole, with the item named.
statusstringAlways accepted. Each item becomes a wellness.created (or
wellness.updated, if it revised one you already have) on your webhook.
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
]
}{
"items": 0,
"status": "active"
}Say whether this device can schedule workouts
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
authorizationSchedulingAuthorizationRequiredauthorizeddeniedrestrictednot_determinedReturns
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"
}HTTP/1.1 204 No ContentPush a HealthKit workout from the athlete's device
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 · int32RequiredHKWorkoutActivityType's raw value, kept for reference.
durationnumber · doubleRequiredSeconds, as HKWorkout.duration reports it (paused time excluded).
endDatestring · date-timeRequiredserverTypestringRequiredThe 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-timeRequireduuidstringRequiredThe HKWorkout's UUID. Its identity: sending the same one twice
delivers it once.
cyclingCadenceRawSample[]Revolutions per minute.
Show child attributesHide child attributes
tstring · date-timeRequiredWhen it was measured.
vnumber · doubleRequiredThe value, in the stream's unit.
cyclingPowerRawSample[]Watts.
Same RawSample shape as above.
deviceNamestringThe recording device's name or model, when HealthKit has one.
formatstringhealthkit.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 attributesHide child attributes
latnumber · doubleRequiredDegrees.
lonnumber · doubleRequiredDegrees.
tstring · date-timeRequiredaltitudenumber · doubleMeters above sea level.
coursenumber · doubleDegrees from true north.
horizontalAccuracynumber · doubleMeters.
speednumber · doubleMeters per second.
verticalAccuracynumber · doubleMeters.
runningPowerRawSample[]Watts.
Same RawSample shape as above.
sourceBundleIdstringThat app's bundle id — com.apple.health for an Apple Watch workout.
sourceNamestringThe app that wrote the workout into HealthKit, by name.
totalDistanceMetersnumber · doubletotalEnergyKcalnumber · doubleActive energy, kilocalories.
workoutPlanIdstringThe 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
Show WorkoutAccepted attributesHide WorkoutAccepted attributes
statusstringAlways accepted. The workout is stored and queued; activity.created
follows on your webhook once its FIT file exists, usually within seconds.
uuidstring · uuidThe HealthKit workout's UUID, as received.
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
}{
"status": "active",
"uuid": "string"
}Whether this device's user is connected
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
providerstringRequiredapple
Returns
The Connection object.
GET /v1/device/connections/{provider} HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:{
"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
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
providerstringRequiredapple
Returns
The Connection object.
The Connection object.
PUT /v1/device/connections/{provider} HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:{
"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
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
providerstringRequiredapple
Returns
DELETE /v1/device/connections/{provider} HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:HTTP/1.1 204 No ContentRequests are authenticated with an Ed25519 signature over the request itself — RFC 9421 HTTP Message Signatures. There is no API key.
Read the Signing requests guideIt 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
Show Whoami attributesHide Whoami attributes
account_idstring · uuidThe account the signature authenticated as.
GET /v1/whoami HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:{
"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.