---
title: "Lapse flags for fitness apps | Snowplow Signals"
description: "Snowplow Signals tells your CRM and app which members stopped training, opened the cancel screen or had a card fail, so they act while the membership lasts."
source: https://signals.snowplow.io/solutions/health-fitness/lapses
---

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

# Reach a lapsing member while they are still a member. 

Signals tells your CRM and your app when a daily member has stopped opening the app, is on the cancel screen or has a failed card, so the check-in reaches them before the membership ends.

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

Lower subscriber churn

## Keep more members by making the check-in the week the training stops. 

Churn in a fitness app starts weeks before the cancellation, when a daily member stops opening the app, and the renewal is where it shows up. Signals gives your CRM and your app how often each member has opened the app this month, how long since their last workout, what they usually train, whether they are on the cancel screen right now and whether their last payment went through. Your CRM decides who gets the check-in, and your app decides what the screen shows.

Four ways fitness apps use it

01 Daily members who have gone quiet Trained most days, opened the app once this week. Check in now. 02 Members on the cancel screen On the cancel screen after a slow month. Offer the pause beside the button. 03 Members coming up for renewal Renewal in two weeks, three workouts this month. Name what they still do. 04 Members whose card failed Card declined, trained three times this week. Ask in the app too. 

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

### Send the check-in the week the training stops

A member who trained most mornings has opened the app once this week. The drop reaches the churn report at renewal, weeks after the habit broke, and by then the win-back goes to someone who has made up their mind. One week of silence from a daily member is the earlier moment. They are still paying, and the habit is still close enough to pick back up.

What Signals knows

`sessions_30d` 

Separate app sessions in the last 30 days

`days_since_workout` 

Days since their last completed workout

`top_workout_30d` 

The workout type they completed most in the last 30 days

What your CRM and app do

Sends a check-in built around the workout they do most once a member who trained most days this month has gone a week without one. It leaves alone the member whose sessions say they are still training, and your CRM picks the message and the channel.

Define it · PythonUse it · TypeScript

crm-sync.ts · 14 lines Copy Copied 

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

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

sessions_30d = Attribute(
    name="sessions_30d",
    type="int32",
    events=[screen_view],
    aggregation="approx_count_distinct",
    property=AtomicProperty(name="domain_sessionid"),
    period=timedelta(days=30),
)

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

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

# Members are signed in, so the group keys on the user id your app sets.
fit_lapses_quiet = StreamAttributeGroup(
    name="fit_lapses_quiet",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[sessions_30d, days_since_workout, top_workout_30d],
)
```

```
// In the job that syncs member state to your CRM
const member = await signals.getServiceAttributes({
  name: "fit_lapses_quiet",
  attribute_key: "user_id",
  identifier: userId,
});
// { sessions_30d: 14, days_since_workout: 6.8, top_workout_30d: "strength" }

if (member.days_since_workout >= 6 && member.sessions_30d >= 12) {
  crm.send({
    campaign: "weekly_check_in",
    workout: member.top_workout_30d,
  });
}
```

### Offer the pause while they are still deciding

A member who used to train most days has had a slow month, and tonight they are on the cancel screen. Canceling is becoming one tap in more markets, so a save email after the cancellation arrives after the decision. The pause offer has to sit on this screen, beside the cancel button and never in front of it, while the member is still weighing it up.

What Signals knows

`sessions_30d` 

Separate app sessions in the last 30 days

`cancel_screen_views_1h` 

Times the cancel screen was opened in the last hour

What your CRM and app do

Shows a pause option beside the cancel button when a member's sessions have dropped this month, keeps the cancel button where it is, and tells your CRM which offer was shown so the follow-up does not repeat it.

Define it · PythonUse it · TypeScript

cancel-screen.tsx · 15 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import (
    Attribute, AtomicProperty, Criteria, Criterion, Event, EventProperty, StreamAttributeGroup, user_id,
)

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

sessions_30d = Attribute(
    name="sessions_30d",
    type="int32",
    events=[screen_view],
    aggregation="approx_count_distinct",
    property=AtomicProperty(name="domain_sessionid"),
    period=timedelta(days=30),
)

# The cancel flow is a native screen, so the criterion is the screen name rather than a URL.
cancel_screen_views_1h = Attribute(
    name="cancel_screen_views_1h",
    type="int32",
    events=[screen_view],
    aggregation="counter",
    criteria=Criteria(all=[
        Criterion.eq(EventProperty(vendor="com.example", name="screen_view", major_version=1, path="screen"), "cancel_membership"),
    ]),
    period=timedelta(hours=1),
    default_value=0,
)

fit_lapses_cancel = StreamAttributeGroup(
    name="fit_lapses_cancel",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[sessions_30d, cancel_screen_views_1h],
)
```

