Skip to Content
DocsHow to useChurn Result

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 on
  • freshness: how recent the evidence is
  • reasons: 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:

ScoreLevel
< 30low
>= 30medium
>= 60high
>= 80critical

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:

ValueMeaning
freshRecent evidence
agingStill useful, but not very recent
staleOld 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:

FieldPurpose
riskScoreRank users by churn risk
riskLevelTrigger business logic
confidenceKnow whether there is enough evidence
freshnessKnow whether the evidence is recent
reasonsExplain the score
recommendedActionsDecide what to do next
stateContinue scoring efficiently

The safest rule is:

Never act on riskScore alone. Always check confidence, freshness, and reasons.