Why does this user have no data?¶
Beta
The issue field and the status endpoint (GET /api/v1/profile/status) are in beta. We can release breaking changes to them with little notice. If you plan to use them in production, email [email protected] before you integrate. We'll then tell you before a breaking change is released and help you migrate.
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.
The one-line version¶
You already call userinfo at the end of the OAuth handshake. It carries an issue field:
{
"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 there is nothing to say. When it is not null, show message. If destination is set, add 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 resolves everything outstanding on their account in one visit. You do not 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 runs as an installed PWA, omit
return_url. A link can't reopen an installed app (display-mode: standalone), so the Portal tells the user to close the page instead. - 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 profile scope. If your app does not request it, use GET /api/v1/profile/status instead. It accepts data:read as well.
Check issue before your own filters¶
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.
If you want to show different UI per situation¶
status has four values, and each one implies a different screen.
status |
What it means | What to show |
|---|---|---|
syncing |
Data is on its way. Resolves on its own. | A waiting state. Check again after a minute. |
action_required |
The user can fix this. | A button to the Portal when destination is set. |
unavailable |
This will not work for this user. | A one-time explanation, then move on. Do not poll. |
(issue is null) |
Nothing to say. | Nothing. |
status describes the account. 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.
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.
Handle unavailable with care. Some limits are permanent: an account whose activities reach us through Intervals.icu from Strava will never have history, and no amount of reconnecting changes that. Design around the shorter window instead of prompting the user forever.
Not every unavailable is permanent, and you don't need to tell them apart. A source that has no history feature at all reads activity_history: unavailable on the day it is connected, then ready once the account has accumulated 30 days of its own activities, because by then the user has history. Either way the right response is the same: explain once, don't prompt, and read the capability again the next time you need it instead of caching the answer forever.
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.
Does this user have what my app needs?¶
Read this section if your app is useless without past activities, or without sleep data, or if it pushes workouts to devices. Otherwise skip it.
GET https://app.sweatstack.no/api/v1/profile/status
{
"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",
"dailies": "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 | Relevant to your app when it holds |
|---|---|---|
activities |
Activities arrive at all | data:read |
activity_history |
Past activities are available: the source shares its history, or the account already holds activities at least 30 days old | data:read |
dailies |
Dailies: sleep, resting heart rate, HRV, body mass | data:read |
workouts |
Scheduled workouts can be delivered to a device | data:write |
Each value is one of the four status values above, with ready meaning it works. The last column is also how issue is filtered: an app holding only data:read is never handed a workout issue, because it could neither use nor fix it. The capability still appears in the map.
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
readyfrom the start, after a first day ofsyncingwhile the backfill runs. Someone who just started training, or just bought their first watch, has no old activities anywhere, and still readsready: everything that exists arrives. - 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.
Requires the data:read or the profile scope.
What we promise, and what we do not¶
- Branch on
status. There are four values 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
capabilitieskeys and newcodevalues 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 isnullonsyncingandunavailableissues, on every delegated token, and on a token that was not issued to an app. - New
destinationvalues can appear. Pass the value toPOST /api/v1/portal/sessionsas it is.
Testing this¶
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": "dailies_unavailable",
"status": "unavailable",
"message": "No connected source provides daily health data.",
"destination": null } }
{ "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.