---
title: "Upgrade timing for product-led teams | Snowplow Signals"
description: "Snowplow Signals tells your product which plan limit this workspace just hit and how often, so the upgrade prompt appears at that moment and names it."
source: https://signals.snowplow.io/solutions/b2b-software/upgrade
---

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

# Ask for the upgrade when the workspace hits the limit. 

Signals tells your product which plan limit a trial workspace just hit, how often it keeps hitting it and what the trial has used so far, so the prompt appears at that moment and names the limit.

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

Higher trial to paid conversion

## Convert more trials by asking for the upgrade when the workspace hits the limit. 

Trial to paid conversion moves when the upgrade prompt follows what the workspace is doing rather than the day of the trial. Signals gives your product the plan limit this workspace just hit, how many times it has hit each limit this week, how many exports a user has run today and which paid features the trial has used. The product decides when the prompt appears, where it sits and which limit it names.

Four ways product-led teams use it

01 A workspace that just hit a plan limit Fourth teammate invited, three seats allowed. Ask now and name seats. 02 A workspace hitting the same limit all week Third time at the row limit this week. Put the prompt on the blocked action. 03 A trial user exporting for the third time today Third export today on a trial. Name the paid plan's export limit. 04 A trial on its last days with nothing used beyond free Day twelve, no limit hit, no paid feature opened. Hold the prompt. 

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

### Show the prompt the moment a limit is hit, and name the limit

A trial admin has just invited a fourth teammate to a workspace whose plan allows three. The upgrade prompt runs on the trial calendar, so the next one is due on the last day of the trial, and the one before it went out on a day when nothing was blocked. The moment to ask is now, in the invite dialog, with the seat limit named, because seats are the limit this workspace has just run into.

What Signals knows

`last_limit_hit` 

The plan limit this workspace hit most recently

`minutes_since_limit_hit` 

Minutes since a limit was last hit in this workspace

What your product does

Shows the upgrade prompt in the dialog where the limit was hit, within minutes of the hit, and names that limit and the plan that lifts it. The plan table comes from your billing system. Signals supplies which limit was hit and when.

Define it · PythonUse it · TypeScript

workspace.tsx · 15 lines Copy Copied 

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

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

last_limit_hit = Attribute(
    name="last_limit_hit",
    type="string",
    events=[plan_limit_hit],
    aggregation="last",
    property=EventProperty(vendor="com.example", name="plan_limit_hit", major_version=1, path="limit"),
    ttl=timedelta(days=30),
)

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

# Plan limits belong to the workspace, not the user, so the group keys on the workspace id.
saas_upgrade_limit = StreamAttributeGroup(
    name="saas_upgrade_limit",
    version=1,
    attribute_key=workspace,
    owner="you@example.com",
    attributes=[last_limit_hit, minutes_since_limit_hit],
)
```

```
// In the code that renders the workspace after a blocked action
const workspace = await signals.getServiceAttributes({
  name: "saas_upgrade_limit",
  attribute_key: "workspace",
  identifier: workspaceId,
});
// { last_limit_hit: "seats", minutes_since_limit_hit: 0.4 }

if (workspace.minutes_since_limit_hit < 5) {
  upgradePrompt.show({
    placement: "inline",
    limit: workspace.last_limit_hit,
    plan: plans.liftsLimit(workspace.last_limit_hit),
  });
}
```

### Move the prompt from the banner to the action that keeps getting blocked

A trial workspace has hit the row limit three times this week, on three separate days. Each time a banner at the top of the page mentioned upgrading, and each time the team closed it and worked around the limit. The follow-up that reacts to a dismissed banner is scheduled for next week, after the fourth hit. Three hits on one limit in a week is a workspace that needs the plan above, and the prompt should now sit on the blocked action itself, naming the limit and what the next plan allows.

What Signals knows

`limit_hits_7d` 

How many times each plan limit was hit in the last seven days, by limit name

`last_limit_hit` 

The plan limit this workspace hit most recently

What your product does

Replaces the banner with a prompt on the blocked action once a workspace has hit the same limit three times in a week, and names that limit and the plan that lifts it. The threshold is your rule. Signals supplies the count per limit.

Define it · PythonUse it · TypeScript

workspace.tsx · 16 lines Copy Copied 

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

plan_limit_hit = Event(name="plan_limit_hit", vendor="com.example", version="1-0-0")
limit = EventProperty(vendor="com.example", name="plan_limit_hit", major_version=1, path="limit")
workspace = AttributeKey(
    name="workspace",
    property=EntityProperty(vendor="com.example", name="workspace", major_version=1, path="id"),
)

limit_hits_7d = Attribute(
    name="limit_hits_7d",
    type="dict",
    events=[plan_limit_hit],
    aggregation="category_count",
    property=limit,
    period=timedelta(days=7),
)

last_limit_hit = Attribute(
    name="last_limit_hit",
    type="string",
    events=[plan_limit_hit],
    aggregation="last",
    property=limit,
    ttl=timedelta(days=30),
)

saas_upgrade_repeat = StreamAttributeGroup(
    name="saas_upgrade_repeat",
    version=1,
    attribute_key=workspace,
    owner="you@example.com",
    attributes=[limit_hits_7d, last_limit_hit],
)
```

