Connect 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 and you want their workouts. Built yourself, that is a partner application, weeks of approval, a client secret, a PKCE verifier and an OAuth callback — 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. 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 is the only identifier you send and the only one you have to store. We mint a user_id
and hand it back, but nothing breaks if you throw it away: it is 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 have finished consenting. Add it under
Return URIs in the console before your first connect call.
It is matched as a prefix, so register the stem once and vary the rest per user:
HTTPS, no wildcards, and it has to name a host — we won't send your users to a URL you haven't claimed. Skip this step and your users finish on a plain page we host that says the connection worked: fine for a first try, not for production.
Register as many as you need — one per environment, and one per app. They are an allowlist,
not a default: nothing is used unless you pass it as return_uri on the call below.
https://app.yourapp.com/settings/devicesMobile apps
Register your app's own scheme, in reverse-domain form, alongside your web URL:
Open connect_url in ASWebAuthenticationSession on iOS or a Custom Tab on Android, and
pass this as return_uri from your mobile flow. When consent is done we redirect to it, the
browser sheet closes, and your app reads status and user_id from the URL.
The scheme has to be lowercase, contain at least one dot, and be followed by a path.
Single-word schemes like myapp:// are refused: any other app on the phone can claim one.
The redirect carries only status and user_id — no code or token — so it is safe to
deliver to a custom scheme.
com.yourapp.ios:/stridee-callback2. Start a link
Call this from your backend with your id for the user, then redirect their browser to the
connect_url it returns.
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.
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.
connect_url is a page we host, showing your logo beside ours and carrying our terms; one
click later your user is at the provider. That click is required and there is no setting to
skip it: your user is about to send their heart rate, sleep and GPS traces through
infrastructure they have never heard of, and every provider agreement behind this API
assumes they were told who receives it. Set the name and logo they see under
Branding, and the colours and corner shape of every page we host for them — it
previews the real pages as you type, on a phone and on a desktop.
provider is any of the nine on Provider support & limits that connect
here. Everything below is the same whichever you name — the differences in how each provider
handles tokens and consent are ours to carry. Apple Health is the one that doesn't: it has
no consent page, and apple here is a 400. Your iOS app connects it instead — see
Apple Health.
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"
}Language
The pages we host — this one, the confirmation after it, and the
manage page — are written in English, German, Danish,
Norwegian, Swedish, Polish, Czech, Slovak, Spanish, Chinese (Simplified and Traditional),
Korean and Japanese. Without lang they follow your user's browser language. Pass lang
when your app knows better: someone using your product in German on a phone set to English
should read German, and lang always wins.
Your user's locale can go in as it is. de-AT is German, es_MX is Spanish, no is read
as nb, and a language we don't have yet is treated as if you had passed nothing. Chinese
is the one language where the rest of the tag matters: zh-Hant, zh-TW, zh-HK and
zh-MO are Traditional, and every other zh tag is Simplified.
3. Your user comes back
They approve, and we send them to your return_uri with two parameters appended:
status | |
|---|---|
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 is a normal outcome, not a failure.
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. A
user who closes the tab mid-redirect still connected.
https://app.yourapp.com/settings/devices?status=success&user_id=4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f64. Get the event
With stridee listen running, this arrives on your local handler the
moment they finish:
user_id is the value /v1/connect returned, which is how you map it back to your own
user. From then on their workouts arrive as activity.created — see
Events for the shape and
Getting the file for the recording.
{
"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"
}
}After the link
Linking leaves you with two lists. Connections are one per authorization at one provider, so a user with two watches is two rows. Users are one per person, including people you sent a link to who never finished it. Everything you can do with either is in the API reference:
| To | Call |
|---|---|
| List who is connected | GET /v1/connections |
| Find a user by your id, or list everyone | GET /v1/accounts |
| Disconnect one provider | DELETE /v1/connections/{id} |
| Let your user manage their own connections | POST /v1/connections/manage-link |
| Change your id for someone | PATCH /v1/accounts/{id} |
| Forget a user and everything you hold about them | DELETE /v1/accounts/{id} |
Three things no single endpoint says:
reauth_requiredmeans reconnect, not forget. The provider stopped accepting our credentials. Theaccount.reauth_requiredevent is your cue, and the only notice anyone gets: start a new link for the sameexternal_user_id, which resolves to the same user.- A second provider is the same call. Call
/v1/connectagain with anotherproviderand the sameexternal_user_id: one user, two connections. Your ids are opaque to us, souser_4821and4821are two different people — pick one form and keep it. - The provider's own id for your user is never returned. Two developers serving the same person could otherwise join their user tables on it.
Something wrong or missing on this page? Tell us in Discord. Need something the API doesn’t do yet? Request it on the roadmap.