Skip to main content

Tutorial: Churn Prevention

This tutorial walks through building a churn-prevention flow from a blank tenant. By the end you’ll have a working flow that scores customers by churn risk, qualifies the right retention offer, suppresses outreach for already-contacted customers, and improves with every recorded outcome. Business scenario: a telecom or SaaS provider wants to reduce monthly churn. The marketing team has three retention offers — a free month, a service upgrade, and a personal call. Each offer has different eligibility, different cost, and different effectiveness depending on the customer’s risk profile and tenure. The system has 24 hours to decide who gets which offer, and the business rule says no customer may receive more than one retention contact per week. What you’ll build:
  • A Customer schema with the fields the flow needs to score
  • 3 retention offers, each with its own qualification rules and business impact
  • A decision flow that combines propensity scoring, qualification gates, and a weekly contact cap
  • An end-to-end test through /recommend and /respond
  • A view into how the model learns from recorded outcomes
Prerequisites: A running KaireonAI instance (Playground, self-hosted, or Cloud), an admin API key, and curl + jq (used here to capture IDs between steps). Time: 25–30 minutes.

0. Set up your shell

Every request is tenant-scoped and authenticated with your API key. Export these once; the examples reuse them.
export BASE="https://your-instance.kaireonai.com"
export API_KEY="sk_live_..."          # an admin API key
export TENANT_ID="<your-tenant-id>"

# Every call carries these three headers.
AUTH=(-H "X-API-Key: $API_KEY" -H "X-Tenant-Id: $TENANT_ID" -H "Content-Type: application/json")

1. Define the customer schema

The flow needs four fields to make a churn-aware decision: tenure (months), monthly spend, support tickets in the last 90 days, and current plan tier. It also needs a customer_id column — the key the Enrich stage joins on at decision time.
SCHEMA_ID=$(curl -s -X POST "$BASE/api/v1/schemas" "${AUTH[@]}" \
  -d '{
    "name": "customer",
    "displayName": "Customer",
    "fields": [
      { "name": "customer_id",     "dataType": "varchar", "isUnique": true },
      { "name": "tenureMonths",    "dataType": "integer" },
      { "name": "monthlySpend",    "dataType": "decimal" },
      { "name": "ticketsLast90d",  "dataType": "integer" },
      { "name": "planTier",        "dataType": "varchar" }
    ]
  }' | jq -r '.id')
echo "Schema id: $SCHEMA_ID"
A schema creates a real PostgreSQL table (ds_customer) you can populate via the Data → Schemas UI, a /api/v1/customers bulk insert, or a pipeline from any of the 80+ connectors. The id returned here is the schemaId the decision flow’s Enrich node references in step 5.

2. Define the three retention offers

Each offer represents a different retention play. priority encodes business preference when scores tie; businessValue (0–100) feeds the Impact term in ranking; revenueValue records the dollar figure the platform credits on a positive outcome. Offers are created draft by default, so set status: "active" — the Inventory stage only loads active offers.
OFFER_FREE_MONTH=$(curl -s -X POST "$BASE/api/v1/offers" "${AUTH[@]}" \
  -d '{
    "name": "Free month",
    "status": "active",
    "category": "retention",
    "priority": 70,
    "businessValue": 30,
    "revenueValue": 30,
    "margin": 80
  }' | jq -r '.id')

OFFER_UPGRADE=$(curl -s -X POST "$BASE/api/v1/offers" "${AUTH[@]}" \
  -d '{
    "name": "Service upgrade (one tier up)",
    "status": "active",
    "category": "retention",
    "priority": 85,
    "businessValue": 70,
    "revenueValue": 110,
    "margin": 60
  }' | jq -r '.id')

OFFER_CALL=$(curl -s -X POST "$BASE/api/v1/offers" "${AUTH[@]}" \
  -d '{
    "name": "Personal retention call",
    "status": "active",
    "category": "retention",
    "priority": 95,
    "businessValue": 90,
    "revenueValue": 200,
    "margin": 20
  }' | jq -r '.id')
