> ## 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: Winback

> Build a winback flow that re-engages lapsed customers — recency-aware eligibility, quiet-hour respect, multi-touch sequencing, and incrementality measurement.

# Tutorial: Winback

Winback campaigns target customers who've gone quiet — they had a relationship, they stopped, and the question is whether a well-timed offer can revive them. The flow has to know when each customer went silent, what they did before, and what offer might bring them back without over-messaging the lapsed-and-uninterested.

**Business scenario:** an e-commerce platform's data shows a long tail of customers who haven't purchased in 90+ days. Instead of a blanket discount email, the platform routes lapsed-but-recoverable customers to a *personalized* offer matched to their prior purchase category — and routes the truly lost customers to no message at all.

**What you'll build:**

* A Customer schema with recency and lifetime-value signals
* A category with a computed personalization field
* 3 winback offers (Apparel, Electronics, Home) with recency-aware eligibility
* Quiet-hour protection via a time-window contact policy
* A 3-touch cap and a flow-level holdout for measuring incremental winback

**Prerequisites:** A running KaireonAI instance, an admin API key, and `curl` + `jq`.

**Time:** 25–30 minutes.

***

## 0. Set up your shell

```bash theme={null}
export BASE="https://your-instance.kaireonai.com"
export API_KEY="sk_live_..."
export TENANT_ID="<your-tenant-id>"
AUTH=(-H "X-API-Key: $API_KEY" -H "X-Tenant-Id: $TENANT_ID" -H "Content-Type: application/json")
```

***

## 1. Define the schema with lapse signals

The gate needs a `daysSinceLastPurchase` value it can compare against. The formula engine can't compute a date difference (it has no epoch/date-diff function), so `daysSinceLastPurchase` is a real column that your data pipeline refreshes on a schedule — one `add_field` transform in a daily pipeline keeps it current. Store it alongside the lifetime-value signals the eligibility rules read.

```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": "daysSinceLastPurchase",  "dataType": "integer" },
      { "name": "lifetimePurchases",      "dataType": "integer" },
      { "name": "lifetimeRevenue",        "dataType": "decimal" },
      { "name": "primaryCategory",        "dataType": "varchar" },
      { "name": "preferredChannel",       "dataType": "varchar" },
      { "name": "consentEmail",           "dataType": "boolean" },
      { "name": "consentSMS",             "dataType": "boolean" }
    ]
  }' | jq -r '.id')
echo "Schema id: $SCHEMA_ID"
```

The `consent*` fields are non-negotiable. Winback hits dormant customers; the platform's consent stage suppresses candidates on channels whose consent was revoked.

***

## 2. Create the category with a computed personalization field

Computed custom fields run in the **Compute** stage, near the end of the flow, and their results are merged into each decision's `personalization` object. They are *output* values — a personalized number or string you pass to the creative — not gates. (Gating happens earlier, in the Qualify stage, against enriched schema columns.)

A computed field's formula uses the safe formula engine: arithmetic, comparison, ternary (`cond ? a : b`), and functions like `round`, `min`, `max`, `coalesce`, `if`, `concat`. There is **no** `today()`, no `&&`/`||`, and no date math — combine conditions with nested ternaries instead. `outputType` is `number` or `text` only.

```bash theme={null}
CATEGORY_ID=$(curl -s -X POST "$BASE/api/v1/categories" "${AUTH[@]}" \
  -d '{
    "name": "Winback",
    "customFields": [
      {
        "name": "loyaltyScore",
        "type": "computed",
        "formula": "round(customer.lifetimePurchases * 3 + customer.lifetimeRevenue / 50)",
        "outputType": "number"
      }
    ]
  }' | jq -r '.id')
echo "Category id: $CATEGORY_ID"
```

`loyaltyScore` is a lightweight index the creative can key off (e.g. show a richer incentive to high-loyalty lapsers). The formula reads enriched customer fields via the `customer.` prefix and produces a single number surfaced in the response.

***

## 3. Define winback offers per category

Each offer belongs to the `Winback` category (via `categoryId`, so the computed field runs for it) and targets one prior-purchase category. Offers are `active`; eligibility is attached in step 4.

```bash theme={null}
OFFER_APPAREL=$(curl -s -X POST "$BASE/api/v1/offers" "${AUTH[@]}" -d "{
  \"name\": \"Apparel — 20% back\", \"status\": \"active\",
  \"category\": \"winback\", \"categoryId\": \"$CATEGORY_ID\",
  \"priority\": 70, \"businessValue\": 30, \"revenueValue\": 30, \"margin\": 50 }" | jq -r '.id')

OFFER_ELEC=$(curl -s -X POST "$BASE/api/v1/offers" "${AUTH[@]}" -d "{
  \"name\": \"Electronics — free shipping + 10% off\", \"status\": \"active\",
  \"category\": \"winback\", \"categoryId\": \"$CATEGORY_ID\",
  \"priority\": 75, \"businessValue\": 60, \"revenueValue\": 60, \"margin\": 35 }" | jq -r '.id')

OFFER_HOME=$(curl -s -X POST "$BASE/api/v1/offers" "${AUTH[@]}" -d "{
  \"name\": \"Home — bundle 3, save 15%\", \"status\": \"active\",
  \"category\": \"winback\", \"categoryId\": \"$CATEGORY_ID\",
  \"priority\": 70, \"businessValue\": 40, \"revenueValue\": 40, \"margin\": 45 }" | jq -r '.id')
```

