Skip to content

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.href is usually right. return_url must 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 syncing on its first day, while the backfill runs, and ready after that. Someone who just started training, or just bought their first watch, has no old activities anywhere, and still reads ready: everything that exists arrives. A source with no permission model, such as Intervals.icu, reads ready from 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 capabilities value 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 capabilities keys 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 destination is set. It is present only on action_required issues, and only on the user's own token issued to your app. It is null on syncing issues, on every delegated token, and on a token that was not issued to an app (an API key).
  • New destination values can appear. Pass the value to POST /api/v1/portal/sessions as 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.