The SweatStack Portal¶
Beta
The Portal is in beta. We can release breaking changes to it with little notice. If you plan to use it in production, email [email protected] before you integrate. We'll then tell you before a breaking change is released and help you migrate.
Sometimes a user's data isn't flowing and the fix is in their SweatStack account: a source to connect, a permission to grant, a stale connection to replace. You could build those screens yourself. The Portal exists so you don't have to.
It's a SweatStack-hosted page that does that one job and nothing else. It carries your branding, and when your users are done, it sends them back to you.

Your users always come back¶
This is the part worth reading twice, because it's the thing you're trusting us with.
Every Portal page carries a link back to your app. When there's nothing left to do, that link is the page's primary button, so the user is never left to find their own way out.

You choose where it goes:
| What you do | What your user gets |
|---|---|
Pass return_url when you create the session |
Back to {your app}, pointing exactly there |
Nothing, just use the action_url we give you |
Back to {your app}, pointing at your registered app URL |
Pass no return_url, deliberately |
"You can close this page and return to {your app}" |
Nothing, and your app is native (no https redirect URI registered) |
"You can close this page and return to {your app}" |
The last row is deliberate. There is no URL that reliably reopens a native app in a sensible
place: its redirect URIs are OAuth callbacks, and its website is a detour, not a return. The
app is almost always still running behind the browser, so telling the user to close the page is
both true and what works. Pass a return_url if your app has a deep link that does better.
And when we can't build a link back, we say so instead of saying nothing. A user who can't be helped at all is offered SweatStack support instead of being left in a loop.
The one case that needs your help¶
If some of your users run your app as an installed PWA, a link can't bring those users back. On iOS, opening a URL inside an installed app's scope opens a new browser tab instead of reopening the app.
We can't detect this. The Portal runs in the browser, not inside your app, so no amount of user-agent sniffing on our side would tell us. You know, and it's one line:
const standalone = window.matchMedia('(display-mode: standalone)').matches
|| window.navigator.standalone; // iOS Safari
const { url } = await fetch('https://app.sweatstack.no/api/v1/portal/sessions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
client_id: CLIENT_ID,
destination: 'manage-integrations',
// Omitted on purpose when standalone. The Portal then tells the user to close the
// page, which is what actually works when a link can't reopen your app.
...(standalone ? {} : { return_url: 'https://example.com/app/' }),
}),
}).then(r => r.json());
No backend needed: public clients can create sessions, and CORS permits the call from the browser.
If all your users are in a normal browser, ignore this and use action_url.
How it works¶
- Your app notices something is wrong, usually from the
issuefield onuserinfo, which carries a ready-madeaction_url. - You send the user to that URL, or create your own session for more control.
- They arrive on a SweatStack page with your logo, showing everything outstanding on their account.
- They fix it and press Back to your app.
Step 3 is deliberate. The Portal doesn't show only the one issue your app was told about. It shows everything outstanding. A user sent to fix one thing can come back having fixed three, and they never bounce in and out resolving them one at a time.
The fastest way to understand these four steps is to walk them with your own account. The live demo
signs you in, renders issue and capabilities the way this documentation prescribes, creates a
session from the browser, and opens the real Portal, so you can come back through its link and see
what changed.
What your user sees¶
Nothing connected yet¶
The most common arrival by a wide margin. About a quarter of accounts have no activities at all, and most of those have connected nothing. For most users this is a connect page, not a manage page.

Something needs their attention¶
Leads with one action. The rest stay reachable below it.

Data is still arriving¶
No button, because there's nothing to do.

A permanent limit¶
Some things can't be fixed by anyone. An account whose activities reach us through Intervals.icu from Strava will never have history. The Portal explains these once, quietly, and never as a prompt.

Everything is fine¶
Including when your link is stale and the user already fixed it. This reads as reassurance, never as an error.

Every case, and where it lands¶
The code and message are exactly what your app receives from userinfo
or GET /api/v1/profile/status. {source} is the connection's name, for example "Garmin Connect".
code |
status |
The message your app receives | Where the user lands |
|---|---|---|---|
no_source_connected |
action_required |
No data source is connected yet. | Connect |
activities_not_granted |
action_required |
{source} isn't sharing activities. | Needs attention |
activity_history_not_granted |
action_required |
{source} isn't sharing past activities. | Needs attention |
dailies_not_granted |
action_required |
{source} isn't sharing daily health data. | Needs attention |
workouts_not_granted |
action_required |
{source} isn't accepting workouts. | Needs attention |
sync_pending |
syncing |
{source} data is still arriving. | Still arriving |
activity_history_unavailable |
unavailable |
{source} can't provide activities from before it was connected. | Worth knowing |
dailies_unavailable |
unavailable |
No connected source provides daily health data. | Worth knowing |
workouts_unavailable |
unavailable |
No connected source accepts workouts. | Worth knowing |
| (none) | issue is null |
All set |
Every message is one short sentence, so it fits a banner without wrapping. A code reads as its capability plus its status: <capability>_not_granted when the user can fix it, <capability>_unavailable when nobody can.
Two more the Portal handles on its own¶
These describe this visit rather than the account, so they never appear in issue. You may still
see them in a screenshot or a support conversation.
The provider account belongs to a different SweatStack account.

The provider's email address already has a SweatStack account.

Creating a Portal session¶
You need this only for the control it gives you. Otherwise use the action_url on issue.
POST /api/v1/portal/sessions creates a session and returns the URL to send the user to. It needs no access token.
{
"client_id": "01JMYRA...",
"client_secret": "optional, see below",
"destination": "manage-integrations",
"return_url": "https://example.com/app/"
}
Body fields:
client_id(required): your application ID. It also brands the page.client_secret(optional): a PKCE-only public client authenticates withclient_idalone. If your app has a registered secret, you must send it.destination(required):manage-integrations.return_url(optional): where the Portal sends the user back to. Omitting it is meaningful; see above. It has to belong to your app: we validate it against your registered URLs.
The API returns the URL:
{ "url": "https://app.sweatstack.no/portal/integrations?app=01JMYRA...&return_url=..." }
A delegated token (a coach viewing an athlete) gets 404: a grant to read training data is not a grant to inspect the account's setup.
Don't build Portal URLs yourself
The URL in the response is not a stable contract. Its shape will change, and we can replace the query parameters with opaque tokens without notice.
Create sessions through this endpoint, or use the action_url we hand you on issue. Both
always produce a working URL. Anything you assemble by hand will break.