Why does this user have no data?¶
Beta
The issue field and the status endpoint (GET /api/v1/profile/status) are in beta and are subject to breaking changes. If you plan to use them in production, email info@sweatstack.no before you integrate, so we can tell you about changes in advance and help you migrate.
This page explains how your app finds out why a user has little or no data, and where the user fixes it. At some point a user will authorize your app and GET /activities will come back empty, or with far less than you expected. That is usually not a bug in your integration. It is one of a handful of ordinary situations: they signed up by email and never connected a device, they connected Garmin but did not share their history, or their data is still arriving.
SweatStack tells you which one it is, and where the user can fix it.
Show the issue¶
You already call userinfo at the end of the OAuth handshake. It carries an issue field:
from sweatstack import Client
client = Client()
user = client.oauth.userinfo()
if user.issue:
print(user.issue.message, user.issue.destination)
curl https://app.sweatstack.no/api/v1/oauth/userinfo \
-H "Authorization: Bearer $ACCESS_TOKEN"
{
"sub": "01JQ8...",
"name": "Alice Anderson",
"issue": {
"code": "activity_history_not_granted",
"status": "action_required",
"message": "Garmin Connect isn't sharing past activities.",
"destination": "manage-integrations"
}
}
issue is null when nothing is wrong with the data your app uses. When it is not null, show message. If destination is set, show a button. When the user clicks it, create a Portal link with the user's access token and send them there:
if (user.issue) {
// Show a button only when user.issue.destination is set.
showBanner(user.issue.message, user.issue.destination);
}
// When the user clicks the button:
async function openPortal(destination) {
const res = await fetch('https://app.sweatstack.no/api/v1/portal/sessions', {
method: 'POST',
headers: { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' },
body: JSON.stringify({ destination, return_url: location.href }),
});
location.assign((await res.json()).url);
}
That is the whole integration. The link opens the SweatStack Portal: a SweatStack-hosted page with your branding, where the user fixes the problem and comes back to your app. You don't need to know what was wrong. When they are done, Back to {your app} takes them to return_url.
- Pass the page you want the user back on.
location.hrefis usually right.return_urlmust equal or sit under one of your registered redirect URIs; see Your users always come back. - If your app is native, or runs as an installed PWA, omit
return_url. A link can't reopen an installed app (display-mode: standalone), and a custom-scheme URL is rejected, so the Portal tells the user to close the page instead. See Your users always come back. - Create the link when the user clicks, and send them straight there. Don't store or reuse it.
message is one short sentence, written to fit a banner you sized without us. It names the source in plain words ("Garmin Connect isn't sharing past activities."), so you never need to know which providers exist.
There is only ever one issue
Not a list. We decide which one matters most for your app right now, and we keep that decision current as we learn. You never sort, filter, or choose. Some of the ordering is causal rather than editorial: telling someone to share their history is meaningless before they have connected anything.
userinfo requires the openid scope. If your app does not request it, use GET /api/v1/profile/status instead, which carries the same issue and needs no scope at all.
Understand issues and capabilities¶
SweatStack answers two questions about the user's account.
issue: is something wrong with the data your app uses? An issue always ends. It ends when the user does something, or when their data arrives. If the user can fix it, the issue has a destination.
capabilities: what can the account supply? A fact about the user's connected sources, for each kind of data. Every app sees the same values.
A fact is not always a problem. A Wahoo bike computer records activities and no sleep data, so a Wahoo user's capabilities.wellness is unavailable. A running app should say nothing about that, and it gets no issue. A sleep app can't work without sleep data. It requests wellness:read, and gets an issue that sends the user to connect a source that records sleep.
| The user's account | capabilities (the same for both apps) |
issue for a running app (the default) |
issue for a sleep app (requests only wellness:read) |
|---|---|---|---|
| Nothing connected | all four action_required |
no_source_connected |
no_source_connected |
| Garmin Connect, history not shared | activity_history is action_required, the rest ready |
activity_history_not_granted |
null |
| Garmin Connect, health data not shared | wellness is action_required, the rest ready |
null |
wellness_not_granted |
| Wahoo only | wellness and workouts are unavailable, the rest ready |
null |
wellness_no_source |
| Intervals.icu fed by Strava, nothing arrives | activities and activity_history are action_required, wellness and workouts unavailable |
activities_blocked_upstream |
wellness_no_source |
| Intervals.icu fed by a watch that shares only new activities | activities and activity_history are ready, wellness and workouts unavailable |
null |
wellness_no_source |
| Garmin Connect, connected 10 minutes ago | activities, activity_history and wellness are syncing, workouts ready |
sync_pending |
sync_pending |
In each row, capabilities is the same for both apps. Only issue depends on the app. The two Intervals.icu rows are the ones that look alike from the outside and are not: when Strava is in the way, nothing arrives and activities is action_required; a watch that shares only new activities is a working source, so activities and activity_history are ready and everything that exists arrives.
Choose the data your app uses¶
Your app's scopes say what it uses. SweatStack reports issues only for the capabilities your token's scopes reach, and never asks a user to share data that your app does not use:
| Capability | Reached by |
|---|---|
activities, activity_history |
activities:read |
wellness |
wellness:read |
workouts |
workouts:write |
The menstrual cycle day reaches no capability: no source supplies it, so there is nothing a
source can fail to provide. The key still appears in capabilities, with its real value,
whatever your scopes. For an app registered before the scope change of October 2026 that still
sends data:read, the scopes ticked under Data your app uses on your app's Consent screen tab narrow the issues it is told
about, exactly as the capabilities it declared did before.
Fall back to your own message¶
If your app has its own idea of what "no data" means, such as no running activities or nothing in
the last 90 days, check issue first. Fall through to your own message only when issue is
null.
Those are different situations, and only you can write the second message:
if (user.issue) {
// We know why, and the Portal can fix it.
showBanner(user.issue.message, user.issue.destination);
} else if (noRunsInLast90Days) {
// Their data is fine; this one is yours to explain.
showBanner("You haven't logged a run in the last 90 days.");
}
The reverse order is the most common mistake we see: an app leads with "no runs in 90 days" and buries the real causes, so the largest group of affected users, the ones who never connected anything, are told something that isn't true.
Show a different screen per status¶
issue.status has two values, and each one implies a different screen.
issue.status |
What it means | What to show |
|---|---|---|
action_required |
The user can fix this. | message and, if destination is set, a button. |
syncing |
The user's data is arriving. It ends by itself, at most 24 hours after the user connected a source. | message and a waiting state. Check again after 1 minute. |
(issue is null) |
Nothing is wrong with the data your app uses. | Nothing. |
destination is about the person holding the token. It is set only when your app can send them to the Portal to fix the issue: the token is the user's own, and it was issued to your app. Otherwise it is null. Show a button when destination is set, and only then.
Coaches and delegated tokens¶
If your app views an athlete through a delegated token (a coach, or a manager of a managed
account), issue describes the athlete and carries the same message, so the coach can see why the
chart is empty and say "open the app and fix your Garmin". destination is always null on a
delegated token, because a Portal link opens the account of whoever is signed in, which is the
coach. The athlete opens your app as themselves, sees the same issue with a button, and fixes it
there. Nothing has to travel from coach to athlete. The issue follows the coaching app's declaration, not the athlete's other apps.
Delegation does not affect capabilities. A coaching app that schedules workouts reads
capabilities.workouts for the athlete exactly as it would for a user of their own account.
Check one capability¶
Read this section if your app is useless without past activities, or without sleep data, or if it pushes workouts to devices. Read it too if your app does not request the profile scope: this endpoint carries the same issue as userinfo.
GET /api/v1/profile/status returns the issue and the capability map for the user's account.
from sweatstack import Client
client = Client()
status = client.profile.status()
print(status.capabilities["wellness"])
curl https://app.sweatstack.no/api/v1/profile/status \
-H "Authorization: Bearer $ACCESS_TOKEN"
Needs a valid token and no particular scope; the issue follows the token's scopes.
{
"issue": {
"code": "activity_history_not_granted",
"status": "action_required",
"message": "Garmin Connect isn't sharing past activities.",
"destination": "manage-integrations"
},
"capabilities": {
"activities": "ready",
"activity_history": "action_required",
"wellness": "unavailable",
"workouts": "ready"
}
}
Two fields. issue is the same object userinfo carries. capabilities is a map, not a list: read capabilities.activity_history directly. It is the stable answer to "can I do X", independent of whichever issue we chose to report first, so you never have to branch on code.
| Key | What the account can supply | Needs the scope |
|---|---|---|
activities |
Activities arrive at all | activities:read |
activity_history |
Past activities are available: the source shares its history, or the account already holds activities at least 30 days old | activities:read |
wellness |
Dailies: sleep, resting heart rate, HRV, body mass | wellness:read |
workouts |
Scheduled workouts can be delivered to a device | workouts:write |
Each value is one of four:
| Value | Meaning |
|---|---|
ready |
A connected source provides it. What you can read is what exists. |
syncing |
A source is sending it now. |
action_required |
A connected source can provide it after the user changes something. |
unavailable |
No connected source provides it. This changes if the user connects one that does. |
Read a capability again each time you need it. Don't store the value: it changes when the user connects or disconnects a source, changes a permission, or when their data arrives, and none of that involves your app.
activity_history answers "can I show this user their past training?", and two things make the
answer yes:
- The source shares its history. A Garmin connection with the historical-data toggle on reads
syncingon its first day, while the backfill runs, andreadyafter that. Someone who just started training, or just bought their first watch, has no old activities anywhere, and still readsready: everything that exists arrives. A source with no permission model, such as Intervals.icu, readsreadyfrom the start for the same reason. - The account already holds history. An activity that started 30 or more days ago, whether it was backfilled, synced day by day since, or uploaded by hand, makes the answer yes regardless of any toggle. A user who connected Garmin without the toggle and has been training for two months has history, and this says so.
If neither holds (a source that is not sharing its history, and an account with nothing 30
days old), it reads action_required, because then the toggle is the one thing that would
change the answer.
If capabilities.wellness is unavailable and your app requests wellness:read, issue already tells the user to connect a source that provides it. You don't need to build that message.
syncing is bounded. SweatStack reports it for at most 24 hours after a source is connected. After that the account reads as ready, even if nothing has arrived, because a perpetual spinner is worse than an empty state.
What we promise, and what we do not¶
- Branch on
issue.status. There are two values and there will never be a third. - Each
capabilitiesvalue is one of four, and there will never be a fifth. - Display
message. Never parse it. The wording will change. It will always be one short sentence. - Do not branch on
code. Use it for logging, or for writing your own copy. Which code appears first is ours to change, and it will change as we improve the ordering. New codes appear over time; ignore the ones you don't know. - New
capabilitieskeys will appear over time. Ignore the ones you do not recognize. - Nothing in the response names a provider. If you find that you need to know which one, send the user to the Portal instead.
- Show a button only when
destinationis set. It is present only onaction_requiredissues, and only on the user's own token issued to your app. It isnullonsyncingissues, on every delegated token, and on a token that was not issued to an app (an API key). - New
destinationvalues can appear. Pass the value toPOST /api/v1/portal/sessionsas it is.
Test your integration¶
You probably have one SweatStack account and it is healthy, so you cannot easily see the states you are building for. For the healthy case, the live Portal demo shows the real issue and capabilities for your account, rendered the way this page prescribes. For the other cases, these are the payloads to mock:
{ "issue": {
"code": "no_source_connected",
"status": "action_required",
"message": "No data source is connected yet.",
"destination": "manage-integrations" } }
{ "issue": {
"code": "sync_pending",
"status": "syncing",
"message": "Garmin Connect data is still arriving.",
"destination": null } }
{ "issue": {
"code": "activity_history_not_granted",
"status": "action_required",
"message": "Garmin Connect isn't sharing past activities.",
"destination": "manage-integrations" } }
{ "issue": {
"code": "activities_blocked_upstream",
"status": "action_required",
"message": "Intervals.icu can't pass on activities that come from Strava.",
"destination": "manage-integrations" } }
{ "issue": {
"code": "wellness_no_source",
"status": "action_required",
"message": "No connected source provides daily health data.",
"destination": "manage-integrations" } }
{ "issue": null }
The full list of codes, with the message each one carries and the Portal screen it leads to, is on the Portal page.