Skip to Content
DocsHow to useConfiguration

Configuration

Your config defines what churn risk means in your product.

Start small. Pick signals that map to product behavior you already understand.

A good first config usually has:

  • one inactivity signal;
  • one usage signal;
  • one high-risk event signal;
  • optionally one negative-weight engagement signal.

Config shape

import type { ChurnScorerConfig } from "tiaude"; export const config: ChurnScorerConfig = { 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: 7, reason: "No recent activity", action: "Send a re-engagement message", }, ], };

Defaults

You can omit:

levels: { medium: 30, high: 60, critical: 80, } confidence: { targetRelevantEvents: 10, } freshness: { freshAfterHours: 24, staleAfterHours: 72, }

For frequency signals, mode defaults to:

"linear"

Signal shape

type ChurnSignal = { id: string; type: "occurrence" | "absence" | "frequency_above" | "frequency_below"; event: string; weight: number; halfLifeDays: number; threshold?: number; mode?: "linear" | "binary"; reason: string; action?: string; };

Signal types

occurrence

Use when a recent event should affect risk and then fade.

{ id: "billing_failed", type: "occurrence", event: "billing.payment_failed", weight: 40, halfLifeDays: 3, reason: "Recent billing failure", action: "Ask the user to update their payment method", }

Good for:

  • payment failures;
  • support escalations;
  • cancellation intent;
  • failed imports;
  • critical product errors.

absence

Use when missing activity is the risk signal.

{ id: "inactive", type: "absence", event: "app.opened", weight: 30, halfLifeDays: 7, reason: "No recent activity", action: "Send a re-engagement message", }

Good for:

  • no app open;
  • no activation event;
  • no core workflow completed;
  • no team activity.

frequency_above

Use when repeated recent usage should affect risk.

{ id: "strong_usage", type: "frequency_above", event: "feature.used", weight: -20, halfLifeDays: 7, threshold: 8, mode: "linear", reason: "Strong recent product usage", }

With a negative weight, this becomes a retention signal.

frequency_below

Use when insufficient recent usage should increase risk.

{ id: "low_usage", type: "frequency_below", event: "feature.used", weight: 25, halfLifeDays: 7, threshold: 3, mode: "linear", reason: "Low recent product usage", action: "Suggest a relevant feature or workflow", }

Weights

Weights decide how much a signal can move the score.

positive weight -> increases churn risk negative weight -> reduces churn risk

Starting ranges:

baseRisk: 10 to 30 moderate risk signal: 10 to 25 strong risk signal: 30 to 50 strong retention signal: -10 to -30

Avoid starting with too many high-weight signals. It is easier to tune a simple model than a noisy one.

Half-life

halfLifeDays controls how quickly a signal fades.

Starting ranges:

payment failure: 1 to 3 activation/onboarding: 3 to 7 general usage: 7 to 14 inactivity: 7 to 30

Short half-life means the signal reacts quickly.

Long half-life means the signal remembers longer-term behavior.

Threshold and mode

threshold is required for:

  • frequency_above;
  • frequency_below.

mode is valid only for frequency signals.

Use linear when you want gradual scoring.

Use binary when you want a hard yes/no rule.

linear -> smooth intensity binary -> strict threshold

Event naming

Keep event names stable and explicit.

Good examples:

app.opened feature.used billing.payment_failed workspace.invited_member project.created

Avoid renaming events casually.

Changing event names changes signal matching and may require a rebuild with scoreUser().

Timestamps

Use Date objects or UTC ISO strings.

Recommended:

"2026-05-20T10:00:00.000Z"

Accepted:

"2026-05-20T10:00:00Z" "2026-05-20T10:00:00.000Z"

Rejected:

"05/20/2026" "2026-05-20" "2026-05-20T10:00:00+02:00"

For stored events, standardize with:

new Date().toISOString()

A good first B2B SaaS config

import { createChurnScorer } from "tiaude"; export const scorer = createChurnScorer({ baseRisk: 20, signals: [ { id: "inactive", type: "absence", event: "app.opened", weight: 30, halfLifeDays: 7, reason: "No recent activity", action: "Send a re-engagement message", }, { id: "low_usage", type: "frequency_below", event: "feature.used", weight: 25, halfLifeDays: 7, threshold: 3, reason: "Low recent product usage", action: "Suggest a relevant feature or workflow", }, { id: "billing_failed", type: "occurrence", event: "billing.payment_failed", weight: 40, halfLifeDays: 3, reason: "Recent billing failure", action: "Ask the user to update their payment method", }, { id: "strong_usage", type: "frequency_above", event: "feature.used", weight: -20, halfLifeDays: 7, threshold: 8, reason: "Strong recent product usage", }, ], });

Config changes

These changes usually require a full recompute with scoreUser():

  • adding or removing signals;
  • changing signal type;
  • changing event name;
  • changing weights;
  • changing thresholds;
  • changing half-lives;
  • changing score levels.

Changing only reason or action does not break state compatibility.