ChurnResult
scoreUser(), track(), and refreshScore() all return a ChurnResult.
type ChurnResult = {
userId?: string;
riskScore: number;
riskLevel: "low" | "medium" | "high" | "critical";
rawScore: number;
confidence: number;
freshness: "fresh" | "aging" | "stale";
reasons: {
signalId: string;
impact: number;
message: string;
}[];
recommendedActions: string[];
state: ChurnScoreState;
};The important part: do not read riskScore alone.
Always read it with:
confidence: how much evidence the score is based onfreshness: how recent the evidence isreasons: why the score was produced
Example
{
"riskScore": 90.27,
"riskLevel": "critical",
"rawScore": 90.27,
"confidence": 0.96,
"freshness": "aging",
"reasons": [
{
"signalId": "cancel_intent",
"impact": 27.41,
"message": "Recent cancellation intent"
},
{
"signalId": "inactive",
"impact": 19.46,
"message": "No recent app activity"
},
{
"signalId": "billing_failed",
"impact": 15.29,
"message": "Recent billing failure"
}
],
"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"
],
"state": {
"computedAt": "2026-05-22T12:00:00.000Z",
"lastEventAt": "2026-05-20T17:40:00.000Z",
"lastRelevantEventAt": "2026-05-20T17:40:00.000Z",
"eventCount": 9,
"relevantEventCount": 9
}
}riskScore and riskLevel
riskScore is the churn risk score, normalized between 0 and 100.
{
"riskScore": 90.27,
"riskLevel": "critical"
}riskLevel is derived from your configured thresholds.
For example, with this config:
levels: {
medium: 30,
high: 60,
critical: 80,
}The levels are:
| Score | Level |
|---|---|
< 30 | low |
>= 30 | medium |
>= 60 | high |
>= 80 | critical |
Use riskScore to rank users.
Use riskLevel to trigger product or retention logic.
confidence
confidence tells you whether Tiaude had enough relevant events to produce a meaningful score.
It goes from 0 to 1.
{
"confidence": 0.96
}A low-confidence score is not wrong. It just means there is not enough evidence yet.
This is common for new users or users with no events.
{
"riskScore": 20,
"riskLevel": "low",
"confidence": 0,
"freshness": "stale",
"reasons": [], // empty or sometimes some random reasons (confidence 0)
"recommendedActions": [] // empty or sometimes some random actions (confidence 0)
}Do not interpret this as “the user is safe”.
Interpret it as:
Not enough data yet.A typical product rule:
if (result.confidence < 0.3) {
return "Not enough data";
}freshness
freshness tells you how recent the latest relevant event is.
type Freshness = "fresh" | "aging" | "stale";It is based on your config:
freshness: {
freshAfterHours: 24,
staleAfterHours: 72,
}Interpretation:
| Value | Meaning |
|---|---|
fresh | Recent evidence |
aging | Still useful, but not very recent |
stale | Old evidence; treat carefully |
A stale score is not automatically low risk.
{
"riskLevel": "critical",
"freshness": "stale"
}This means the user looked critical based on old evidence.
reasons
reasons explain why the score was produced.
[
{
"signalId": "cancel_intent",
"impact": 27.41,
"message": "Recent cancellation intent"
},
{
"signalId": "strong_usage",
"impact": -3.9,
"message": "Strong recent product usage"
}
]Positive impact increases churn risk.
Negative impact decreases churn risk.
Use reasons to explain the score in your UI or logs.
recommendedActions
recommendedActions are the actions attached to the active signals.
[
"Trigger a human retention follow-up",
"Ask an admin to update the payment method"
]They come from your signal config:
{
id: "billing_failed",
type: "occurrence",
event: "billing.payment_failed",
action: "Ask an admin to update the payment method",
}Use them to decide what to do next.
state
state is the internal scoring state returned by Tiaude.
type ChurnScoreState = {
version: number;
stateCompatibilityHash: string;
userId?: string;
computedAt: string;
lastEventAt?: string | null;
lastRelevantEventAt?: string | null;
rawScore: number;
riskScore: number;
riskLevel: "low" | "medium" | "high" | "critical";
confidence: number;
freshness: "fresh" | "aging" | "stale";
eventCount: number;
relevantEventCount: number;
signals: Record<string, SignalState>; // contains residues
};Persist state if you want to use track() or refreshScore() efficiently.
const result = scorer.track({
userId,
previousState,
event,
now,
});
await saveState(userId, result.state);Then pass it back later:
const result = scorer.refreshScore({
userId,
previousState: savedState,
now,
});It exists so Tiaude can continue scoring without rebuilding from all raw events every time.
Counts
state.eventCount is the number of events processed.
state.relevantEventCount is the number of events that matched your scoring config.
{
"eventCount": 9,
"relevantEventCount": 9
}If eventCount is high but relevantEventCount is low, your app may be sending events that your config does not use.
Safe interpretation
A simple production rule:
if (result.confidence < 0.3) {
return "Not enough data";
}
if (result.freshness === "stale") {
return `${result.riskLevel} risk, but based on old evidence`;
}
return `${result.riskLevel} risk`;Summary
Use:
| Field | Purpose |
|---|---|
riskScore | Rank users by churn risk |
riskLevel | Trigger business logic |
confidence | Know whether there is enough evidence |
freshness | Know whether the evidence is recent |
reasons | Explain the score |
recommendedActions | Decide what to do next |
state | Continue scoring efficiently |
The safest rule is:
Never act on riskScore alone.
Always check confidence, freshness, and reasons.