Skip to main content

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

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.
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.
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.
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.
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.
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

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.
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.)

8. Recommend for a lapsed apparel customer

curl -s -X POST "$BASE/api/v1/recommend" "${AUTH[@]}" \
  -d '{ "customerId": "cust_lapsed_1", "channel": "email", "decisionFlowKey": "winback" }'
Sample response:
{
  "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:
{
  "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 compares revenue per customer between the treated 85% and the 15% holdout and confidence-bounds the difference, so you can tell finance “winback drives Xincrementalrevenue,95X 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, 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 for the existing-customer growth scenario, Churn Prevention for the retention scenario, or Computed Values for the formula-engine reference.