Skip to content

Self-hosted integrations

Beta

Self-hosted integrations are in beta and are subject to breaking changes. If you plan to use them in production, email [email protected] before you integrate, so we can tell you about changes in advance and help you migrate.

This page is the contract for a self-hosted integration: what your integration must implement so that athletes can sign in to your app through a wearable platform that your app connects to on its own keys.

A self-hosted integration is your own connection to a wearable platform, such as Garmin Connect. Your integration holds the platform's client secret and every athlete's platform tokens. The platform's consent screen shows your app's name, and the platform sends its webhooks to your domain. SweatStack holds no credential and no user id of that platform.

Towards SweatStack, your integration is an ordinary OpenID Provider, and SweatStack is its relying party. Everything else is optional and uses SweatStack's public API. If you have built "Sign in with" for any identity provider, you know most of this page already.

You can implement this contract in three ways:

  • Deploy the SweatStack bridge. It is SweatStack's open-source reference implementation, for Garmin Connect.
  • Add it to the platform code you already have. Your backend already talks to the platform. Add an OpenID Provider endpoint pair and the API calls.
  • Use an identity provider you already run. For sign-in only, a standard OpenID Provider such as Keycloak or Auth0 can be the issuer.

Standards this contract uses

Part Standard What SweatStack adds
Sign-in OpenID Connect Core 1.0, authorization code flow Nothing
Discovery OpenID Connect Discovery 1.0 One optional metadata member, sweatstack_disconnect_endpoint
Proof of the code PKCE, RFC 7636, S256 Nothing
Client registration Dynamic client registration, RFC 7591, with a software statement Nothing. Credentials entered by hand also work
Your app's own token OAuth 2.0 client credentials grant, RFC 6749 section 4.4 Nothing
Disconnect request Security Event Token, RFC 8417, with an iss_sub subject, RFC 9493 One event type
Data and connection state SweatStack's public API The connection call

What you build

An OpenID Provider is a complete integration. Each part after it is optional, and each part works without the parts after it.

Part Your integration does What it gives you
Sign-in Runs an OpenID Provider A button for the platform on your sign-in screen. SweatStack creates your app's users
Data Uploads files and dailies as the user The user's data in SweatStack
Connection state Tells SweatStack what each connection provides A correct account status and Portal, and the user's id before the browser returns
Disconnect Accepts a signed request A Disconnect button in the Portal, and connections that end when a user is deleted

If your app has its own sign-in, you need no OpenID Provider. See If your app has its own sign-in.

Workout fanout does not send workouts through a self-hosted integration yet. See Integrations.

Whose users these are

Every person who signs in through your issuer is a user that belongs to your app, as described under Who owns the athlete. SweatStack finds a user by your issuer and the sub of your ID token. One sub is one user of your app. SweatStack keeps that link when a connection ends, so if the same sub signs in again, it is the same user.

Use one sub per platform account, and never the platform's own user id. A keyed hash of the platform's user id is a good sub: it is stable, and it reveals nothing.

Set it up

  1. Take your app live and accept the developer terms on its Consent screen tab. See Going live.
  2. Under Data your app uses on the same tab, tick write for Activities. If your integration posts dailies, tick write for Wellness too.
  3. Open the Data sources tab of your app.
  4. On the platform's card, choose Self-hosted.
  5. Enter your integration's issuer URL.
  6. Select Save and switch to Self-hosted.

A token for one of your users holds what you tick in step 2, and never more.

When you save, SweatStack reads {issuer}/.well-known/openid-configuration and registers itself as a client of your issuer. If your issuer has no registration endpoint, enter the client id and the client secret that your issuer made for SweatStack. SweatStack waits 5 seconds for each of these requests. If one fails, the card says why and nothing is saved.

From that save, every athlete who selects the platform on your sign-in screen goes through your integration. You can switch back for free until the first athlete signs in through your integration. After that, the card keeps the self-hosted integration while those users exist. To try this contract first, use a second SweatStack app with its own integration.

Sign in athletes

SweatStack runs the OpenID Connect authorization code flow against your issuer. This section is the profile of that flow: what SweatStack sends and what it reads.

Discovery. Serve {issuer}/.well-known/openid-configuration with issuer, authorization_endpoint, token_endpoint and jwks_uri. Each URL must use https on a public host.

