Connecting a device
Link one of your users to their watch, without holding a provider client secret or implementing a callback.
Your user has a Garmin watch. You want their workouts. The version of this you would build yourself starts with a partner application, waits weeks for approval, and ends with a client secret in your environment, a PKCE verifier to keep somewhere both halves of the flow can reach, and an OAuth callback in your router — repeated per provider, each with its own quirks.
Here it is three calls, and none of them involve a provider credential. The registration is ours: one client per provider, one callback URL, one consent screen we host the redirect for. What you get back is your user, linked.
Who's who
Three parties, and keeping them apart is most of understanding this page.
| You | The developer. Your Stridee account, your keys, your webhook endpoints. |
| Your user | An athlete who signed up for your product. They have no Stridee account and will never be asked for one. |
| The connection | One of your users' authorization at one provider. |
You refer to your users by your own id — whatever your database already calls them.
That's the only identifier you send and the only one you have to store. We mint a user_id
for our own use and hand it back, but nothing breaks if you throw it away: it's on every
connection you list, and GET /v1/accounts looks it up from your id.
1. Register a return URI
Where your user's browser should land once they've finished consenting. Add it under
Return URIs in the console before your first connect call.
It's matched as a prefix, so register the stem once and vary the rest per user:
https://app.yourapp.com/settings/devicesHTTPS only, no wildcards, and it has to name a host. We won't send your users to a URL you haven't claimed — an endpoint that takes an arbitrary URL from a caller and redirects a browser to it is a phishing link wearing our domain.
Skipping this step is allowed. Your users then finish on a plain page we host that tells them the connection worked and nothing else — fine for a first integration, not what you want in production.
2. Start a link
POST /v1/connect HTTP/1.1
Host: api.stridee.fit
Content-Type: application/json
{
"provider": "coros",
"external_user_id": "user_4821",
"return_uri": "https://app.yourapp.com/settings/devices"
}{
"connect_url": "https://api.stridee.fit/connections/start?state=…",
"user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6",
"expires_at": "2026-08-05T11:44:02Z"
}Redirect your user's browser to connect_url. It's a screen a person has to read — don't
fetch it server-side, and don't cache it. It stops working after 30 minutes, matching how
long the provider's own authorization code stays good.
connect_url is a page we host, not the provider's consent screen. It names your product,
names us, and carries our terms; one click later your user is at the provider and the rest
of the flow is exactly as documented below.
That page exists because your user is about to authorize their heart rate, sleep and GPS traces flowing through infrastructure they have never heard of, and every provider agreement behind this API assumes they were told who receives it. We are the recipient, so the disclosure is ours to make rather than yours — which is also why there's no setting to turn it off.
The name your user reads on it is the one on your Stridee account. If yours is blank the page still works and simply names no product — worth filling in before your first real user.
Call this twice for the same external_user_id and you get the same user_id back rather
than a second identity. That's what makes retrying safe.
coros, polar, wahoo and garmin today. Naming any other one is a 400, not a 404 —
the path exists, the argument doesn't.
Everything below is the same regardless of which one you named. That's the point of the endpoint: the differences between these four — one issues a refresh token, one rotates both every two hours, one issues neither and never expires, one requires PKCE and reports what the athlete granted through a separate call that can change afterwards — are ours to carry, and none of them reach your integration.
3. Your user comes back
They approve, and we send them to your return_uri with two parameters appended:
https://app.yourapp.com/settings/devices?status=success&user_id=4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6status | |
|---|---|
success | Connected. The tokens are stored and data will start arriving. |
denied | They declined at the provider. Nothing was linked and nothing was shared. |
error | Something went wrong on our side or the provider's. Nothing was saved. |
Handle all three. denied in particular is a normal outcome, not a failure — a user who
changed their mind on the consent screen should see something other than an error page.
Don't treat this redirect as the source of truth. It tells you where to send the browser
next; the account.connected event is what tells your backend the connection exists.
The two are independent on purpose — a user who closes the tab mid-redirect still connected.
4. Listen for the event
{
"id": "9f2c1e7a-4b83-4d21-9a6e-3c5f0d8b71a4",
"type": "account.connected",
"created": "2026-08-05T11:16:44Z",
"webhook_id": "2d7b45c1-8e0a-4f36-b512-9c7d3a6e04f8",
"nonce": "Kd4nWpLbEa9xTvRm2Cj7Lz0Bq2vNhCz7",
"user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6",
"provider": "coros",
"data": {
"connection_id": "a2f81b60-3c47-49d5-b8e2-70a4c9f13d85"
}
}user_id is the value /v1/connect returned, which is how you map it back to your own
user. See Events for the envelope this arrives in and
Webhooks for the handler.
Their workouts arrive as activity.created from then on, carrying the provider's own
summary and a link to the recording:
{
"type": "activity.created",
"user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6",
"provider": "coros",
"data": {
"object": "activity",
"id": "0f31a8c4-59d2-4e07-b6a1-8c74e2f95d30",
"provider_activity_id": "465061532017233920",
"sport": "run",
"start_time": "2026-08-04T06:12:00Z",
"device": "PACE 3",
"name": "Morning Run",
"file": {
"format": "fit",
"url": "https://api.stridee.fit/v1/activities/0f31a8c4-59d2-4e07-b6a1-8c74e2f95d30/file"
}
}
}The recording is not in the body — file.url is a signed call that 302s to a short-lived
download. See Getting the file for what it answers and
when file is null.
Which activities that covers is decided by this connection. A grant is recorded when the activity arrives, so a user who disconnects and reconnects later leaves a gap that is not yours — and one who disconnects for good does not take away the files they had already agreed to share.
Reading connections back
GET /v1/connections?external_user_id=user_4821 HTTP/1.1[
{
"id": "a2f81b60-3c47-49d5-b8e2-70a4c9f13d85",
"user_id": "6d3f0c11-90ab-4e77-9c25-1b8f5a2e40d9",
"external_user_id": "user_4821",
"provider": "coros",
"status": "active",
"scope": null,
"connected_at": "2026-08-05T11:16:44Z",
"revoked_at": null
}
]Omit the query parameter for every connection on your account. Only live connections are
returned — a disconnected one leaves the list rather than appearing with a revoked status.
status is active or reauth_required. The second means the provider stopped accepting
our credentials, which is not the same as the user leaving: ask them to reconnect rather
than forgetting them. You'll also get an account.reauth_required event carrying the
same connection_id, which is the moment to prompt — there's no callback from the provider
when someone deauthorizes in their app, so that event is the only notice anyone gets.
scope is what the athlete actually granted, verbatim from the provider, or null where
the provider doesn't report it. Wahoo does. COROS and Polar don't — and it's left null
rather than filled with the scopes we requested, because a value you read off this field
should be something the provider confirmed.
What you don't get
The provider's own id for the athlete — COROS's openId, Polar's x_user_id, Wahoo's user
id — is never in a response. We hold it because that person stood in front of a consent
screen we hosted; handing it to you would let you and another developer who both serve them
join your user tables against each other. They asked to sync workouts, not to be correlated.
Disconnecting
DELETE /v1/connections/a2f81b60-3c47-49d5-b8e2-70a4c9f13d85 HTTP/1.1204 on success, 404 if it isn't yours or is already disconnected. Tokens are dropped
immediately and an account.disconnected event goes out — even though you're the one who
asked for it, because the fleet of workers behind your endpoint doesn't know what your API
client just did.
What this does not do
It does not withdraw your user's authorization at the provider — unless yours was the last connection they had through Stridee.
This is worth reading twice. There is one Stridee registration per provider, so an athlete who uses your product and another product built on Stridee has one authorization at that provider backing both. Revoking it on your behalf would stop that other developer's sync with nothing in their event stream to explain it. So your access ends immediately and always; the underlying grant is only torn down once nobody is left holding it.
Wahoo makes this concrete: its deauthorization ends every grant an athlete has given our app at once, and its webhooks are app-wide, so a disconnect that ran it eagerly would take down every other developer serving that person. Polar's deregistration works the same way.
The practical version: disconnecting is safe, and it means what you'd want it to mean.
Letting your user do it
If you'd rather not build a settings screen, mint them a link:
POST /v1/connections/manage-link HTTP/1.1
{ "external_user_id": "user_4821" }{
"manage_url": "https://api.stridee.fit/connections/manage?token=…",
"expires_at": "2026-09-04T11:16:44Z"
}It opens a page listing that person's connections with you, each with a disconnect button. It shows nothing about any other product they may also have connected through Stridee, and disconnecting there follows the same last-connection rule as the API.
The link is the credential — there's nobody to log in as — so treat it like a password reset link rather than a page URL. It lasts 30 days; mint a fresh one per visit rather than storing it.
Disconnections made this way arrive as account.disconnected with "revoked_by": "end_user" in the data block, so you can tell them from your own calls.
Your users
Everything above is about connections — one person's authorization at one provider.
/v1/accounts is the other axis: one row per person, whether or not they ever connected
anything.
GET /v1/accounts?external_user_id=user_4821 HTTP/1.1{
"accounts": [
{
"id": "6d3f0c11-90ab-4e77-9c25-1b8f5a2e40d9",
"external_user_id": "user_4821",
"providers": ["coros"],
"status": "active",
"connected_at": "2026-08-05T11:16:44Z",
"activities": 47,
"last_event_at": "2026-08-07T06:02:11Z",
"created_at": "2026-08-05T11:14:02Z"
}
],
"total": 1
}Omit the filter for a page of everyone (limit defaults to 50, caps at 200). status rolls
up their connections: active, reauth_required, revoked, or pending — somebody you
sent a link who never came back through it. That last one is the reason this endpoint exists
alongside GET /v1/connections: a pending user has no connection, so they appear in no
connection list, and they're exactly the person worth chasing.
GET /v1/accounts/{id} returns one of them with their full connection list — including
revoked connections, unlike /v1/connections — and their recent events.
Changing your id for someone
Your user table got re-keyed and user_4821 is now usr_01J9X…. Tell us:
PATCH /v1/accounts/6d3f0c11-90ab-4e77-9c25-1b8f5a2e40d9 HTTP/1.1
{ "external_user_id": "usr_01J9X..." }Nothing moves. Our user_id is unchanged, so every connection, activity grant and past
event stays attached to the same person, and nobody goes back through a consent screen. The
next connect call with the new id finds them instead of creating a second person.
409 if another of your users already has that id. That would be a merge — two sets of
connections at the same provider, two histories — and this API won't guess at it. Delete
whichever reference you don't want, or pick a different id.
Forgetting a user
DELETE /v1/accounts/6d3f0c11-90ab-4e77-9c25-1b8f5a2e40d9 HTTP/1.1204, and your reference to that person is gone: your id for them, their connections
through you, the consents behind those, any outstanding management link, their event history
on your stream, and your permission to download the activities they shared with you.
This does not delete a person. It can't — you never had one. The athlete, their Stridee
account if they have one, and every workout file they ever uploaded are untouched, as is any
other developer's separate reference to the same human, which you could never see and
still can't. What you're deleting is the row that says "this developer calls that person
user_4821", and everything hanging off it.
Live connections are revoked on the way out, under the same last-connection rule as
DELETE /v1/connections/{id} — so the provider-side grant is only torn down if yours was the
last one standing.
You get one account.deleted event, not an account.disconnected per connection. A
handler that saw four disconnections and then silence couldn't tell that from someone who
merely unplugged every watch, which is the distinction the whole call is for. It carries the
ids in data and has no user_id of its own — the user it would name doesn't exist any
more:
{
"user_id": "6d3f0c11-90ab-4e77-9c25-1b8f5a2e40d9",
"external_user_id": "user_4821",
"connections_revoked": ["a2f81b60-3c47-49d5-b8e2-70a4c9f13d85"]
}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.
Adding a second provider
Nothing above changes. Each one is another value for provider, and the same person
connecting two of them is one user with two connections — provided you send the same
external_user_id both times.
That last part is on you. Your ids are opaque to us by design, so user_4821 and 4821 are
two different people as far as this API can tell, and nothing downstream can spot the
mistake. Pick one form per user and keep it.
Something wrong or missing on this page? Tell us in Discord.