---
title: "Cancel flow for product-led teams | Snowplow Signals"
description: "Snowplow Signals tells your save flow what a user did this month and what brought them to the cancel page, so it offers the right thing while they decide."
source: https://signals.snowplow.io/solutions/b2b-software/cancel-flow
---

[B2B software](https://signals.snowplow.io/solutions/b2b-software) / Cancel flow 

# A save offer that arrives while the user is deciding. 

Signals tells your save flow how much this user has used the product this month, what they used most and what went wrong this week, so the offer can meet them on the cancel page rather than in their inbox afterward.

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

Higher save rate

## Save more users by making the offer while the cancel page is open. 

The save rate moves when the offer reaches a user before the cancellation goes through, and when it fits the reason they are leaving. Signals gives your save flow how often this user has been in the product this month, what they used most, which errors they hit this week and whether the cancel page is open right now. Your save flow decides whether that calls for a pause, a smaller plan, a fix or a person.

Four ways product-led teams use it

01 A user on the cancel page after a quiet month Two sessions this month, mostly reports. Offer a pause beside the button. 02 A user on the cancel page who was active yesterday In the product every day this week, no errors. Ask what changed, do not discount. 03 A user who hit the same error three times, then opened the cancel page Same export error three times this week, now on the cancel page. Send the fix and a person. 04 An admin on the cancel page while the team is still using it Admin on the cancel page, three teammates in the product this week. Show them the team's week. 

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

### Offer a pause or a smaller plan while the page is open

A user who was in the product most working days has logged in twice this month, and now they are on the cancel page. The save email is written for the moment after the cancellation, so it reaches an inbox after the user has decided and the seat has gone. The offer belongs on this page, next to the cancel button and never over it, while the user is still making up their mind. It lands better when it names the one thing they kept coming back for.

What Signals knows

`sessions_30d` 

Separate sessions in the last 30 days

`cancel_page_views_1h` 

Times the cancel page was opened in the last hour

`top_feature_30d` 

The feature they used most in the last 30 days

What your save flow does

Shows a pause or a smaller plan beside the cancel button when a user's sessions have fallen off this month, names the feature they used most in the offer, and leaves the cancel button where it is.

Define it · PythonUse it · TypeScript

cancel-page.tsx · 15 lines Copy Copied 

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

page_view = Event(name="page_view", vendor="com.snowplowanalytics.snowplow", version="1-0-0")
feature_used = Event(name="feature_used", vendor="com.example", version="1-0-0")

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

cancel_page_views_1h = Attribute(
    name="cancel_page_views_1h",
    type="int32",
    events=[page_view],
    aggregation="counter",
    criteria=Criteria(all=[
        Criterion.like(AtomicProperty(name="page_url"), "%/settings/billing/cancel%"),
    ]),
    period=timedelta(hours=1),
    default_value=0,
)

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

# Users are logged in, so this group keys on the user id.
saas_cancel_quiet = StreamAttributeGroup(
    name="saas_cancel_quiet",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[sessions_30d, cancel_page_views_1h, top_feature_30d],
)
```

```
// In the code that renders the cancel page
const user = await signals.getServiceAttributes({
  name: "saas_cancel_quiet",
  attribute_key: "user_id",
  identifier: userId,
});
// { sessions_30d: 2, cancel_page_views_1h: 1, top_feature_30d: "reports" }

if (user.cancel_page_views_1h > 0 && user.sessions_30d <= 3) {
  saveFlow.offer({
    kind: "pause_or_smaller_plan",
    keeps: user.top_feature_30d,
    beside: "cancel_button",
  });
}
```

### Ask what changed when a daily user opens the cancel page

A user who was in the product yesterday, and every day before that this week, has opened the cancel page. Nothing broke for them this week. A discount here answers a question they did not ask, and it goes to someone who was getting value a day ago. The usual flow learns the reason from the exit survey, after the cancellation, which is late for a user who is still here. The better move is to ask now, and to bring a person into the conversation when the account is one you want to keep.

What Signals knows

`sessions_7d` 

Separate sessions in the last seven days

`errors_7d` 

Errors they hit in the last seven days, by type

`cancel_page_views_1h` 

Times the cancel page was opened in the last hour

What your save flow does

Replaces the discount with a short question when a user with daily sessions and no errors this week opens the cancel page, and routes the answer to a person on your team along with the usage that says this user was getting value.

Define it · PythonUse it · TypeScript

cancel-page.tsx · 17 lines Copy Copied 

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

page_view = Event(name="page_view", vendor="com.snowplowanalytics.snowplow", version="1-0-0")
error_shown = Event(name="error_shown", vendor="com.example", version="1-0-0")

sessions_7d = Attribute(
    name="sessions_7d",
    type="int32",
    events=[page_view],
    aggregation="approx_count_distinct",
    property=AtomicProperty(name="domain_sessionid"),
    period=timedelta(days=7),
)

errors_7d = Attribute(
    name="errors_7d",
    type="dict",
    events=[error_shown],
    aggregation="category_count",
    property=EventProperty(vendor="com.example", name="error_shown", major_version=1, path="code"),
    period=timedelta(days=7),
)

cancel_page_views_1h = Attribute(
    name="cancel_page_views_1h",
    type="int32",
    events=[page_view],
    aggregation="counter",
    criteria=Criteria(all=[
        Criterion.like(AtomicProperty(name="page_url"), "%/settings/billing/cancel%"),
    ]),
    period=timedelta(hours=1),
    default_value=0,
)

saas_cancel_active = StreamAttributeGroup(
    name="saas_cancel_active",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[sessions_7d, errors_7d, cancel_page_views_1h],
)
```

```
// In the code that renders the cancel page
const user = await signals.getServiceAttributes({
  name: "saas_cancel_active",
  attribute_key: "user_id",
  identifier: userId,
});
// { sessions_7d: 6, errors_7d: {}, cancel_page_views_1h: 1 }

const cleanWeek = Object.keys(user.errors_7d ?? {}).length === 0;

if (user.cancel_page_views_1h > 0 && user.sessions_7d >= 5 && cleanWeek) {
  saveFlow.ask({
    question: "What changed?",
    routeTo: "customer_team",
    context: { sessionsThisWeek: user.sessions_7d },
  });
}
```

### Send the fix and a person, not an offer

This user hit the same export error three times this week, and tonight the cancel page is open. The error went into a log and the user went into the save flow, and the two meet in a support ticket a few days later, if the user writes one. A discount here reads as paying them to put up with a bug. What they need on this page is the workaround, in plain words, and a person who opens the conversation knowing which error it was.

What Signals knows

`errors_7d` 

Errors they hit in the last seven days, by type

`cancel_page_views_1h` 

Times the cancel page was opened in the last hour

What your save flow does

Shows the known fix for the error this user kept hitting and opens a chat with a person who has the error code in front of them, in place of a discount. The discount stays in reserve for users whose week went fine.

Define it · PythonUse it · TypeScript

cancel-page.tsx · 14 lines Copy Copied 

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

page_view = Event(name="page_view", vendor="com.snowplowanalytics.snowplow", version="1-0-0")
error_shown = Event(name="error_shown", vendor="com.example", version="1-0-0")

errors_7d = Attribute(
    name="errors_7d",
    type="dict",
    events=[error_shown],
    aggregation="category_count",
    property=EventProperty(vendor="com.example", name="error_shown", major_version=1, path="code"),
    period=timedelta(days=7),
)

cancel_page_views_1h = Attribute(
    name="cancel_page_views_1h",
    type="int32",
    events=[page_view],
    aggregation="counter",
    criteria=Criteria(all=[
        Criterion.like(AtomicProperty(name="page_url"), "%/settings/billing/cancel%"),
    ]),
    period=timedelta(hours=1),
    default_value=0,
)

saas_cancel_errors = StreamAttributeGroup(
    name="saas_cancel_errors",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[errors_7d, cancel_page_views_1h],
)
```

```
// In the code that renders the cancel page
const user = await signals.getServiceAttributes({
  name: "saas_cancel_errors",
  attribute_key: "user_id",
  identifier: userId,
});
// { errors_7d: { export_timeout: 3 }, cancel_page_views_1h: 1 }

const [code, count] = Object.entries(user.errors_7d ?? {}).sort((a, b) => b[1] - a[1])[0] ?? [];

if (user.cancel_page_views_1h > 0 && count >= 3) {
  saveFlow.showFix({ error: code });
  support.openChat({ userId, context: { error: code, timesThisWeek: count } });
}
```

### Show the admin what the team used this week before they cancel for everyone

The admin who pays the bill opens the cancel page. They have not been in the product much themselves, and the invoice is the part of it they see. Three of their teammates were in it this week, mostly in the shared dashboards. Judged by the admin's own usage, the account looks quiet, and a pause offer written for a quiet user misses the point. The account is the unit here, and the teammates' week is the case for staying.

What Signals knows

`active_users_7d` 

Separate users in this account in the last seven days

`sessions_7d` 

Separate sessions across the account in the last seven days

`top_feature_7d` 

The feature the account used most in the last seven days

What your save flow does

Shows the admin how many teammates used the product this week and what they used most, beside the cancel button, when the account has more than one active user this week, and offers the smaller plan that keeps those seats.

Define it · PythonUse it · TypeScript

cancel-page.tsx · 15 lines Copy Copied 

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

page_view = Event(name="page_view", vendor="com.snowplowanalytics.snowplow", version="1-0-0")
feature_used = Event(name="feature_used", vendor="com.example", version="1-0-0")
account = AttributeKey(
    name="account",
    property=EntityProperty(vendor="com.example", name="account", major_version=1, path="id"),
)

active_users_7d = Attribute(
    name="active_users_7d",
    type="int32",
    events=[page_view],
    aggregation="approx_count_distinct",
    property=AtomicProperty(name="user_id"),
    period=timedelta(days=7),
)

sessions_7d = Attribute(
    name="sessions_7d",
    type="int32",
    events=[page_view],
    aggregation="approx_count_distinct",
    property=AtomicProperty(name="domain_sessionid"),
    period=timedelta(days=7),
)

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

# Keyed on the account, not the admin, so the cancel page asks about the team.
saas_cancel_account = StreamAttributeGroup(
    name="saas_cancel_account",
    version=1,
    attribute_key=account,
    owner="you@example.com",
    attributes=[active_users_7d, sessions_7d, top_feature_7d],
)
```

```
// In the code that renders the cancel page, for the account the admin is canceling
const team = await signals.getServiceAttributes({
  name: "saas_cancel_account",
  attribute_key: "account",
  identifier: accountId,
});
// { active_users_7d: 3, sessions_7d: 14, top_feature_7d: "shared_dashboards" }

if (team.active_users_7d > 1) {
  saveFlow.showTeamUsage({
    activeUsers: team.active_users_7d,
    usedMost: team.top_feature_7d,
    offer: "smaller_plan_keeps_seats",
  });
}
```

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