Skip to Content
DocsHow to useComplete example

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.

  • inactive increases risk when the user has not opened the app recently.
  • low_usage increases risk when recent feature usage is below the threshold.
  • billing_failed increases risk when a payment failed recently.
  • cancel_intent increases risk when the user clicked a cancellation flow.
  • strong_usage decreases 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:

DateEventMeaning
2026-05-01app.openedLast app open, 21 days before scoring
2026-05-02 → 2026-05-14feature.usedThe workspace used the product earlier in the month
2026-05-18billing.payment_failedA payment failed around 4 days before scoring
2026-05-20subscription.cancel_clickedSomeone 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.

SignalImpactExplanation
cancel_intent+27.41The user clicked the cancellation flow recently
inactive+19.46The last app open was 21 days before scoring
billing_failed+15.29A payment failed recently
low_usage+12.01Recent feature usage has decayed below the configured threshold
strong_usage-3.90Past 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.27

Because 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.

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.41 to +10.39.
  • The billing failure signal decayed from +15.29 to +3.03.
  • Inactivity and low usage became the main explanations.
  • The score became stale because 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:

  1. scoreUser() rebuilds the score from raw events.
  2. track() updates the score incrementally when new events arrive.
  3. refreshScore() updates the score when time passes without new events.
  4. reasons explain why the score was produced.
  5. recommendedActions turn 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.