FFastero
Back to blog

Blog article

How to Detect Silent Churn: Paying Customers Who Stopped Using Your Product

A customer who's paying but hasn't logged in for 6 weeks is already gone — you just haven't been told yet. Here's how to detect silent churn early enough to save the account, with practical code and SQL examples.

Fastero Dev TeamFastero Dev Team
2026-07-26
churnsilent-churncustomer-healthstripeproduct-analyticsretention
How to Detect Silent Churn: Paying Customers Who Stopped Using Your Product

There's a customer in your Stripe account right now who's paying you every month, whose payment is succeeding, whose subscription shows "active" in every dashboard — and who hasn't logged into your product in 47 days. They're gone. You just don't know it yet.

This is silent churn. The subscription is technically alive, but the relationship is over. The customer will cancel eventually — maybe next month, maybe in three months when someone in Finance audits their software spend. Or maybe they'll stay subscribed forever out of inertia, which sounds good until you realize they'll never expand, never refer, and will churn the moment any friction appears (a price increase, a card expiration, a budget cut).

Silent churn is invisible to every standard SaaS metric until the moment the subscription cancels. Your MRR looks healthy. Your billing system shows green. Your churn dashboard only counts explicit cancellations. And by the time the cancellation event fires, the customer has been disengaged for weeks or months — far past the window where intervention could have made a difference.

Why standard churn metrics miss this

Your SaaS metrics tool (Baremetrics, ChartMogul, ProfitWell) calculates churn from billing events. A customer churns when their subscription status changes from active to canceled. That's the definition. But cancellation is the last step in a process that started weeks or months earlier:

  1. Engagement drops — the customer stops using core features
  2. Login frequency decreases — from daily to weekly to monthly to never
  3. Support goes quiet — no tickets, no questions (this is bad, not good)
  4. Renewal evaluation — Finance asks "do we still need this?"
  5. Cancellation — the billing event fires

Your metrics tool sees step 5. By then, it's too late. The customer made their decision at step 4, and the behavioral signal was available at step 1. The gap between "detectable" and "detected" is where recoverable revenue lives.

Building a silent churn detection system

You need three things: product usage data, billing data, and a way to join them. Here's how to build each layer.

Layer 1: Define "active"

Not every product event counts equally. A customer who logged in and immediately bounced isn't "active." Define meaningful engagement for your product:

# Define meaningful product events for your app
MEANINGFUL_EVENTS = [
    "report_created",
    "dashboard_viewed",
    "query_executed",
    "export_downloaded",
    "integration_used",
    "team_member_invited",
]
 
# Events that DON'T count as meaningful engagement
SHALLOW_EVENTS = [
    "page_viewed",       # Too passive
    "logged_in",         # Doesn't indicate value received
    "settings_opened",   # Often precedes cancellation
    "billing_viewed",    # Definitely precedes cancellation
]

The distinction matters. If you count "logged in" as active, you'll miss customers who open the app out of habit, see nothing useful, and close the tab. Meaningful engagement means they used the product to accomplish something.

Layer 2: Score customer health

Combine product usage with billing signals to create a health score:

# customer_health_score.py
from datetime import datetime, timedelta
from dataclasses import dataclass
 
 
@dataclass
class CustomerHealth:
    email: str
    stripe_status: str
    mrr: float
    last_meaningful_event: datetime | None
    events_last_30d: int
    events_last_7d: int
    days_inactive: int
    health_score: float  # 0-100
    risk_level: str      # "healthy", "at_risk", "critical", "zombie"
 
 
def calculate_health_score(
    stripe_status: str,
    last_meaningful_event: datetime | None,
    events_last_30d: int,
    events_last_7d: int,
) -> tuple[float, str]:
    """
    Calculate a customer health score from 0-100.
    Higher = healthier. Weights:
      - Recent activity (last 7d): 40%
      - Trend (30d volume): 30%
      - Recency (days since last event): 30%
    """
    now = datetime.utcnow()
    
    # Recency score (0-100)
    if last_meaningful_event is None:
        recency_score = 0
    else:
        days_since = (now - last_meaningful_event).days
        if days_since <= 3:
            recency_score = 100
        elif days_since <= 7:
            recency_score = 80
        elif days_since <= 14:
            recency_score = 60
        elif days_since <= 30:
            recency_score = 30
        else:
            recency_score = max(0, 10 - (days_since - 30))
    
    # Recent activity score (0-100)
    if events_last_7d >= 10:
        recent_score = 100
    elif events_last_7d >= 5:
        recent_score = 75
    elif events_last_7d >= 1:
        recent_score = 40
    else:
        recent_score = 0
    
    # Trend score (0-100)
    if events_last_30d >= 30:
        trend_score = 100
    elif events_last_30d >= 15:
        trend_score = 70
    elif events_last_30d >= 5:
        trend_score = 40
    elif events_last_30d >= 1:
        trend_score = 15
    else:
        trend_score = 0
    
    # Weighted composite
    score = (recent_score * 0.4) + (trend_score * 0.3) + (recency_score * 0.3)
    
    # Risk level
    if score >= 60:
        risk = "healthy"
    elif score >= 35:
        risk = "at_risk"
    elif score >= 10:
        risk = "critical"
    else:
        risk = "zombie"
    
    return score, risk

Layer 3: The detection query

Run this against your warehouse to find customers at each risk level:

