---
title: "Expansion alerts for product-led teams | Snowplow Signals"
description: "Snowplow Signals tells your CRM when an account adds seats, nears its quota or goes quiet, so the account team reaches out while the account grows."
source: https://signals.snowplow.io/solutions/b2b-software/expansion
---

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

# Reach an account while it is growing. 

Signals tells your CRM and customer success tool when an account adds seats, nears its quota, starts a second module or goes quiet, so the account team reaches out this week rather than at the renewal call.

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

Higher net revenue retention

## Keep and grow more account revenue by reaching out while the usage moves. 

Net revenue retention moves when the account team hears about a change in usage while it is happening, with the numbers in hand. Signals gives your CRM and customer success tool how many seats each account has added, how much of its quota it has used, which modules it has picked up and how many people are active this month against last. Your CRM decides who gets a call, a note or a plan change, and when.

Four ways product-led teams use it

01 An account that keeps adding seats Five seats two weeks ago, seven more this morning. Tell the account manager this week. 02 An account near its quota before renewal Most of the quota used, renewal next month. Have the right-size conversation now. 03 An account that started a second module Three people opened reporting this month, on a plan without it. Hand the account to the reporting team. 04 An account whose usage fell against last month Forty people active last month, far fewer this month, renewal in six weeks. Tell the account team now. 

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

### Tell the account manager while the account is adding seats

An account added five seats two weeks ago and seven more this morning. Seat growth usually reaches the account manager as a line in the quarterly review, after the renewal call is booked, so the first conversation about a bigger plan happens once the team has grown into the old one. The right moment is this week, with the seat count in the message.

What Signals knows

`seats_added_30d` 

Seats added to this account in the last thirty days

`days_since_seat_added` 

Days since the last seat was added

`active_users_30d` 

People in this account who used the product in the last thirty days

What your CRM and account team do

Opens a task for the account manager when seats added this month pass the level your team set for growth and the last one was added this week, with the seat count and the active users in the note. Which plan to propose stays with the account manager.

Define it · PythonUse it · TypeScript

crm-sync.ts · 15 lines Copy Copied 

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

seat_added = Event(name="seat_added", vendor="com.example", version="1-0-0")
module_used = Event(name="module_used", vendor="com.example", version="1-0-0")
account = AttributeKey(
    name="account",
    property=EntityProperty(vendor="com.example", name="account", major_version=1, path="id"),
)

# seat_added fires once per seat, so a counter is the seat count.
seats_added_30d = Attribute(
    name="seats_added_30d",
    type="int32",
    events=[seat_added],
    aggregation="counter",
    period=timedelta(days=30),
    default_value=0,
)

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

active_users_30d = Attribute(
    name="active_users_30d",
    type="int32",
    events=[module_used],
    aggregation="approx_count_distinct",
    property=AtomicProperty(name="user_id"),
    period=timedelta(days=30),
)

# Keyed on the account, so one record covers everyone in it.
saas_expansion_seats = StreamAttributeGroup(
    name="saas_expansion_seats",
    version=1,
    attribute_key=account,
    owner="you@example.com",
    attributes=[seats_added_30d, days_since_seat_added, active_users_30d],
)
```

```
// In the job that syncs account state to your CRM
const account = await signals.getServiceAttributes({
  name: "saas_expansion_seats",
  attribute_key: "account",
  identifier: accountId,
});
// { seats_added_30d: 12, days_since_seat_added: 0.3, active_users_30d: 41 }

if (account.seats_added_30d >= crm.rules.seatGrowth && account.days_since_seat_added <= 7) {
  crm.createTask({
    owner: crm.accountManager(accountId),
    type: "expansion_outreach",
    note: `Added ${account.seats_added_30d} seats this month, ${account.active_users_30d} people active`,
  });
}
```

### Have the right-size conversation before the overage

An account has used most of its quota with three weeks left in the period, and its renewal is next month. The overage shows up on the invoice, so the conversation about a bigger plan tends to start as a question about a charge, during the renewal. Reaching out while the account is near the line, before it crosses, turns the same conversation into a plan change the account asked for.

What Signals knows

`quota_used` 

The share of its quota this account has used, as of the last report

`quota_peak_30d` 

The highest share of quota it reached in the last thirty days

What your CRM and account team do

Opens a right-size task for the account manager when the share of quota used passes the level your team set and billing says the renewal is close, and leaves alone an account that sits near the line every month and has a plan that fits. The renewal date comes from your billing system and the usage comes from Signals.

Define it · PythonUse it · TypeScript

crm-sync.ts · 17 lines Copy Copied 

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

quota_used_event = Event(name="quota_used", vendor="com.example", version="1-0-0")
percent_of_quota = EventProperty(vendor="com.example", name="quota_used", major_version=1, path="percent_of_quota")
account = AttributeKey(
    name="account",
    property=EntityProperty(vendor="com.example", name="account", major_version=1, path="id"),
)

quota_used = Attribute(
    name="quota_used",
    type="double",
    events=[quota_used_event],
    aggregation="last",
    property=percent_of_quota,
)

quota_peak_30d = Attribute(
    name="quota_peak_30d",
    type="double",
    events=[quota_used_event],
    aggregation="max",
    property=percent_of_quota,
    period=timedelta(days=30),
)

saas_expansion_quota = StreamAttributeGroup(
    name="saas_expansion_quota",
    version=1,
    attribute_key=account,
    owner="you@example.com",
    attributes=[quota_used, quota_peak_30d],
)
```