```
// In the code that renders the workspace after a blocked action
const workspace = await signals.getServiceAttributes({
  name: "saas_upgrade_repeat",
  attribute_key: "workspace",
  identifier: workspaceId,
});
// { limit_hits_7d: { rows: 3, seats: 1 }, last_limit_hit: "rows" }

const hits = workspace.limit_hits_7d[workspace.last_limit_hit] ?? 0;
const plan = plans.liftsLimit(workspace.last_limit_hit);

if (hits >= 3) {
  upgradePrompt.show({ placement: "blocked_action", limit: workspace.last_limit_hit, plan });
} else {
  upgradePrompt.show({ placement: "banner", limit: workspace.last_limit_hit, plan });
}
```

### Name the paid plan's export limit on the third export of the day

A trial user has just run their third export of the day, each one a few hundred rows to a spreadsheet. The trial plan allows a handful a day, and the user does not know how many are left or what the paid plan allows. The upgrade email that mentions exports goes out at the end of the trial, after the day this user needed them. The place to say what the paid plan allows is the export dialog, now, while they are about to run another.

What Signals knows

`exports_today` 

Exports this user has completed in the last day

`exports_7d` 

Exports this user has completed in the last seven days

What your product does

Adds a line to the export dialog once a trial user reaches three exports in a day, stating how many the trial allows and naming the paid plan's export limit. The limits come from your plan table. Signals supplies how many exports this user has run.

Define it · PythonUse it · TypeScript

export-dialog.tsx · 14 lines Copy Copied 

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

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

exports_today = Attribute(
    name="exports_today",
    type="int32",
    events=[export_completed],
    aggregation="counter",
    period=timedelta(days=1),
    default_value=0,
)

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

# Exports are a user's work, so this group keys on the logged-in user.
saas_upgrade_exports = StreamAttributeGroup(
    name="saas_upgrade_exports",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[exports_today, exports_7d],
)
```

```
// In the code that renders the export dialog
const user = await signals.getServiceAttributes({
  name: "saas_upgrade_exports",
  attribute_key: "user_id",
  identifier: userId,
});
// { exports_today: 3, exports_7d: 5 }

if (plan.isTrial && user.exports_today >= 3) {
  exportDialog.note({
    text: `${plan.exportsPerDay} exports a day on the trial. ${plans.paid.name} allows ${plans.paid.exportsPerDay}.`,
    action: "upgrade",
  });
}
```

### Hold the upgrade prompt for a trial that has not needed the paid plan

A trial workspace is on its twelfth day. It has hit no plan limit, and nobody in it has opened a paid feature. The end-of-trial sequence is the same for every workspace, so this one gets the upgrade prompt on the same day as the one that hit its seat limit twice. To a workspace that has used nothing beyond the free tier, that prompt reads as a bill for a product they have not tried. What this workspace needs is more time, or a person, and that is what should appear instead.

What Signals knows

`days_since_trial_start` 

Days since this workspace started its trial

`paid_features_trial` 

The paid features anyone in this workspace has used during the trial

`limit_hits_7d` 

How many times each plan limit was hit in the last seven days, by limit name

What your product does

Holds the upgrade prompt when a trial past its tenth day has used no paid feature and hit no limit, and shows an extension offer or a line to a person instead, as your rule decides. Which of the two, and for how long, is your call. Signals supplies what the trial has used.

Define it · PythonUse it · TypeScript

trial-panel.ts · 15 lines Copy Copied 

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

trial_started = Event(name="trial_started", vendor="com.example", version="1-0-0")
feature_used = Event(name="feature_used", vendor="com.example", version="1-0-0")
plan_limit_hit = Event(name="plan_limit_hit", vendor="com.example", version="1-0-0")
workspace = AttributeKey(
    name="workspace",
    property=EntityProperty(vendor="com.example", name="workspace", major_version=1, path="id"),
)

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

paid_features_trial = Attribute(
    name="paid_features_trial",
    type="string_list",
    events=[feature_used],
    aggregation="unique_list",
    property=EventProperty(vendor="com.example", name="feature_used", major_version=1, path="feature"),
    criteria=Criteria(all=[
        Criterion.eq(EventProperty(vendor="com.example", name="feature_used", major_version=1, path="tier"), "paid"),
    ]),
    ttl=timedelta(days=30),
)

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

saas_upgrade_quiet = StreamAttributeGroup(
    name="saas_upgrade_quiet",
    version=1,
    attribute_key=workspace,
    owner="you@example.com",
    attributes=[days_since_trial_start, paid_features_trial, limit_hits_7d],
)
```

```
// In the job that decides what the end-of-trial panel shows
const workspace = await signals.getServiceAttributes({
  name: "saas_upgrade_quiet",
  attribute_key: "workspace",
  identifier: workspaceId,
});
// { days_since_trial_start: 12.3, paid_features_trial: [], limit_hits_7d: {} }

const hitAnyLimit = Object.keys(workspace.limit_hits_7d ?? {}).length > 0;
const usedPaid = (workspace.paid_features_trial ?? []).length > 0;

if (workspace.days_since_trial_start >= 10 && !usedPaid && !hitAnyLimit) {
  upgradePrompt.hold({ workspace: workspaceId });
  trialPanel.show({ offer: "extension", contact: "success_team" });
}
```

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