The authorization request. SweatStack sends the browser to your authorization_endpoint with these parameters:

  • response_type: always code.
  • client_id: the client id your issuer gave SweatStack.
  • redirect_uri: always https://app.sweatstack.no/api/v1/tenants/{client_id}/integration-providers/{provider}/oauth2-authorization-callback. {client_id} is your app's client id, and {provider} is the platform, for example garmin_connect.
  • scope: openid. If your scopes_supported lists email or profile, SweatStack adds them.
  • state and nonce: return state unchanged, and put nonce in the ID token.
  • code_challenge and code_challenge_method=S256: verify them at the token endpoint.
  • prompt (optional): login or select_account, if your app asked SweatStack for a new sign-in.

If the person does not approve, redirect to redirect_uri with error and the unchanged state, as OAuth 2.0 defines. SweatStack then shows a page with a link back to your sign-in screen.

The token endpoint. SweatStack authenticates with client_secret_basic. If your token_endpoint_auth_methods_supported lists only client_secret_post, SweatStack uses that. Return an id_token.

The ID token. Sign it with RS256 and publish the key at jwks_uri, or sign it with HS256 and the client secret. The claims SweatStack reads:

  • iss (required): your issuer.
  • aud (required): the client id your issuer gave SweatStack.
  • sub (required): your id for the person, at most 255 characters.
  • exp and iat (required).
  • nonce (required): the nonce of the authorization request.
  • email and email_verified (optional): if email_verified is true, SweatStack sets the address on a user who has none.
  • given_name and family_name, or name (optional): SweatStack sets the name on a user who has none.

Registration. To let SweatStack register itself, name a registration_endpoint in discovery. SweatStack posts a software_statement, a signed token. Verify all of the following before you answer with a client_id and a client_secret:

  • The signature, against the keys at https://app.sweatstack.no/.well-known/jwks.json.
  • The header typ is software-statement+jwt.
  • iss is https://app.sweatstack.no, and aud is your issuer.
  • software_id is your app's client id.
  • redirect_uris holds exactly the redirect URI above.

Refuse a registration without a valid statement. Otherwise anyone can register a client and send people through your platform's consent screen.

What SweatStack does with the result. If SweatStack knows the sub, it signs that user in. If the sub is new, SweatStack creates a user of your app. If the user has no email address, SweatStack asks for one, saves it, and sends a confirmation message. Your app receives its code immediately: SweatStack does not wait for the user to open that message. email_verified is false until they open it.

If another user of your app has already confirmed that address, SweatStack does not save it. The user continues without an email address, and SweatStack asks again at their next sign-in.

Connect from the Portal

You build nothing for this. If a user of your app selects Connect or Reconnect in the Portal, SweatStack sends the same authorization request while it holds that user's session. Your issuer cannot tell this request from a sign-in. This is account linking. When the browser returns, SweatStack decides:

  • If the sub belongs to no user, SweatStack links it to the user in the Portal.
  • If the sub already belongs to that user, nothing changes.
  • If the sub belongs to another user of your app, SweatStack refuses and nothing changes. The Portal says the account is connected elsewhere.

If the user had a different platform account connected, SweatStack first sends your disconnect endpoint a request for the old one. If your integration does not answer 2xx, nothing changes and the Portal asks the user to try again.

Tell SweatStack what a connection provides

PUT /api/v1/applications/{client_id}/connections/{provider}/{subject} states what the connection of one sub provides, and returns the user it belongs to.

curl -H "Authorization: Bearer $APP_TOKEN" \
  -X PUT https://app.sweatstack.no/api/v1/applications/$CLIENT_ID/connections/garmin_connect/$SUBJECT \
  -H "Content-Type: application/json" \
  -d '{"capabilities": {"activities": "ready", "activity_history": "syncing", "wellness": "unavailable", "workouts": "unavailable"}, "state": "'"$STATE"'"}'
  • capabilities (required): one value per capability, from ready, syncing, action_required and unavailable. These are the values of the account status. A capability you leave out reads unavailable. A syncing older than 24 hours reads ready.
  • state (optional): the state of the authorization request, unchanged. Send it on the call you make after the platform's consent. Leave it out on later calls.
  • connected_at (optional, default now): when the user connected, with a UTC offset. Not in the future.
  • platform_user_id (optional): the platform's own id for the person, for example the Garmin user id. Send it together with state, on the first call after consent only. SweatStack uses it once to recognize an athlete who already uses your app through a SweatStack account, and does not store it.

$APP_TOKEN is your app's token from the client credentials grant.