```
// In the job that syncs account state to your CRM
const account = await signals.getServiceAttributes({
  name: "saas_expansion_quota",
  attribute_key: "account",
  identifier: accountId,
});
// { quota_used: 86, quota_peak_30d: 91 }

const renewal = billing.renewalDate(accountId);

if (account.quota_used >= crm.rules.quotaWarn && renewal.withinDays(45)) {
  crm.createTask({
    owner: crm.accountManager(accountId),
    type: "right_size",
    note: "Near quota ahead of renewal",
  });
}
```

### Route a new module's first users to the people who know it

Six weeks ago nobody in this account had opened the reporting module. This month three people have, and one of them is in it most days. The expansion is under way, so the general upsell sequence arrives behind it and describes a module they are using. The people who know reporting, or a note written for an account that has started using it, land better than a sequence written for everyone.

What Signals knows

`modules_used_30d` 

The modules anyone in this account used in the last thirty days

`last_module_used` 

The module someone in this account opened most recently

What your CRM and account team do

Compares the modules used this month with the ones on the account's plan, and when one is new, assigns the account to that module's team in your CRM or sends a note about that module instead of the general sequence.

Define it · PythonUse it · TypeScript

crm-sync.ts · 15 lines Copy Copied 

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

module_used = Event(name="module_used", vendor="com.example", version="1-0-0")
module_name = EventProperty(vendor="com.example", name="module_used", major_version=1, path="module")
account = AttributeKey(
    name="account",
    property=EntityProperty(vendor="com.example", name="account", major_version=1, path="id"),
)

modules_used_30d = Attribute(
    name="modules_used_30d",
    type="string_list",
    events=[module_used],
    aggregation="unique_list",
    property=module_name,
    period=timedelta(days=30),
)

last_module_used = Attribute(
    name="last_module_used",
    type="string",
    events=[module_used],
    aggregation="last",
    property=module_name,
)

saas_expansion_modules = StreamAttributeGroup(
    name="saas_expansion_modules",
    version=1,
    attribute_key=account,
    owner="you@example.com",
    attributes=[modules_used_30d, last_module_used],
)
```

```
// In the job that syncs account state to your CRM
const account = await signals.getServiceAttributes({
  name: "saas_expansion_modules",
  attribute_key: "account",
  identifier: accountId,
});
// { modules_used_30d: ["projects", "reporting"], last_module_used: "reporting" }

const onPlan = new Set(billing.planModules(accountId));
const started = account.modules_used_30d.filter((m) => !onPlan.has(m));

if (started.length > 0) {
  crm.assign({ account: accountId, team: crm.moduleTeam(started[0]) });
  crm.send({ campaign: "module_started", module: started[0] });
}
```

### Flag the account whose usage dropped before the renewal call

An account had forty people active last month, and this month the count is well below that, with the renewal six weeks away. Usage reports reach the account team in the quarterly review, so the save case gets built during the renewal call, after the customer has made up their mind. The account team hearing this week, while most of the term is still to run, leaves room for a save plan instead of a discount.

What Signals knows

`active_users_30d` 

People in this account who used the product in the last thirty days

`active_users_60d` 

People in this account who used the product in the last sixty days

What your CRM and account team do

Compares the people active this month with the people active over the last two months, and when this month's count is well below, opens a save task for the account owner with the renewal date from billing. What the save looks like is the account team's call.

Define it · PythonUse it · TypeScript

crm-sync.ts · 18 lines Copy Copied 

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

module_used = Event(name="module_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_30d = Attribute(
    name="active_users_30d",
    type="int32",
    events=[module_used],
    aggregation="approx_count_distinct",
    property=AtomicProperty(name="user_id"),
    period=timedelta(days=30),
)

# The longer window holds last month's people too, so the two compare.
active_users_60d = Attribute(
    name="active_users_60d",
    type="int32",
    events=[module_used],
    aggregation="approx_count_distinct",
    property=AtomicProperty(name="user_id"),
    period=timedelta(days=60),
)

saas_expansion_quiet = StreamAttributeGroup(
    name="saas_expansion_quiet",
    version=1,
    attribute_key=account,
    owner="you@example.com",
    attributes=[active_users_30d, active_users_60d],
)
```

```
// In the job that syncs account state to your CRM
const account = await signals.getServiceAttributes({
  name: "saas_expansion_quiet",
  attribute_key: "account",
  identifier: accountId,
});
// { active_users_30d: 19, active_users_60d: 42 }

const share = account.active_users_30d / account.active_users_60d;
const renewal = billing.renewalDate(accountId);

if (share < crm.rules.quietAccount && renewal.withinDays(90)) {
  crm.createTask({
    owner: crm.accountManager(accountId),
    type: "save_case",
    note: `Active users well below last month, renewal ${renewal.toDateString()}`,
  });
}
```

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