***

## 4. Attach recency-aware eligibility

Each offer's rules gate on enriched columns: the right prior category, a lapse window of 90–365 days, and a "was a real customer" test (`lifetimePurchases >= 3` **and** `lifetimeRevenue >= 50`, expressed as two AND-combined rules). The 365-day upper bound matters — a customer gone 18 months needs win-*back-from-cold*, a different program.

```bash theme={null}
attach_winback_rules () {   # $1 = offerId, $2 = primaryCategory
  curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
    \"name\": \"$2 — matches prior category\", \"ruleType\": \"attribute_condition\",
    \"scope\": \"offer\", \"scopeId\": \"$1\", \"stage\": \"eligibility\",
    \"config\": { \"attribute\": \"customer.primaryCategory\", \"operator\": \"eq\", \"value\": \"$2\" } }"
  curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
    \"name\": \"$2 — lapsed >= 90d\", \"ruleType\": \"attribute_condition\",
    \"scope\": \"offer\", \"scopeId\": \"$1\", \"stage\": \"eligibility\",
    \"config\": { \"attribute\": \"customer.daysSinceLastPurchase\", \"operator\": \"gte\", \"value\": 90 } }"
  curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
    \"name\": \"$2 — lapsed <= 365d\", \"ruleType\": \"attribute_condition\",
    \"scope\": \"offer\", \"scopeId\": \"$1\", \"stage\": \"eligibility\",
    \"config\": { \"attribute\": \"customer.daysSinceLastPurchase\", \"operator\": \"lte\", \"value\": 365 } }"
  curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
    \"name\": \"$2 — 3+ lifetime purchases\", \"ruleType\": \"attribute_condition\",
    \"scope\": \"offer\", \"scopeId\": \"$1\", \"stage\": \"eligibility\",
    \"config\": { \"attribute\": \"customer.lifetimePurchases\", \"operator\": \"gte\", \"value\": 3 } }"
  curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
    \"name\": \"$2 — lifetime revenue >= 50\", \"ruleType\": \"attribute_condition\",
    \"scope\": \"offer\", \"scopeId\": \"$1\", \"stage\": \"eligibility\",
    \"config\": { \"attribute\": \"customer.lifetimeRevenue\", \"operator\": \"gte\", \"value\": 50 } }"
}

attach_winback_rules "$OFFER_APPAREL" "apparel"
attach_winback_rules "$OFFER_ELEC"    "electronics"
attach_winback_rules "$OFFER_HOME"    "home"
```

The four value/recency rules encode "this customer was a real customer in a recoverable window, not a one-time tire-kicker." An offer surfaces only when all of its rules pass.

***

## 5. Quiet hours via a time-window contact policy

A winback email at 2am does worse than no email. Quiet hours are a **contact policy**, not a channel setting: a `time_window` rule blocks any candidate it's scoped to when the current time falls outside the allowed window. `startHour`/`endHour` are 0–23; `timezone` is an IANA zone the rule evaluates against.

```bash theme={null}
curl -s -X POST "$BASE/api/v1/contact-policies" "${AUTH[@]}" \
  -d '{
    "name": "Quiet hours 21:00-08:00",
    "ruleType": "time_window",
    "scope": "global",
    "config": { "startHour": 8, "endHour": 21, "timezone": "America/New_York" }
  }'
```

This allows delivery only between 08:00 and 21:00 in the configured zone. (The `time_window` rule uses a single zone per policy; for multi-region campaigns, create one policy per zone and scope it to the relevant channel or segment.) You can add `"daysOfWeek": ["Mon","Tue","Wed","Thu","Fri"]` to also skip weekends.

***

## 6. Cap the winback sequence at 3 touches

```bash theme={null}
curl -s -X POST "$BASE/api/v1/contact-policies" "${AUTH[@]}" \
  -d '{
    "name": "Winback 3-touch monthly cap",
    "ruleType": "customer_total_cap",
    "scope": "global",
    "config": { "maxTotal": 3, "periodType": "monthly" }
  }'
```

A 3-touch monthly cap lets the platform run a real sequence — initial → reminder → final-call — then stop. Beyond 3 the customer has signaled disinterest.

***

## 7. Wire the winback flow

The flow enriches the customer, qualifies against the recency rules, applies both contact policies, scores with an uplift-weighted formula, then runs the **Compute** node to attach `loyaltyScore` to each surviving decision. Node phases run 1 → 2 → 3 in order.