WITH customer_activity AS (
  SELECT
    sc.email,
    sc.id AS stripe_customer_id,
    s.status AS subscription_status,
    SUM(si.unit_amount * si.quantity) / 100.0 AS mrr,
    MAX(pe.event_time) AS last_meaningful_event,
    COUNT(CASE WHEN pe.event_time >= CURRENT_DATE - INTERVAL '30 days' 
               THEN 1 END) AS events_30d,
    COUNT(CASE WHEN pe.event_time >= CURRENT_DATE - INTERVAL '7 days' 
               THEN 1 END) AS events_7d,
    COALESCE(
      CURRENT_DATE - MAX(pe.event_time)::date,
      999
    ) AS days_inactive
  FROM stripe_customers sc
  JOIN stripe_subscriptions s ON sc.id = s.customer_id
  JOIN stripe_subscription_items si ON s.id = si.subscription_id
  LEFT JOIN product_events pe
    ON sc.email = pe.user_email
    AND pe.event_name IN (
      'report_created', 'dashboard_viewed', 'query_executed',
      'export_downloaded', 'integration_used'
    )
    AND pe.event_time >= CURRENT_DATE - INTERVAL '90 days'
  WHERE s.status = 'active'
  GROUP BY sc.email, sc.id, s.status
)
SELECT
  email,
  mrr,
  days_inactive,
  events_30d,
  events_7d,
  CASE
    WHEN events_7d >= 5 THEN 'healthy'
    WHEN events_7d >= 1 AND events_30d >= 5 THEN 'healthy'
    WHEN events_30d >= 1 AND days_inactive <= 14 THEN 'at_risk'
    WHEN days_inactive BETWEEN 15 AND 30 THEN 'critical'
    ELSE 'zombie'
  END AS risk_level
FROM customer_activity
WHERE days_inactive >= 14  -- Only show at-risk and worse
ORDER BY mrr DESC;

Layer 4: Automated intervention

Different risk levels deserve different responses:

Risk level Days inactive Action
Healthy 0-7 None
At risk 8-14 Automated "we miss you" email + CS dashboard flag
Critical 15-30 CSM outreach: check-in call or personal email
Zombie 30+ Executive-sponsored outreach or accept likely churn
# intervention_workflow.py
 
def trigger_intervention(customer: CustomerHealth):
    """Dispatch the right intervention based on risk level."""
    
    if customer.risk_level == "at_risk":
        # Automated: "We noticed you haven't logged in..."
        send_reengagement_email(
            email=customer.email,
            template="gentle_checkin",
            context={
                "days_inactive": customer.days_inactive,
                "last_feature_used": get_last_feature(customer.email),
            },
        )
        # Flag in CRM for CS awareness
        update_hubspot_property(
            customer.email,
            "customer_health_status",
            "at_risk",
        )
    
    elif customer.risk_level == "critical":
        # Human outreach: assign to CSM
        create_hubspot_task(
            assigned_to=get_csm_for_customer(customer.email),
            title=f"Silent churn risk: {customer.email} ({customer.days_inactive}d inactive)",
            notes=(
                f"Customer paying ${customer.mrr:.0f}/mo but hasn't used "
                f"the product in {customer.days_inactive} days. "
                f"Last 30d events: {customer.events_last_30d}. "
                f"Recommend personal check-in."
            ),
            priority="HIGH",
        )
    
    elif customer.risk_level == "zombie":
        # Alert RevOps leadership
        send_slack_alert(
            channel="#revops-alerts",
            text=(
                f"🧟 Zombie customer: {customer.email}\n"
                f"MRR: ${customer.mrr:.0f}/mo | "
                f"Inactive: {customer.days_inactive} days | "
                f"Zero product events in 30+ days\n"
                f"Likely churning at next renewal or card expiry."
            ),
        )

The math on why this matters

Typical SaaS numbers at $2M ARR:

  • 500 active customers at $333/month average
  • 5% monthly gross churn = 25 customers/month churning
  • Of those 25, roughly 30% (8 customers) showed zombie behavior for 30+ days before canceling
  • At $333/month average, that's $2,664/month in "predictable" churn — $32k/year

If early detection + intervention saves even 25% of those zombie customers, that's $8k/year in retained revenue. At $10M ARR with the same rates, it's $40k/year. The detection system pays for itself immediately.

What makes this hard in practice

Identity matching. Your product might identify users by user_id while Stripe identifies them by customer_id and HubSpot by contact_id. Joining across all three requires a reliable identity resolution layer — usually email, but sometimes company domain or a custom mapping table.

Defining "meaningful" activity. Some customers use your product in bursts — heavy usage for a week during quarter-end reporting, then nothing for 3 weeks. They're not churning; they're seasonal users. Your health score needs to account for each customer's individual usage pattern, not just absolute thresholds.

Multi-seat accounts. An enterprise customer with 20 seats might show "inactive" for the billing contact while 15 team members are actively using the product daily. You need to score at the account level, not just the individual level.

Privacy/data access. Not every product tracks granular usage events. If your product usage data lives in a separate system (Amplitude, Mixpanel, PostHog) that's not connected to your billing data, you have an infrastructure problem to solve before you can build detection.

The integrated approach

The reason silent churn detection is hard is that it requires joining three different data sources: billing (Stripe), CRM (HubSpot/Salesforce), and product analytics (your event stream). Most tools handle one or two of these. Very few join all three.

Fastero's revenue leak detection connects to your billing, CRM, and product database, runs the cross-source health scoring continuously, and surfaces zombie customers before they formally churn. But whether you use a tool or build the system described above, the principle is the same: billing status alone is a lagging indicator. Usage is the leading indicator. You need both in the same view.


Try Fastero free — connect your CRM and billing data, get live revenue dashboards, and set up alerts that catch leaks before your next board meeting. No credit card required.

Last updated: July 2026.