Offers hold no eligibility logic themselves — that lives in qualification rules, which you attach in the next step. This separation lets one rule be reused across offers and keeps an offer’s cost/impact independent of its gating.

3. Attach qualification rules

A qualification rule is a separate entity scoped to what it gates. Here each rule is an attribute_condition scoped to a single offer, at the eligibility stage (a hard pass/fail filter). The attribute names the enriched customer field — the Enrich stage loads schema columns under the customer. prefix, so the flow sees customer.tenureMonths, customer.monthlySpend, and so on.
# Free month — only for customers with recent support friction
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" \
  -d "{
    \"name\": \"Free month — 2+ recent tickets\",
    \"ruleType\": \"attribute_condition\",
    \"scope\": \"offer\", \"scopeId\": \"$OFFER_FREE_MONTH\",
    \"stage\": \"eligibility\",
    \"config\": { \"attribute\": \"customer.ticketsLast90d\", \"operator\": \"gte\", \"value\": 2 }
  }"

# Service upgrade — needs tenure AND spend (two rules on the same offer, AND-combined)
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" \
  -d "{
    \"name\": \"Upgrade — tenure >= 6mo\",
    \"ruleType\": \"attribute_condition\",
    \"scope\": \"offer\", \"scopeId\": \"$OFFER_UPGRADE\",
    \"stage\": \"eligibility\",
    \"config\": { \"attribute\": \"customer.tenureMonths\", \"operator\": \"gte\", \"value\": 6 }
  }"
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" \
  -d "{
    \"name\": \"Upgrade — spend >= 40\",
    \"ruleType\": \"attribute_condition\",
    \"scope\": \"offer\", \"scopeId\": \"$OFFER_UPGRADE\",
    \"stage\": \"eligibility\",
    \"config\": { \"attribute\": \"customer.monthlySpend\", \"operator\": \"gte\", \"value\": 40 }
  }"

# Personal call — high spend AND long tenure
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" \
  -d "{
    \"name\": \"Call — spend >= 100\",
    \"ruleType\": \"attribute_condition\",
    \"scope\": \"offer\", \"scopeId\": \"$OFFER_CALL\",
    \"stage\": \"eligibility\",
    \"config\": { \"attribute\": \"customer.monthlySpend\", \"operator\": \"gte\", \"value\": 100 }
  }"
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" \
  -d "{
    \"name\": \"Call — tenure >= 12mo\",
    \"ruleType\": \"attribute_condition\",
    \"scope\": \"offer\", \"scopeId\": \"$OFFER_CALL\",
    \"stage\": \"eligibility\",
    \"config\": { \"attribute\": \"customer.tenureMonths\", \"operator\": \"gte\", \"value\": 12 }
  }"
The rules are the gate — a low-tenure low-spend customer cannot receive the personal call, no matter how high their churn risk. When a scoped attribute isn’t enriched for a given customer, the rule is skipped (fail-open) rather than disqualifying every offer. Valid attribute_condition operators: eq, neq, lt, lte, gt, gte, in, contains, exists, not_exists.

4. Add the weekly contact cap

The most common churn-prevention failure mode is over-messaging. A customer_total_cap policy caps all contacts to a customer within a period, so a free-month offer can’t be followed by an upgrade nudge the next day. Contact policies use a scope enum plus a per-ruleType config — this one is global (every candidate counts toward the cap).
curl -s -X POST "$BASE/api/v1/contact-policies" "${AUTH[@]}" \
  -d '{
    "name": "One contact per customer per week",
    "ruleType": "customer_total_cap",
    "scope": "global",
    "config": { "maxTotal": 1, "periodType": "weekly" }
  }'
periodType accepts daily, weekly, monthly, or alltime. Because this tutorial’s tenant runs only the retention program, a global cap is exactly “at most one retention contact per week.” To cap a specific category instead, create a Category entity, link offers to it via categoryId, and scope the policy with scope: "category" + scopeId: "<categoryId>".