{
  "user_id": "01JABCDEFGHJKMNPQRSTVWXYZ0",
  "provider": "garmin_connect",
  "capabilities": {"activities": "ready", "activity_history": "syncing", "wellness": "unavailable", "workouts": "unavailable"},
  "connected_at": "2026-11-05T09:30:00Z"
}

user_id is the user to upload for. It is null while SweatStack does not know whose connection this is. That happens in three cases:

  • The person started from the Portal. user_id stays null until their browser is back at SweatStack.
  • SweatStack recognized the person from platform_user_id, and is asking them a question on the sign-in screen. user_id stays null until they have answered.
  • You sent no state, or one that is older than 1 hour, for a sub that SweatStack does not know.

In the first two cases, the wait ends when the person acts, so SweatStack promises no time. Send the call again until it returns a user_id, without state. Do not upload before you have a user_id.

If the sub is new and the person started on your sign-in screen, SweatStack usually creates the user during this call, and user_id is set at once. You can get a token for that user immediately.

Send the call again on a permission change and when a backfill completes. If a connection gets no user_id within 24 hours, deregister it at the platform and delete it.

DELETE the same URL when a connection ends, for example on the platform's deregistration. The API answers 204, also when no such connection exists. The user and their data stay.

Errors:

  • 409: the platform is not set to Self-hosted with an issuer on your app's Data sources tab. Set it there, then send the call again.
  • 422: the body or the sub is not valid. A sub has 1 to 255 characters.
  • 401 or 403: the token is not your app's client token.

Upload data

Get an access token for the user with POST /api/v1/applications/{client_id}/users/{user_id}/token, described under The management API. Send scope with only what your integration writes, for example activities:write. Then relay their files and post their dailies.

For each file, use the platform's own activity id as external_id and the platform's name as provider, for example garmin_connect. A retry then never stores an activity twice.

If the token call answers 400, or an upload answers 403, the scope is not ticked under Data your app uses. Tick it, then get a new token. You change nothing per user.

End a connection

To give the Portal a Disconnect button, name a sweatstack_disconnect_endpoint in your discovery document. It must use https on a public host. If your discovery document cannot have the member, enter the URL on the Data sources tab.

SweatStack sends a POST with Content-Type: application/secevent+jwt. The body is a signed Security Event Token:

  • Header typ: secevent+jwt. Refuse any other value.
  • iss: https://app.sweatstack.no. Verify the signature against https://app.sweatstack.no/.well-known/jwks.json.
  • aud: your issuer.
  • sub: the SweatStack user id.
  • jti and iat: refuse a jti you have seen. SweatStack signs each try again with a new jti, so a repeat after your 503 is a new request.
  • events: one member, https://sweatstack.no/events/connection-disconnect, with provider and subject. subject is {"format": "iss_sub", "iss": "<your issuer>", "sub": "<your sub>"}: the person as your integration knows them.

Deregister the user at the platform, delete their tokens, and answer 2xx. SweatStack waits 5 seconds. If your integration answers anything else, the connection stays and the Portal asks the user to try again. If you have no connection for that sub, answer 2xx.

If your app deletes the user, SweatStack sends the same request, and repeats it until your integration answers 2xx.

If your integration has no disconnect endpoint, the Portal shows no Disconnect button and tells the user to manage the connection in your app.

What athletes see on your sign-in screen

If your app runs a platform itself, its sign-in screen has no email sign-up. An athlete selects a platform, and the platform they select decides the kind of account:

  • A platform your app runs: a user that belongs to your app.
  • A platform SweatStack hosts: a SweatStack account.

Use my SweatStack account signs in an existing SweatStack account by email. It creates no account.

A user that belongs to your app connects the platforms your app runs. A SweatStack account connects the platforms SweatStack hosts. One person can therefore be two users of your app, with the same email address. Never merge users by email.

To send an athlete directly to one platform, add integration={provider} to your authorize request.

If the browser is already signed in to your app, SweatStack answers your authorize request with a code at once, and your issuer does not see the request. To reach your issuer in that case, for example to test a sign-in, add prompt=login. SweatStack then shows the sign-in screen.

If your app has its own sign-in

Leave the issuer empty on the Data sources tab. Create your users with the management API, and state each connection by user id with PUT /api/v1/applications/{client_id}/users/{user_id}/connections/{provider}. The body is the same as for the connection call, without state and platform_user_id. The disconnect endpoint is optional. Without it, SweatStack deletes your users' connections on its side only.