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 riskStarting ranges:
baseRisk: 10 to 30
moderate risk signal: 10 to 25
strong risk signal: 30 to 50
strong retention signal: -10 to -30Avoid 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 30Short 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 thresholdEvent naming
Keep event names stable and explicit.
Good examples:
app.opened
feature.used
billing.payment_failed
workspace.invited_member
project.createdAvoid 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.