5. Wire the decision flow

A decision flow is a versioned pipeline of typed nodes across three phases: Narrow (phase 1: inventory, enrich, qualify, contact policy), Score & Rank (phase 2), and Output (phase 3). Every node carries a type, phase, position, and a config validated against that node type. The flow must start with inventory, end with response, and contain exactly one score node.
FLOW_ID=$(curl -s -X POST "$BASE/api/v1/decision-flows" "${AUTH[@]}" \
  -d "{
    \"key\": \"churn-prevention\",
    \"name\": \"Churn Prevention\",
    \"draftConfig\": {
      \"version\": 2,
      \"nodes\": [
        { \"id\": \"inv\",   \"type\": \"inventory\",      \"phase\": 1, \"position\": 0,
          \"config\": { \"scope\": \"all\", \"includeStatuses\": [\"active\"] } },
        { \"id\": \"enr\",   \"type\": \"enrich\",         \"phase\": 1, \"position\": 1,
          \"config\": { \"sources\": [ { \"schemaId\": \"$SCHEMA_ID\", \"lookupKey\": \"customer_id\",
            \"fields\": [\"tenureMonths\", \"monthlySpend\", \"ticketsLast90d\", \"planTier\"] } ] } },
        { \"id\": \"qual\",  \"type\": \"qualify\",        \"phase\": 1, \"position\": 2,
          \"config\": { \"mode\": \"all\" } },
        { \"id\": \"cp\",    \"type\": \"contact_policy\", \"phase\": 1, \"position\": 3,
          \"config\": { \"mode\": \"all\" } },
        { \"id\": \"score\", \"type\": \"score\",          \"phase\": 2, \"position\": 4,
          \"config\": { \"method\": \"propensity\" } },
        { \"id\": \"rank\",  \"type\": \"rank\",           \"phase\": 2, \"position\": 5,
          \"config\": { \"method\": \"topN\", \"maxCandidates\": 3 } },
        { \"id\": \"out\",   \"type\": \"response\",       \"phase\": 3, \"position\": 6,
          \"config\": { \"responseFormat\": \"standard\" } }
      ]
    }
  }" | jq -r '.id')
The stages run in order: Inventory loads active offers → Enrich pulls the four schema fields → Qualify applies each offer’s eligibility rules → Contact Policy drops candidates that would break the weekly cap → Score ranks by predicted response likelihood → Rank returns the top three. qualify and contact_policy in mode: "all" evaluate every active rule/policy for the tenant. Publish it to make it live for /recommend:
curl -s -X POST "$BASE/api/v1/decision-flows/publish" "${AUTH[@]}" \
  -d "{ \"id\": \"$FLOW_ID\" }"

6. Run the recommendation

For a customer with long tenure, high spend, and recent support friction — the kind worth saving — call /recommend with the flow key. (This assumes you’ve populated ds_customer with a row where customer_id = 'cust_7'.)
curl -s -X POST "$BASE/api/v1/recommend" "${AUTH[@]}" \
  -d '{
    "customerId": "cust_7",
    "channel": "email",
    "decisionFlowKey": "churn-prevention"
  }'
A representative response:
{
  "interactionId": "b3f1c2a0-8e4d-4a1b-9c77-0d2e5f6a7b81",
  "recommendationId": "b3f1c2a0-8e4d-4a1b-9c77-0d2e5f6a7b81",
  "customerId": "cust_7",
  "decisionFlowKey": "churn-prevention",
  "decisionFlowVersion": 1,
  "controlGroup": false,
  "direction": "inbound",
  "timestamp": "2026-01-15T10:22:47.031Z",
  "channel": "email",
  "placement": "all",
  "count": 3,
  "decisions": [
    { "offerId": "…", "offerName": "Personal retention call", "score": 0.81, "rank": 1,
      "priority": 95, "creativeId": null, "creativeName": null,
      "channelName": null, "categoryName": null, "personalization": {} },
    { "offerId": "…", "offerName": "Service upgrade (one tier up)", "score": 0.64, "rank": 2, "priority": 85 },
    { "offerId": "…", "offerName": "Free month", "score": 0.42, "rank": 3, "priority": 70 }
  ],
  "meta": {
    "totalCandidates": 3,
    "afterQualification": 3,
    "afterSuppression": 3,
    "afterContactPolicy": 3,
    "degradedScoring": false
  }
}
Each decision reports offerName, score, rank, and priority; meta shows how many candidates survived each stage. To see why an offer won — the per-rule qualification reasons, the contact-policy status, and the propensity / relevance / impact / emphasis breakdown — add ?explain=true to the request. That adds an explanation object to each decision and a top-level rejectedOffers array listing anything the qualify or contact-policy stages dropped.

7. Record the outcome

When the customer accepts (or doesn’t), record the result so the model improves. Identify the offer with recommendationId + rank from the recommend response, name a registered outcome (accept, convert, dismiss, … — the standard set is provisioned the first time you GET /api/v1/outcome-types), and pass an idempotencyKey (required, to prevent double-counting).
curl -s -X POST "$BASE/api/v1/respond" "${AUTH[@]}" \
  -d '{
    "customerId": "cust_7",
    "recommendationId": "b3f1c2a0-8e4d-4a1b-9c77-0d2e5f6a7b81",
    "rank": 1,
    "outcome": "accept",
    "idempotencyKey": "cust_7-b3f1c2a0-r1-accept",
    "conversionValue": 200,
    "context": { "channel": "email" }
  }'
Response:
{
  "interactionId": "e7a9…",
  "recommendationId": "b3f1c2a0-8e4d-4a1b-9c77-0d2e5f6a7b81",
  "customerId": "cust_7",
  "outcome": "accept",
  "classification": "positive",
  "rank": 1,
  "offerName": "Personal retention call",
  "channelName": "email",
  "categoryName": null,
  "status": "recorded",
  "timestamp": "2026-01-15T10:31:12.884Z"
}
/respond records the interaction, rolls it into the customer’s contact-frequency summary (so the weekly cap sees it), and — when the tenant has an active adaptive model — updates that model’s scoped posterior for the offer.

8. Watch the model learn

Learning requires at least one active adaptive model (bayesian, thompson_bandit, epsilon_greedy, or online_learner). Create one via POST /api/v1/algorithm-models with status: "active", then point the Score node at it with "config": { "method": "propensity", "defaultModel": "<modelKey>" }. With a model in place, each positive/negative /respond updates the offer’s Beta(α, β) posterior. The Model Health dashboard shows the Wilson confidence interval per scoped adaptation: it starts wide, and the maturity ramp holds an immature offer’s exposure below 1.0 until it has enough evidence. After roughly 30–50 outcomes the interval tightens below the tenant’s maturity-width threshold, the offer matures, and exposure goes to 1.0. For the math (Wilson 1927 closed form, the width threshold, the cold-start floor decay), see Maturity Ramp (BCB-MR).

9. What’s next

  • Add an uplift signal. Give the Score node a formula with an upliftWeight so ranking optimizes incremental response — see Cross-Sell for the full PRIE-U setup.
  • Tighten the cap for disengaged customers. Add an engagementMultiplier block to the customer_total_cap config to shrink the cap for low-engagement customers and widen it for engaged ones.
  • Quiet hours. Add a time_window contact policy so retention messages only land during the customer’s active window (see Winback).
  • Prove incremental revenue. Configure a flow-level holdout in draftConfig.flowConfig.experiment and use the platform’s z-test uplift calculation to measure the program against a no-contact control.

See Industry Templates for ready-made starter kits, Starbucks NBA pipeline for a full retail walkthrough, or the API Reference for endpoint-by-endpoint details.