> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kaireonai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Tutorial: Churn Prevention

> Build a complete churn-prevention next-best-action flow from scratch — score at-risk customers, qualify retention offers, respect contact frequency, and watch the model learn.

# 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`](https://jqlang.github.io/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.

```bash theme={null}
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.

```bash theme={null}
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](/data/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.

```bash theme={null}
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.

```bash theme={null}
# 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).

```bash theme={null}
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.

```bash theme={null}
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`:

```bash theme={null}
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'`.)

```bash theme={null}
curl -s -X POST "$BASE/api/v1/recommend" "${AUTH[@]}" \
  -d '{
    "customerId": "cust_7",
    "channel": "email",
    "decisionFlowKey": "churn-prevention"
  }'
```

A representative response:

```json theme={null}
{
  "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).

```bash theme={null}
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:

```json theme={null}
{
  "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](/ai-ml/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)](/ai-ml/maturity-ramp).

***

## 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](/tutorials/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](/tutorials/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](/tutorials/industry-templates) for ready-made starter kits, [Starbucks NBA pipeline](/tutorials/starbucks-tutorial) for a full retail walkthrough, or the [API Reference](/api-reference/recommend) for endpoint-by-endpoint details.*