```
// In the code that renders the cancel screen
const member = await signals.getServiceAttributes({
  name: "fit_lapses_cancel",
  attribute_key: "user_id",
  identifier: userId,
});
// { sessions_30d: 4, cancel_screen_views_1h: 1 }

if (member.cancel_screen_views_1h > 0 && member.sessions_30d <= 6) {
  cancelScreen.addOption({
    offer: "pause_membership",
    label: "Pause for a month instead",
    placement: "beside_cancel",
  });
}
```

### Write the renewal note to the training they still do

A member whose renewal is close gets the same reminder as everyone else. For the member still training four mornings a week, that is fine. For the member who has completed three workouts this month, a note that only names the price lands as a prompt to cancel, and the thing that would keep them is the workout they still show up for. For the member who has not trained at all this month, a pause is a better offer than a reminder.

What Signals knows

`workouts_30d` 

Workouts completed in the last 30 days

`top_workout_30d` 

The workout type they completed most in the last 30 days

`days_since_workout` 

Days since their last completed workout

What your CRM and app do

Fills the renewal note with the workout type a member keeps completing when their month has thinned, and offers a pause or a smaller plan instead when the month is empty. The renewal date comes from your billing system and the training comes from Signals.

Define it · PythonUse it · TypeScript

crm-sync.ts · 18 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")

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

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

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

fit_lapses_renewal = StreamAttributeGroup(
    name="fit_lapses_renewal",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[workouts_30d, top_workout_30d, days_since_workout],
)
```

```
// In the job that builds the renewal reminder
const member = await signals.getServiceAttributes({
  name: "fit_lapses_renewal",
  attribute_key: "user_id",
  identifier: userId,
});
// { workouts_30d: 3, top_workout_30d: "yoga", days_since_workout: 9.1 }

if (member.workouts_30d === 0) {
  crm.send({ campaign: "renewal_pause_offer" });
} else if (member.workouts_30d <= 4) {
  crm.send({
    campaign: "renewal_reminder",
    workout: member.top_workout_30d,
  });
} else {
  crm.send({ campaign: "renewal_reminder" });
}
```

### Ask for the new card on the screen they open every morning

Billing has recorded a failed payment, and the member keeps training for a few days before access ends. The dunning email goes out, and it competes with every other message in the inbox asking for a card number. This member opens the app most mornings to start a session. A quiet line on the home screen, with the email still going out, reaches them where they are.

What Signals knows

`payment_failures_7d` 

Failed payments in the last seven days

`workouts_7d` 

Workouts completed in the last seven days

What your CRM and app do

Shows a quiet update-your-card line on the home screen once billing has recorded a failed payment and the member has trained this week, and leaves the dunning email to run as it does. Your billing tool still owns the retry.

Define it · PythonUse it · TypeScript

home-screen.tsx · 15 lines Copy Copied 

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

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

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

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

# payment_failed arrives from your billing webhook, not the app, and the user id is what joins it to the workouts.
fit_lapses_payment = StreamAttributeGroup(
    name="fit_lapses_payment",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[payment_failures_7d, workouts_7d],
)
```

```
// In the code that renders the home screen
const member = await signals.getServiceAttributes({
  name: "fit_lapses_payment",
  attribute_key: "user_id",
  identifier: userId,
});
// { payment_failures_7d: 1, workouts_7d: 3 }

if (member.payment_failures_7d > 0 && member.workouts_7d >= 1) {
  homeScreen.showNotice({
    tone: "quiet",
    text: "Your last payment did not go through. Update your card to keep training.",
    href: "/account/billing",
  });
}
```

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