Complete example
This page walks through a complete churn scoring example from start to finish.
You will see:
- a realistic SaaS churn configuration
- concrete product events
- the exact score returned by Tiaude
- why the user was classified as critical
- how to get the same result with incremental scoring
- how the score changes later with
refreshScore()
Scenario
Imagine a B2B SaaS workspace called workspace_acme.
This workspace was active earlier in the month, but usage has slowed down. A payment failed recently, and someone clicked a cancellation button shortly before the score was computed.
We will compute the score at:
const now = "2026-05-22T12:00:00.000Z";The raw product events are the source of truth. Tiaude uses those events to compute an explainable churn risk score.
Configuration
First, create a churn scorer.
import { createChurnScorer } from "tiaude";
const scorer = createChurnScorer({
baseRisk: 20,
levels: {
medium: 30,
high: 60,
critical: 80,
},
confidence: {
targetRelevantEvents: 10,
},
freshness: {
freshAfterHours: 24,
staleAfterHours: 72,
},
signals: [
{
id: "inactive",
type: "absence",
event: "app.opened",
weight: 30,
halfLifeDays: 14,
reason: "No recent app activity",
action: "Send a re-engagement email with a direct link to the dashboard",
},
{
id: "low_usage",
type: "frequency_below",
event: "feature.used",
weight: 25,
halfLifeDays: 7,
threshold: 3,
mode: "linear",
reason: "Low recent feature usage",
action: "Suggest one high-value workflow the workspace has not used recently",
},
{
id: "billing_failed",
type: "occurrence",
event: "billing.payment_failed",
weight: 40,
halfLifeDays: 3,
reason: "Recent billing failure",
action: "Ask an admin to update the payment method",
},
{
id: "cancel_intent",
type: "occurrence",
event: "subscription.cancel_clicked",
weight: 35,
halfLifeDays: 5,
reason: "Recent cancellation intent",
action: "Trigger a human retention follow-up",
},
{
id: "strong_usage",
type: "frequency_above",
event: "feature.used",
weight: -20,
halfLifeDays: 7,
threshold: 8,
mode: "linear",
reason: "Strong recent product usage",
action: "Do not send aggressive retention messaging",
},
],
});This configuration combines risk-increasing and risk-reducing signals.
inactiveincreases risk when the user has not opened the app recently.low_usageincreases risk when recent feature usage is below the threshold.billing_failedincreases risk when a payment failed recently.cancel_intentincreases risk when the user clicked a cancellation flow.strong_usagedecreases risk when recent usage is strong.
Events
Here are the raw events for this workspace.
const events = [
{ name: "app.opened", timestamp: "2026-05-01T09:00:00.000Z" },
{ name: "feature.used", timestamp: "2026-05-02T10:15:00.000Z" },
{ name: "feature.used", timestamp: "2026-05-03T11:20:00.000Z" },
{ name: "feature.used", timestamp: "2026-05-05T14:00:00.000Z" },
{ name: "feature.used", timestamp: "2026-05-08T15:30:00.000Z" },
{ name: "feature.used", timestamp: "2026-05-12T09:45:00.000Z" },
{ name: "feature.used", timestamp: "2026-05-14T16:10:00.000Z" },
{ name: "billing.payment_failed", timestamp: "2026-05-18T08:05:00.000Z" },
{
name: "subscription.cancel_clicked",
timestamp: "2026-05-20T17:40:00.000Z",
},
];The timeline tells a simple story:
| Date | Event | Meaning |
|---|---|---|
| 2026-05-01 | app.opened | Last app open, 21 days before scoring |
| 2026-05-02 → 2026-05-14 | feature.used | The workspace used the product earlier in the month |
| 2026-05-18 | billing.payment_failed | A payment failed around 4 days before scoring |
| 2026-05-20 | subscription.cancel_clicked | Someone showed cancellation intent less than 2 days before scoring |
Compute the score with scoreUser()
Use scoreUser() when you want to rebuild the score from raw events.
const result = scorer.scoreUser({
userId: "workspace_acme",
events,
now: "2026-05-22T12:00:00.000Z",
});This is useful when:
- you are scoring a user for the first time
- you need to rebuild state from raw events
- you changed your configuration
- a stored state cannot be reused safely
Exact output
The public output looks like this.
{
"riskScore": 90.26735505328286,
"riskLevel": "critical",
"confidence": 0.9602525677891275,
"freshness": "aging",
"reasons": [
{
"signalId": "cancel_intent",
"impact": 27.407621714017367,
"message": "Recent cancellation intent"
},
{
"signalId": "inactive",
"impact": 19.45883786302518,
"message": "No recent app activity"
},
{
"signalId": "billing_failed",
"impact": 15.286610130396936,
"message": "Recent billing failure"
},
{
"signalId": "low_usage",
"impact": 12.010988727571823,
"message": "Low recent feature usage"
},
{
"signalId": "strong_usage",
"impact": -3.8967033817284538,
"message": "Strong recent product usage"
}
],
"recommendedActions": [
"Trigger a human retention follow-up",
"Send a re-engagement email with a direct link to the dashboard",
"Ask an admin to update the payment method",
"Suggest one high-value workflow the workspace has not used recently"
]
}For display purposes, you can round the values:
{
"riskScore": 90.27,
"riskLevel": "critical",
"confidence": 0.96,
"freshness": "aging"
}Why this user is critical
The score is critical because several high-risk signals are active at the same time.
| Signal | Impact | Explanation |
|---|---|---|
cancel_intent | +27.41 | The user clicked the cancellation flow recently |
inactive | +19.46 | The last app open was 21 days before scoring |
billing_failed | +15.29 | A payment failed recently |
low_usage | +12.01 | Recent feature usage has decayed below the configured threshold |
strong_usage | -3.90 | Past usage still slightly reduces risk |
The final score starts from baseRisk: 20, then combines the active signal impacts.
20
+ 27.41 cancel_intent
+ 19.46 inactive
+ 15.29 billing_failed
+ 12.01 low_usage
- 3.90 strong_usage
= 90.27Because the configured critical threshold is 80, a score of 90.27 produces:
{
"riskLevel": "critical"
}Confidence
The confidence is high:
{
"confidence": 0.9602525677891275
}This happens because the workspace has 9 relevant events and the configuration uses:
confidence: {
targetRelevantEvents: 10;
}So Tiaude has almost enough relevant event history to reach maximum confidence.
Freshness
The score freshness is:
{
"freshness": "aging"
}The last relevant event happened on:
"2026-05-20T17:40:00.000Z";The score was computed at:
"2026-05-22T12:00:00.000Z";That means the latest relevant event is older than the freshAfterHours threshold, but not yet older than the staleAfterHours threshold.
freshness: {
freshAfterHours: 24,
staleAfterHours: 72,
}So the score is still useful, but it is no longer fresh.
Recommended actions
Tiaude also returns actions attached to the active risk signals.
[
"Trigger a human retention follow-up",
"Send a re-engagement email with a direct link to the dashboard",
"Ask an admin to update the payment method",
"Suggest one high-value workflow the workspace has not used recently"
]In this example, the highest-priority action is a human retention follow-up because the strongest signal is cancel_intent.
Incremental scoring with track()
In production, you usually do not want to rebuild the full score from raw events on every request.
Instead, you can process new events incrementally with track() and persist the returned state.
let previousState = null;
for (const event of events) {
const result = scorer.track({
userId: "workspace_acme",
previousState,
event,
now: event.timestamp,
});
previousState = result.state;
await db.churnStates.upsert({
where: { userId: "workspace_acme" },
update: { state: result.state },
create: {
userId: "workspace_acme",
state: result.state,
},
});
}After processing all events, you can refresh the score at the same now used by scoreUser():
const result = scorer.refreshScore({
userId: "workspace_acme",
previousState,
now: "2026-05-22T12:00:00.000Z",
});The public output matches the scoreUser() output.
{
"matchesScoreUserPublicOutput": true
}That means both paths produce the same public score:
{
"riskScore": 90.26735505328286,
"riskLevel": "critical",
"confidence": 0.9602525677891275,
"freshness": "aging"
}Use this pattern in production:
- store raw events as your source of truth
- store the latest Tiaude state for fast scoring
- use
track()when a new event arrives - use
scoreUser()when you need to rebuild from raw events - use
refreshScore()when no new event arrived, but time has passed
What happens during the event stream
The risk does not jump to critical immediately.
Earlier in the month, the workspace still has product usage, so the score remains low.
After the billing failure, the score becomes high:
{
"event": {
"name": "billing.payment_failed",
"timestamp": "2026-05-18T08:05:00.000Z"
},
"output": {
"riskScore": 76.54508492635577,
"riskLevel": "high",
"confidence": 0.9163138199826525,
"freshness": "fresh"
}
}After the cancellation click, the score becomes critical:
{
"event": {
"name": "subscription.cancel_clicked",
"timestamp": "2026-05-20T17:40:00.000Z"
},
"output": {
"riskScore": 100,
"riskLevel": "critical",
"confidence": 0.9602525677891275,
"freshness": "fresh"
}
}The raw score at that moment is above 100, so the public riskScore is capped at 100.
Two days later, when the score is refreshed at 2026-05-22T12:00:00.000Z, recent event impacts have decayed and the score is:
{
"riskScore": 90.26735505328286,
"riskLevel": "critical",
"confidence": 0.9602525677891275,
"freshness": "aging"
}Refreshing the score later
A score can change even when no new event arrives.
For example, refresh the score one week later:
const refreshed = scorer.refreshScore({
userId: "workspace_acme",
previousState: result.state,
now: "2026-05-29T12:00:00.000Z",
});The refreshed output is:
{
"riskScore": 72.52220963967895,
"riskLevel": "high",
"confidence": 0.9602525677891275,
"freshness": "stale",
"reasons": [
{
"signalId": "inactive",
"impact": 22.54627277135823,
"message": "No recent app activity"
},
{
"signalId": "low_usage",
"impact": 18.505494363785914,
"message": "Low recent feature usage"
},
{
"signalId": "cancel_intent",
"impact": 10.385546570146557,
"message": "Recent cancellation intent"
},
{
"signalId": "billing_failed",
"impact": 3.0332476252524785,
"message": "Recent billing failure"
},
{
"signalId": "strong_usage",
"impact": -1.9483516908642269,
"message": "Strong recent product usage"
}
],
"recommendedActions": [
"Send a re-engagement email with a direct link to the dashboard",
"Suggest one high-value workflow the workspace has not used recently",
"Trigger a human retention follow-up",
"Ask an admin to update the payment method"
]
}The risk is still high, but it is no longer critical.
Why?
- The cancellation signal decayed from
+27.41to+10.39. - The billing failure signal decayed from
+15.29to+3.03. - Inactivity and low usage became the main explanations.
- The score became
stalebecause no relevant event happened recently.
What to store in production
A typical production setup stores two things.
First, store raw events:
await db.events.create({
data: {
userId: "workspace_acme",
name: event.name,
timestamp: event.timestamp,
},
});Then store the latest Tiaude state:
const result = scorer.track({
userId: "workspace_acme",
previousState,
event,
now: event.timestamp,
});
await db.churnStates.upsert({
where: { userId: "workspace_acme" },
update: {
state: result.state,
riskScore: result.riskScore,
riskLevel: result.riskLevel,
confidence: result.confidence,
freshness: result.freshness,
computedAt: result.state.computedAt,
},
create: {
userId: "workspace_acme",
state: result.state,
riskScore: result.riskScore,
riskLevel: result.riskLevel,
confidence: result.confidence,
freshness: result.freshness,
computedAt: result.state.computedAt,
},
});The raw events let you rebuild with scoreUser().
The persisted state lets you score quickly with track() and refreshScore().
Summary
This example shows the full lifecycle:
scoreUser()rebuilds the score from raw events.track()updates the score incrementally when new events arrive.refreshScore()updates the score when time passes without new events.reasonsexplain why the score was produced.recommendedActionsturn those reasons into operational next steps.
In this scenario, the workspace is critical because recent cancellation intent, inactivity, billing failure, and low usage all point in the same direction.