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:
- Engagement drops — the customer stops using core features
- Login frequency decreases — from daily to weekly to monthly to never
- Support goes quiet — no tickets, no questions (this is bad, not good)
- Renewal evaluation — Finance asks "do we still need this?"
- 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, riskLayer 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.