```bash theme={null}
FLOW_ID=$(curl -s -X POST "$BASE/api/v1/decision-flows" "${AUTH[@]}" \
  -d "{
    \"key\": \"winback\",
    \"name\": \"Winback\",
    \"draftConfig\": {
      \"version\": 2,
      \"flowConfig\": { \"experiment\": { \"enabled\": true, \"holdoutPercent\": 15, \"controlGroupAction\": \"no_offers\" } },
      \"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\": [\"daysSinceLastPurchase\", \"lifetimePurchases\", \"lifetimeRevenue\", \"primaryCategory\"] } ] } },
        { \"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\": \"formula\", \"formula\": {
            \"propensityWeight\": 0.4, \"relevanceWeight\": 0.2,
            \"impactWeight\": 0.3, \"emphasisWeight\": 0.1, \"upliftWeight\": 0.6 } } },
        { \"id\": \"rank\",    \"type\": \"rank\",           \"phase\": 2, \"position\": 5,
          \"config\": { \"method\": \"topN\", \"maxCandidates\": 3 } },
        { \"id\": \"compute\", \"type\": \"compute\",        \"phase\": 3, \"position\": 6,
          \"config\": { \"overrides\": [], \"extras\": [], \"transforms\": [] } },
        { \"id\": \"out\",     \"type\": \"response\",       \"phase\": 3, \"position\": 7,
          \"config\": { \"responseFormat\": \"standard\" } }
      ]
    }
  }" | jq -r '.id')

curl -s -X POST "$BASE/api/v1/decision-flows/publish" "${AUTH[@]}" -d "{ \"id\": \"$FLOW_ID\" }"
```

`upliftWeight: 0.6` is intentionally high. Winback is where the uplift signal earns its keep — half the lapsed customers would return anyway (sure things) and the other half won't return regardless (lost causes). The narrow band of *persuadable* customers is the ROI of the whole program. (The uplift term influences ranking once the 15% holdout has generated enough treated-vs-control outcomes — see [Uplift Modeling](/ai-ml/uplift-modeling).)

***

## 8. Recommend for a lapsed apparel customer

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

Sample response:

```json theme={null}
{
  "interactionId": "4c8e…",
  "recommendationId": "4c8e…",
  "customerId": "cust_lapsed_1",
  "decisionFlowKey": "winback",
  "decisionFlowVersion": 1,
  "channel": "email",
  "count": 1,
  "decisions": [
    {
      "offerId": "…",
      "offerName": "Apparel — 20% back",
      "score": 0.71,
      "rank": 1,
      "priority": 70,
      "categoryName": "Winback",
      "personalization": { "loyaltyScore": 27 }
    }
  ],
  "meta": { "totalCandidates": 3, "afterQualification": 1, "afterContactPolicy": 1, "degradedScoring": false }
}
```

The Compute stage attached `loyaltyScore` to the surviving decision. The electronics and home offers were filtered at qualification because this customer's `primaryCategory` is apparel.

Now try the same request when it's 2am in the policy's timezone. The `time_window` policy blocks every candidate, so `decisions` comes back empty. Add `?explain=true` to see *why* — the response then includes a `rejectedOffers` array with the policy's reason:

```json theme={null}
{
  "count": 0,
  "decisions": [],
  "rejectedOffers": [
    { "offerId": "…", "offerName": "Apparel — 20% back", "stage": "contact_policy",
      "reason": "Time window: current hour 2 outside allowed range 8-21" }
  ],
  "meta": { "totalCandidates": 3, "afterQualification": 1, "afterContactPolicy": 0, "degradedScoring": false }
}
```

The platform refused to deliver — not because the offer was wrong, but because the *timing* was. The calling system can retry at the next active window.

***

## 9. Measuring incremental winback

Winback measures poorly because returning customers would often have returned anyway. The flow's `flowConfig.experiment` (set in step 7) already withholds offers from 15% of traffic — a real holdout. After the program has run long enough, the [experiments z-test](/api-reference/experiments) compares revenue per customer between the treated 85% and the 15% holdout and confidence-bounds the difference, so you can tell finance "winback drives $X incremental revenue, 95% CI [$X-low, \$X-high]".

This is the only honest way to claim winback ROI.

***

## 10. What's next

* **Winback-from-cold flow.** Customers gone > 365 days need a different program (re-acquisition price, full re-onboarding). Create a sibling flow with a `daysSinceLastPurchase > 365` gate.
* **Multi-step sequence.** Chain initial email → SMS reminder → final-call via a [Journey](/decisioning/journeys), with the 3-touch cap ensuring each customer gets at most 3 touches.
* **Channel-preference learning.** Feed each customer's engaged channel back into the Score node's Relevance term so later winback runs prefer their surface.
* **Segment by value.** Split the flow on `lifetimeRevenue` thresholds so premium lapsers get an account-manager call, not a discount email.

***

*See [Cross-Sell](/tutorials/cross-sell) for the existing-customer growth scenario, [Churn Prevention](/tutorials/churn-prevention) for the retention scenario, or [Computed Values](/tutorials/computed-values) for the formula-engine reference.*
