---
title: "Upgrade offers for fitness apps | Snowplow Signals"
description: "Snowplow Signals tells your app which plan screens and locked workouts a user keeps opening, so it makes the premium offer while they are deciding."
source: https://signals.snowplow.io/solutions/health-fitness/upgrades
---

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

# An upgrade offer that arrives while the user is deciding. 

Signals tells your app which plan screens, locked workouts and sessions each user has opened this week, so the premium offer reaches the user weighing it up and stays quiet for the one who said no.

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

More upgrades to premium

## Get more upgrades to premium by making the offer while the user is deciding. 

Upgrades move when the offer runs on the user's calendar rather than the campaign's. Signals gives your app how many times each user has opened the plan screen, which locked workout they keep hitting, how many days they have trained this month and whether they have dismissed the prompt. The app chooses who sees the offer, which tier it names and when it stays quiet.

Four ways fitness apps use it

01 Users comparing plans Three looks at the plan screen this week, no upgrade. Offer now. 02 Users hitting a locked workout Tapped the same locked class four times this month. Name it in the offer. 03 Users who train most days Twenty training days this month on the basic plan. Offer coaching after today's session. 04 Users who said no Closed the upgrade prompt twice this month. Stop prompting, keep the plan screen. 

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

### Make the offer while they are comparing plans

A user has opened the premium plan screen three times this week and closed it each time without upgrading. The upsell campaign runs on its own calendar, so its next push lands days after this week's comparing has stopped, or before it started. The decision is being made now, on the screen they keep coming back to. The offer belongs there, and it should name the plan they have compared most.

What Signals knows

`plan_screen_views_7d` 

Times the premium plan screen was opened in the last seven days

`top_plan_viewed_7d` 

The plan they opened most on that screen in the last seven days

What your app does

Shows the offer on the plan screen once a basic user has opened it three times in a week, and puts the plan they keep comparing at the top. Your billing system says they are still on the basic plan. Signals supplies what they have been looking at.

Define it · PythonUse it · TypeScript

plan-screen.tsx · 15 lines Copy Copied 

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

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

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

top_plan_viewed_7d = Attribute(
    name="top_plan_viewed_7d",
    type="string",
    events=[plan_screen_viewed],
    aggregation="most_frequent",
    property=EventProperty(vendor="com.example", name="plan_screen_viewed", major_version=1, path="plan"),
    period=timedelta(days=7),
)

# Users are signed in to the app, so this group keys on the user id.
fit_upgrades_deciding = StreamAttributeGroup(
    name="fit_upgrades_deciding",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[plan_screen_views_7d, top_plan_viewed_7d],
)
```

```
// In the code that renders the premium plan screen
const user = await signals.getServiceAttributes({
  name: "fit_upgrades_deciding",
  attribute_key: "user_id",
  identifier: userId,
});
// { plan_screen_views_7d: 3, top_plan_viewed_7d: "premium_annual" }

if (account.plan === "basic" && user.plan_screen_views_7d >= 3) {
  offer.show({
    plan: user.top_plan_viewed_7d,
    placement: "plan_screen",
    headline: "The plan you keep coming back to",
  });
}
```

### Name the workout they keep trying to open

A user on the basic plan has tapped the same locked premium workout four times this month, and tonight they have hit it again. The premium pitch they get lists everything in the tier, most of which they have not asked for. What they want is on the screen behind the lock. The offer fits best at that moment, and it should say the name of the class they keep reaching for.

What Signals knows

`locked_hits_30d` 

Times they hit a locked premium workout in the last 30 days

`locked_workout_30d` 

The locked workout they hit most in the last 30 days

What your app does

Replaces the tier pitch on the lock screen with an offer that names the workout, once a user has hit locks three times this month and the one in front of them is the one they hit most. A user hitting a lock for the first time gets the plain lock screen.

Define it · PythonUse it · TypeScript

locked-workout.tsx · 15 lines Copy Copied 

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

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

locked_hits_30d = Attribute(
    name="locked_hits_30d",
    type="int32",
    events=[premium_locked_hit],
    aggregation="counter",
    period=timedelta(days=30),
    default_value=0,
)

locked_workout_30d = Attribute(
    name="locked_workout_30d",
    type="string",
    events=[premium_locked_hit],
    aggregation="most_frequent",
    property=EventProperty(vendor="com.example", name="premium_locked_hit", major_version=1, path="workout_id"),
    period=timedelta(days=30),
)

fit_upgrades_locked = StreamAttributeGroup(
    name="fit_upgrades_locked",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[locked_hits_30d, locked_workout_30d],
)
```

