---
title: "Onboarding for fitness apps | Snowplow Signals"
description: "Snowplow Signals tells your app which onboarding step each new user reached and what they have done since, so it opens on the next step, not the first screen."
source: https://signals.snowplow.io/solutions/health-fitness/onboarding
---

[Health and fitness](https://signals.snowplow.io/solutions/health-fitness) / Onboarding 

# An app that knows how far each new user got. 

Signals tells your app and your messaging tool which step a new user reached, which workouts they started and finished, and when, so the first week picks up where the last open left off.

[Start free](https://signals.snowplow.io/start) [Talk to us](https://signals.snowplow.io/start?intent=talk) 

Higher D1 and D7 retention

## Keep more new users through the first week by opening on the step they have not done yet. 

First-week retention moves when the app responds to what each new user did last time, instead of showing every new user the same first screen. Signals gives your app which setup step this user reached, which workouts they started and finished, how long since they first opened the app and when their first session was. Your app decides what to open on, what to ask for and when the nudge goes out.

Four ways fitness apps use it

01 New users who set a goal and left Picked a goal, closed the app before a workout. Open on the first session. 02 New users who did one workout and stopped One workout on Monday evening, nothing since. Nudge on Wednesday evening, not at noon. 03 New users who skipped setup for a workout No profile, no goal, straight into a workout. Ask after the second one. 04 New users repeating the starter routine Two sessions, both the same starter routine. Offer the next level. 

[Read the docs →](https://docs.snowplow.io/docs/signals/introduction/) 

### Open on the first workout, not the goal screen again

A new user installed the app yesterday, picked a goal and closed it before starting a workout. Today they open it to the home screen everyone gets, with the goal flow waiting to be redone and a feed with nothing in it yet. The opening screen is set once per release, so what a user did last time reaches it in the next report, after this user has decided whether to come back. The step this user has not done is the first workout, and the app can know that the moment it opens.

What Signals knows

`onboarding_step_reached` 

The last setup step they completed

`workouts_completed_7d` 

Workouts finished in the last seven days

`days_since_first_open` 

Days since they first opened the app

What your app does

Opens on the first workout for a user in their first week whose last completed step was the goal and who has no finished workout yet, and skips the goal flow they have been through.

Define it · PythonUse it · TypeScript

app-launch.tsx · 15 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import Attribute, Event, EventProperty, StreamAttributeGroup, user_id

app_opened = Event(name="app_opened", vendor="com.example", version="1-0-0")
step_completed = Event(name="onboarding_step_completed", vendor="com.example", version="1-0-0")
workout_completed = Event(name="workout_completed", vendor="com.example", version="1-0-0")

onboarding_step_reached = Attribute(
    name="onboarding_step_reached",
    type="string",
    events=[step_completed],
    aggregation="last",
    property=EventProperty(vendor="com.example", name="onboarding_step_completed", major_version=1, path="step"),
)

workouts_completed_7d = Attribute(
    name="workouts_completed_7d",
    type="int32",
    events=[workout_completed],
    aggregation="counter",
    period=timedelta(days=7),
    default_value=0,
)

days_since_first_open = Attribute(
    name="days_since_first_open",
    type="double",
    events=[app_opened],
    aggregation="time_since_first",
    time_unit="d",
)

# Users sign in on first open, so the user id is the key from the first event on.
fit_onboarding_goal = StreamAttributeGroup(
    name="fit_onboarding_goal",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[onboarding_step_reached, workouts_completed_7d, days_since_first_open],
)
```

```
// In the code that picks the screen the app opens on
const user = await signals.getServiceAttributes({
  name: "fit_onboarding_goal",
  attribute_key: "user_id",
  identifier: userId,
});
// { onboarding_step_reached: "goal_set", workouts_completed_7d: 0, days_since_first_open: 1.1 }

if (
  user.onboarding_step_reached === "goal_set" &&
  user.workouts_completed_7d === 0 &&
  user.days_since_first_open <= 7
) {
  app.openOn({ screen: "first_workout" });
}
```

### Time the second-day nudge to the hour of their first workout

A new user finished their first workout on Monday at seven in the evening and has not opened the app since. The nudge for quiet new users goes out at noon to everyone who has been away two days, and for this user noon is the middle of the working day. Their one session says when they train. The nudge should land at that hour, two days after the first workout, and only reach the user who has not come back on their own.

What Signals knows

`workouts_completed_7d` 

Workouts finished in the last seven days

`days_since_last_workout` 

Days since they last finished a workout

`first_workout_hour` 

The hour of day their first workout finished

What your app does

Schedules the second-day nudge in your messaging tool for the hour of the user's first workout once two days have passed with no second one, and leaves out any user whose workout count has moved since.

Define it · PythonUse it · TypeScript

nudge-job.ts · 14 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import Attribute, AtomicProperty, Event, StreamAttributeGroup, user_id

workout_completed = Event(name="workout_completed", vendor="com.example", version="1-0-0")

workouts_completed_7d = Attribute(
    name="workouts_completed_7d",
    type="int32",
    events=[workout_completed],
    aggregation="counter",
    period=timedelta(days=7),
    default_value=0,
)

days_since_last_workout = Attribute(
    name="days_since_last_workout",
    type="double",
    events=[workout_completed],
    aggregation="time_since_last",
    time_unit="d",
)

first_workout_hour = Attribute(
    name="first_workout_hour",
    type="int32",
    events=[workout_completed],
    aggregation="first",
    property=AtomicProperty(name="derived_tstamp", date_part="hour_of_day"),
)

fit_onboarding_nudge = StreamAttributeGroup(
    name="fit_onboarding_nudge",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[workouts_completed_7d, days_since_last_workout, first_workout_hour],
)
```

```
// In the job that schedules nudges for new users
const user = await signals.getServiceAttributes({
  name: "fit_onboarding_nudge",
  attribute_key: "user_id",
  identifier: userId,
});
// { workouts_completed_7d: 1, days_since_last_workout: 2.1, first_workout_hour: 19 }

if (user.workouts_completed_7d === 1 && user.days_since_last_workout >= 2) {
  messaging.schedule({
    campaign: "second_workout_nudge",
    sendAtHour: user.first_workout_hour,
  });
}
```

### Hold the profile questions until the second workout is done

A new user tapped past the profile and goal screens and started a workout within a minute of opening the app. Their next open shows the setup banner, because the flow treats a skipped step as unfinished and asks on every open until it is done. This user has told the app what they want by doing it. The questions get a better answer after the second workout, when the app has given this user a reason to answer them.

What Signals knows

`onboarding_step_reached` 

The last setup step they completed, or none

`setup_skips_7d` 

Setup screens they skipped in the last seven days

`workouts_completed_7d` 

Workouts finished in the last seven days

What your app does

Hides the setup banner for a user who skipped setup and has a finished workout to show for it, and asks for the profile on the summary screen after their second finished workout, one question at a time.

Define it · PythonUse it · TypeScript

workout-summary.tsx · 18 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import Attribute, Event, EventProperty, StreamAttributeGroup, user_id

step_completed = Event(name="onboarding_step_completed", vendor="com.example", version="1-0-0")
step_skipped = Event(name="onboarding_step_skipped", vendor="com.example", version="1-0-0")
workout_completed = Event(name="workout_completed", vendor="com.example", version="1-0-0")

onboarding_step_reached = Attribute(
    name="onboarding_step_reached",
    type="string",
    events=[step_completed],
    aggregation="last",
    property=EventProperty(vendor="com.example", name="onboarding_step_completed", major_version=1, path="step"),
    default_value="none",
)

setup_skips_7d = Attribute(
    name="setup_skips_7d",
    type="int32",
    events=[step_skipped],
    aggregation="counter",
    period=timedelta(days=7),
    default_value=0,
)

workouts_completed_7d = Attribute(
    name="workouts_completed_7d",
    type="int32",
    events=[workout_completed],
    aggregation="counter",
    period=timedelta(days=7),
    default_value=0,
)

fit_onboarding_skipped = StreamAttributeGroup(
    name="fit_onboarding_skipped",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[onboarding_step_reached, setup_skips_7d, workouts_completed_7d],
)
```

```
// In the code that renders the workout summary screen
const user = await signals.getServiceAttributes({
  name: "fit_onboarding_skipped",
  attribute_key: "user_id",
  identifier: userId,
});
// { onboarding_step_reached: "none", setup_skips_7d: 3, workouts_completed_7d: 2 }

const skippedSetup = user.setup_skips_7d > 0 && user.onboarding_step_reached === "none";

if (skippedSetup && user.workouts_completed_7d >= 2) {
  summary.ask({
    question: "goal",
    text: "Two workouts in. Pick a goal and the next one is built around it.",
  });
} else if (skippedSetup) {
  home.hide({ banner: "finish_setup" });
}
```

### Offer the next level after two runs of the same starter routine

A new user has finished two workouts in their first week, and both were the starter routine the app opens on. The plan moves users up on a schedule set for everyone, which lags behind a user who finished both sessions in two days, so a third open shows the same routine at the top. Two finishes of the same session is the moment the next level is welcome, and the user is on the home screen looking for what to do next.

What Signals knows

`workouts_completed_7d` 

Workouts finished in the last seven days

`routine_counts_7d` 

How many times they finished each routine in the last seven days

`last_routine` 

The routine they finished most recently

What your app does

Puts the next routine in the progression at the top of the home screen when every workout a user finished this week was the same starter routine, and leaves the plan on its schedule for a user who is mixing sessions.

Define it · PythonUse it · TypeScript

home-screen.tsx · 16 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import Attribute, Event, EventProperty, StreamAttributeGroup, user_id

workout_completed = Event(name="workout_completed", vendor="com.example", version="1-0-0")
routine = EventProperty(vendor="com.example", name="workout_completed", major_version=1, path="routine_id")

workouts_completed_7d = Attribute(
    name="workouts_completed_7d",
    type="int32",
    events=[workout_completed],
    aggregation="counter",
    period=timedelta(days=7),
    default_value=0,
)

routine_counts_7d = Attribute(
    name="routine_counts_7d",
    type="dict",
    events=[workout_completed],
    aggregation="category_count",
    property=routine,
    period=timedelta(days=7),
)

last_routine = Attribute(
    name="last_routine",
    type="string",
    events=[workout_completed],
    aggregation="last",
    property=routine,
)

fit_onboarding_progression = StreamAttributeGroup(
    name="fit_onboarding_progression",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[workouts_completed_7d, routine_counts_7d, last_routine],
)
```

```
// In the code that renders the home screen
const user = await signals.getServiceAttributes({
  name: "fit_onboarding_progression",
  attribute_key: "user_id",
  identifier: userId,
});
// { workouts_completed_7d: 2, routine_counts_7d: { starter_full_body: 2 }, last_routine: "starter_full_body" }

const routines = Object.keys(user.routine_counts_7d);

if (user.workouts_completed_7d >= 2 && routines.length === 1) {
  home.feature({
    routine: progression.after(user.last_routine),
    label: "Ready for the next level",
  });
}
```

## Try it on your own site. 

Add the SDK, define one thing you want to know about a visitor, and watch it update while you click around. The free tier runs on the real engine: no card, no sales call.

[Start free](https://signals.snowplow.io/start) [Talk to an engineer](https://signals.snowplow.io/start?intent=engineer)
