---
title: "Subscriber retention for publishers | Snowplow Signals"
description: "Snowplow Signals tells your CRM and site which subscribers have gone quiet, opened the cancel page or had a card fail, so they act while there is still time."
source: https://signals.snowplow.io/solutions/media/retention
---

[Media and publishing](https://signals.snowplow.io/solutions/media) / Subscriber retention 

# Save a subscriber while they are still reading. 

Signals tells your CRM and your site when a subscriber has gone quiet, is on the cancel page or has a failed payment, so the save reaches them while they are still a subscriber.

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

Lower subscriber churn

## Keep more subscribers by making the save offer before the subscription ends. 

Churn moves when the save reaches a subscriber while there is still something to save. Signals gives your CRM and your site how often each subscriber reads, what they read, whether they are on the cancel page right now and whether their last payment went through. Each one decides which subscriber gets the offer, the note or the card prompt, and when.

Four ways publishers use it

01 Subscribers about to cancel On the cancel page after a quiet month. Make the offer now. 02 Subscribers who have gone quiet Read most days, nothing for two weeks. Send the catch-up now. 03 Subscribers coming up for renewal Renewal close, three visits this month. Give them a reason to stay. 04 Subscribers whose card failed Card declined, still reading every day. Ask on the page as well. 

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

### Make the save offer while they are still deciding

A subscriber who read most days has opened two articles this month, and tonight they are on the cancel page. Canceling is becoming one click in more markets, so a save email after the cancellation arrives after the decision. The offer has to sit on this page, beside the cancel button, while they are still deciding.

What Signals knows

`visits_30d` 

Separate visits in the last 30 days

`cancel_page_views_1h` 

Times the cancel page was opened in the last hour

What your CRM and site do

Shows a pause or a discounted renewal on the cancel page itself when a subscriber's visits have dropped this month, and tells your CRM which offer they saw so the follow-up matches.

Define it · PythonUse it · TypeScript

article.tsx · 14 lines Copy Copied 

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

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

visits_30d = Attribute(
    name="visits_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"), "%/account/cancel%"),
    ]),
    period=timedelta(hours=1),
    default_value=0,
)

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

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

if (subscriber.cancel_page_views_1h > 0 && subscriber.visits_30d <= 4) {
  paywall.show({
    offer: "pause_or_discount",
    headline: "Pause for a month instead",
  });
}
```

### Send the catch-up while they are still a subscriber

A subscriber who read most days has not opened the site for two weeks. The win-back email usually waits for the renewal to lapse, so it goes out weeks after the reading stopped. Two weeks of silence from a daily reader is the earlier moment, and at that point they are still paying.

What Signals knows

`days_since_visit` 

Days since their last visit

`visits_30d` 

Separate visits in the last 30 days

`top_section_30d` 

The section they read most in the last 30 days

What your CRM and site do

Sends a note of what they missed in the section they read most once a subscriber who read most days has been away for two weeks, and leaves alone the one whose visits this month say they are still reading.

Define it · PythonUse it · TypeScript

crm-sync.ts · 14 lines Copy Copied 

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

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

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

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

top_section_30d = Attribute(
    name="top_section_30d",
    type="string",
    events=[page_view],
    aggregation="most_frequent",
    property=EntityProperty(vendor="com.example", name="article", major_version=1, path="section"),
    period=timedelta(days=30),
)

retention_lapse = StreamAttributeGroup(
    name="retention_lapse",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[days_since_visit, visits_30d, top_section_30d],
)
```

```
// In the job that syncs subscriber state to your CRM
const subscriber = await signals.getServiceAttributes({
  name: "retention_lapse",
  attribute_key: "user_id",
  identifier: userId,
});
// { days_since_visit: 15.2, visits_30d: 12, top_section_30d: "business" }

if (subscriber.days_since_visit >= 14 && subscriber.visits_30d >= 10) {
  crm.send({
    campaign: "what_you_missed",
    section: subscriber.top_section_30d,
  });
}
```

### Write the renewal reminder to the reading they still do

A subscriber's renewal is close, and the reminder is one email to everyone. The one who still reads every morning needs nothing from it. The one who has opened three articles this month needs a reason to stay, and a reminder that only names the price lands as a prompt to cancel.

What Signals knows

`visits_30d` 

Separate visits in the last 30 days

`top_section_30d` 

The section they read most in the last 30 days

What your CRM and site do

Fills the renewal note with the section a subscriber keeps reading when their visits this month are steady, and offers a pause or a smaller plan instead when the visits have fallen away. The renewal date comes from your billing system and the reading comes from Signals.

Define it · PythonUse it · TypeScript

renewal-email.ts · 16 lines Copy Copied 

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

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

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

top_section_30d = Attribute(
    name="top_section_30d",
    type="string",
    events=[page_view],
    aggregation="most_frequent",
    property=EntityProperty(vendor="com.example", name="article", major_version=1, path="section"),
    period=timedelta(days=30),
)

retention_renewal = StreamAttributeGroup(
    name="retention_renewal",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[visits_30d, top_section_30d],
)
```

```
// In the job that builds the renewal reminder
const subscriber = await signals.getServiceAttributes({
  name: "retention_renewal",
  attribute_key: "user_id",
  identifier: userId,
});
// { visits_30d: 3, top_section_30d: "politics" }

if (subscriber.visits_30d <= 4) {
  crm.send({ campaign: "renewal_pause_offer" });
} else {
  crm.send({
    campaign: "renewal_reminder",
    section: subscriber.top_section_30d,
  });
}
```

### Ask for the new card on the page they are reading

Billing has recorded a failed payment, and the subscriber keeps reading for a few days before access ends. The dunning email is usually the only touch, and it lands in an inbox beside everything else that asks for a card. The subscriber is on the site every morning. That is where the ask belongs, as a quiet line above the article they came for.

What Signals knows

`payment_failures_7d` 

Failed payments in the last seven days

`visits_7d` 

Separate visits in the last seven days

What your CRM and site do

Shows a quiet update-your-card line above the article once billing has recorded a failed payment and the subscriber is still visiting, and tells your CRM the prompt was shown so the dunning email does not repeat it the same day.

Define it · PythonUse it · TypeScript

article.tsx · 15 lines Copy Copied 

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

page_view = Event(name="page_view", vendor="com.snowplowanalytics.snowplow", 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,
)

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

# Billing sends payment_failed from the server, so the user id is the key both events share.
retention_payment = StreamAttributeGroup(
    name="retention_payment",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[payment_failures_7d, visits_7d],
)
```

```
// In the code that renders the article page
const subscriber = await signals.getServiceAttributes({
  name: "retention_payment",
  attribute_key: "user_id",
  identifier: userId,
});
// { payment_failures_7d: 1, visits_7d: 5 }

if (subscriber.payment_failures_7d > 0 && subscriber.visits_7d >= 2) {
  banner.show({
    tone: "quiet",
    text: "Your last payment did not go through. Update your card to keep reading.",
    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)