```
// In the code that renders the lock screen over a premium workout
const user = await signals.getServiceAttributes({
  name: "fit_upgrades_locked",
  attribute_key: "user_id",
  identifier: userId,
});
// { locked_hits_30d: 4, locked_workout_30d: "hiit_20_min" }

if (user.locked_hits_30d >= 3 && workout.id === user.locked_workout_30d) {
  offer.show({
    plan: "premium",
    placement: "lock_screen",
    headline: `Unlock ${workout.name} and every class in the premium library`,
  });
}
```

### Offer the coaching tier once the habit is there

A user on the basic plan has trained on twenty of the last thirty days and has just finished today's session. The coaching tier was built for someone like this, and the campaign that sells it goes to every basic user on a set date, including the ones who opened the app twice. For this user the moment is the end of a completed workout, with the month they have put in on the screen in front of them.

What Signals knows

`active_days_30d` 

Separate days with a completed workout in the last 30 days

`workouts_completed_7d` 

Workouts completed in the last seven days

`minutes_since_workout` 

Minutes since their last completed workout

What your app does

Shows the coaching offer on the workout summary screen when a basic user has trained on twenty or more days this month and the session ended in the last few minutes. The headline carries the number of days they have put in. A user with a few sessions this month sees the plain summary.

Define it · PythonUse it · TypeScript

workout-summary.tsx · 15 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")

# The active_day date part turns each timestamp into its calendar day, so
# the distinct count is days trained rather than workouts.
active_days_30d = Attribute(
    name="active_days_30d",
    type="int32",
    events=[workout_completed],
    aggregation="approx_count_distinct",
    property=AtomicProperty(name="derived_tstamp", date_part="active_day"),
    period=timedelta(days=30),
)

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

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

fit_upgrades_habit = StreamAttributeGroup(
    name="fit_upgrades_habit",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[active_days_30d, workouts_completed_7d, minutes_since_workout],
)
```

```
// In the code that renders the summary screen after a workout
const user = await signals.getServiceAttributes({
  name: "fit_upgrades_habit",
  attribute_key: "user_id",
  identifier: userId,
});
// { active_days_30d: 20, workouts_completed_7d: 5, minutes_since_workout: 1.4 }

if (account.plan === "basic" && user.active_days_30d >= 20 && user.minutes_since_workout < 5) {
  offer.show({
    plan: "coaching",
    placement: "workout_summary",
    headline: `${user.active_days_30d} days this month. A coach would build on that.`,
  });
}
```

### Stop prompting a user who has dismissed the offer twice

A user has closed the upgrade prompt twice this month, the second time the moment it appeared. The campaign calendar has a third prompt booked for next week and a fourth after that. Each one costs a little goodwill and gets the same answer. The prompt should go quiet for this user for a while, and the offer should sit on the plan screen, where they can find it when they are ready.

What Signals knows

`prompt_dismissals_30d` 

Times they dismissed the upgrade prompt in the last 30 days

`days_since_dismissed` 

Days since they last dismissed it

What your app does

Skips the home screen prompt for thirty days once a user has dismissed it twice in a month, and leaves the offer on the plan screen. When the quiet period ends and the user is back on the plan screen, the first scenario on this page takes over.

Define it · PythonUse it · TypeScript

home.tsx · 14 lines Copy Copied 

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

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

prompt_dismissals_30d = Attribute(
    name="prompt_dismissals_30d",
    type="int32",
    events=[upgrade_prompt_dismissed],
    aggregation="counter",
    period=timedelta(days=30),
    default_value=0,
)

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

fit_upgrades_dismissed = StreamAttributeGroup(
    name="fit_upgrades_dismissed",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[prompt_dismissals_30d, days_since_dismissed],
)
```

```
// In the code that decides whether the home screen shows the upgrade prompt
const user = await signals.getServiceAttributes({
  name: "fit_upgrades_dismissed",
  attribute_key: "user_id",
  identifier: userId,
});
// { prompt_dismissals_30d: 2, days_since_dismissed: 6.3 }

if (user.prompt_dismissals_30d >= 2 && user.days_since_dismissed < 30) {
  prompt.skip({ surface: "home" });
  offer.keep({ placement: "plan_screen" });
} else {
  prompt.show({ surface: "home" });
}
```

## 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)
