Skip to main content

Tutorial: Cross-Sell

This tutorial walks through a cross-sell flow that recommends a second product to existing customers. The system needs to know what each customer already owns, what they’re likely to buy next, and how much incremental revenue each candidate offer brings (not just what they’d buy anyway). Business scenario: a bank’s existing-customer base owns one or more of: checking, savings, credit card, mortgage, brokerage. Marketing wants to recommend the next product per customer — and avoid recommending one they already have, one their tier doesn’t qualify for, or one they’d convert on without prompting (sleeping dogs). What you’ll build:
  • A Customer schema that tracks current product holdings
  • 4 cross-sell offers with eligibility tied to existing holdings
  • A flow whose Score node blends propensity, impact, and an uplift term so ranking optimizes incremental revenue
  • Channel-aware setup so the flow can run across email, in-app, and branch
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 product holdings

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": "tier",              "dataType": "varchar" },
      { "name": "hasChecking",       "dataType": "boolean" },
      { "name": "hasSavings",        "dataType": "boolean" },
      { "name": "hasCreditCard",     "dataType": "boolean" },
      { "name": "hasMortgage",       "dataType": "boolean" },
      { "name": "hasBrokerage",      "dataType": "boolean" },
      { "name": "avgMonthlyBalance", "dataType": "decimal" }
    ]
  }' | jq -r '.id')
echo "Schema id: $SCHEMA_ID"
These boolean holding fields are the gate — qualification rules will use them to exclude offers the customer already owns.

2. Define cross-sell offers

Offers are created active (the Inventory stage only loads active offers). businessValue (0–100) feeds ranking’s Impact term; revenueValue records the dollar figure. Eligibility is attached separately in step 3.
OFFER_SAVINGS=$(curl -s -X POST "$BASE/api/v1/offers" "${AUTH[@]}" -d '{
  "name": "High-yield savings", "status": "active", "category": "cross-sell",
  "priority": 70, "businessValue": 40, "revenueValue": 40
}' | jq -r '.id')

OFFER_CARD=$(curl -s -X POST "$BASE/api/v1/offers" "${AUTH[@]}" -d '{
  "name": "Rewards credit card", "status": "active", "category": "cross-sell",
  "priority": 80, "businessValue": 80, "revenueValue": 90
}' | jq -r '.id')

OFFER_WEALTH=$(curl -s -X POST "$BASE/api/v1/offers" "${AUTH[@]}" -d '{
  "name": "Wealth-management account", "status": "active", "category": "cross-sell",
  "priority": 95, "businessValue": 95, "revenueValue": 250
}' | jq -r '.id')

OFFER_MORTGAGE=$(curl -s -X POST "$BASE/api/v1/offers" "${AUTH[@]}" -d '{
  "name": "Mortgage pre-approval", "status": "active", "category": "cross-sell",
  "priority": 90, "businessValue": 90, "revenueValue": 500
}' | jq -r '.id')

3. Attach holding-aware qualification rules

Each rule is an attribute_condition scoped to one offer at the eligibility stage. The attribute names the enriched field (Enrich loads schema columns under the customer. prefix). Multiple rules on the same offer are AND-combined, so an offer surfaces only when all its rules pass. A boolean field compares directly against true/false; tier membership uses the in operator with an array.
# Savings: only for checking-only customers
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
  \"name\": \"Savings — has checking\", \"ruleType\": \"attribute_condition\",
  \"scope\": \"offer\", \"scopeId\": \"$OFFER_SAVINGS\", \"stage\": \"eligibility\",
  \"config\": { \"attribute\": \"customer.hasChecking\", \"operator\": \"eq\", \"value\": true } }"
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
  \"name\": \"Savings — no savings yet\", \"ruleType\": \"attribute_condition\",
  \"scope\": \"offer\", \"scopeId\": \"$OFFER_SAVINGS\", \"stage\": \"eligibility\",
  \"config\": { \"attribute\": \"customer.hasSavings\", \"operator\": \"eq\", \"value\": false } }"

# Credit card: not for customers who already have one, and needs 6mo tenure
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
  \"name\": \"Card — no card yet\", \"ruleType\": \"attribute_condition\",
  \"scope\": \"offer\", \"scopeId\": \"$OFFER_CARD\", \"stage\": \"eligibility\",
  \"config\": { \"attribute\": \"customer.hasCreditCard\", \"operator\": \"eq\", \"value\": false } }"
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
  \"name\": \"Card — tenure >= 6mo\", \"ruleType\": \"attribute_condition\",
  \"scope\": \"offer\", \"scopeId\": \"$OFFER_CARD\", \"stage\": \"eligibility\",
  \"config\": { \"attribute\": \"customer.tenureMonths\", \"operator\": \"gte\", \"value\": 6 } }"

# Wealth: premium/private tier, significant balance, no brokerage yet
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
  \"name\": \"Wealth — no brokerage yet\", \"ruleType\": \"attribute_condition\",
  \"scope\": \"offer\", \"scopeId\": \"$OFFER_WEALTH\", \"stage\": \"eligibility\",
  \"config\": { \"attribute\": \"customer.hasBrokerage\", \"operator\": \"eq\", \"value\": false } }"
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
  \"name\": \"Wealth — premium tier\", \"ruleType\": \"attribute_condition\",
  \"scope\": \"offer\", \"scopeId\": \"$OFFER_WEALTH\", \"stage\": \"eligibility\",
  \"config\": { \"attribute\": \"customer.tier\", \"operator\": \"in\", \"value\": [\"premium\", \"private\"] } }"
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
  \"name\": \"Wealth — balance >= 50k\", \"ruleType\": \"attribute_condition\",
  \"scope\": \"offer\", \"scopeId\": \"$OFFER_WEALTH\", \"stage\": \"eligibility\",
  \"config\": { \"attribute\": \"customer.avgMonthlyBalance\", \"operator\": \"gte\", \"value\": 50000 } }"

# Mortgage: no mortgage yet, strong tenure
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
  \"name\": \"Mortgage — none yet\", \"ruleType\": \"attribute_condition\",
  \"scope\": \"offer\", \"scopeId\": \"$OFFER_MORTGAGE\", \"stage\": \"eligibility\",
  \"config\": { \"attribute\": \"customer.hasMortgage\", \"operator\": \"eq\", \"value\": false } }"
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
  \"name\": \"Mortgage — tenure >= 24mo\", \"ruleType\": \"attribute_condition\",
  \"scope\": \"offer\", \"scopeId\": \"$OFFER_MORTGAGE\", \"stage\": \"eligibility\",
  \"config\": { \"attribute\": \"customer.tenureMonths\", \"operator\": \"gte\", \"value\": 24 } }"
The eligibility rules are the safety net. Even if the propensity model says “this customer would love a credit card”, the rule customer.hasCreditCard == false keeps the platform honest.

4. Create the delivery channels

Cross-sell often runs across email, in-app, and (for high-value offers) branch. Each channel is its own entity. couplingMode: "partial" means a placement that can’t be filled doesn’t cascade-empty its siblings in the same channel — each placement is decided independently.
for ch in email in_app branch; do
  curl -s -X POST "$BASE/api/v1/channels" "${AUTH[@]}" \
    -d "{ \"name\": \"$ch\", \"status\": \"active\", \"couplingMode\": \"partial\" }"
done

5. Wire the uplift-aware decision flow

The Score node’s method: "formula" activates PRIE-U weighting. The four core weights — Propensity, Relevance, Impact, Emphasis — must sum to 1.0. upliftWeight sits outside that sum (range 0–2): it applies the uplift dimension, which downweights offers a customer would convert on anyway (sure things) and boosts offers that actually move the needle (persuadable). The flowConfig.experiment block withholds offers from 10% of traffic — the holdout the uplift dimension needs to estimate incremental lift (step 6).
FLOW_ID=$(curl -s -X POST "$BASE/api/v1/decision-flows" "${AUTH[@]}" \
  -d "{
    \"key\": \"cross-sell\",
    \"name\": \"Cross-Sell\",
    \"draftConfig\": {
      \"version\": 2,
      \"flowConfig\": { \"experiment\": { \"enabled\": true, \"holdoutPercent\": 10, \"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\": [\"tier\", \"tenureMonths\", \"hasChecking\", \"hasSavings\", \"hasCreditCard\",
              \"hasMortgage\", \"hasBrokerage\", \"avgMonthlyBalance\"] } ] } },
        { \"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.15,
            \"impactWeight\": 0.35, \"emphasisWeight\": 0.1, \"upliftWeight\": 0.5 } } },
        { \"id\": \"rank\",  \"type\": \"rank\",           \"phase\": 2, \"position\": 5,
          \"config\": { \"method\": \"topN\", \"maxCandidates\": 5 } },
        { \"id\": \"out\",   \"type\": \"response\",       \"phase\": 3, \"position\": 6,
          \"config\": { \"responseFormat\": \"standard\" } }
      ]
    }
  }" | jq -r '.id')

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

6. Feed the uplift signal

upliftWeight is live the moment you publish, but the uplift dimension only changes rankings once it has a causal signal to read — the per-customer CATE estimate of how much the offer moves conversion versus a control. That signal comes from the holdout you configured in step 5: flowConfig.experiment withholds cross-sell offers from 10% of traffic (controlGroupAction: "no_offers"), so the platform can compare treated vs. untreated conversion. As holdout and treatment outcomes accumulate through /respond, the uplift dimension’s CATE estimates sharpen. You need roughly 30 days of decisions with a holdout before the signal is reliable. See Uplift Modeling for the T-learner / X-learner math and how to inspect a model’s uplift via GET /api/v1/algorithm-models/{id}/uplift.

7. Get a recommendation

For a 36-month-tenure premium customer with no credit card:
curl -s -X POST "$BASE/api/v1/recommend" "${AUTH[@]}" \
  -d '{ "customerId": "cust_42", "channel": "in_app", "decisionFlowKey": "cross-sell" }'
Sample response:
{
  "interactionId": "9d21…",
  "recommendationId": "9d21…",
  "customerId": "cust_42",
  "decisionFlowKey": "cross-sell",
  "decisionFlowVersion": 2,
  "channel": "in_app",
  "count": 2,
  "decisions": [
    { "offerId": "…", "offerName": "Rewards credit card", "score": 0.79, "rank": 1, "priority": 80, "personalization": {} },
    { "offerId": "…", "offerName": "High-yield savings",  "score": 0.58, "rank": 2, "priority": 70, "personalization": {} }
  ],
  "meta": { "totalCandidates": 4, "afterQualification": 2, "afterSuppression": 2, "afterContactPolicy": 2, "degradedScoring": false }
}
The wealth and mortgage offers were filtered at qualification (afterQualification dropped from 4 to 2) because this customer doesn’t clear their eligibility rules. Of the two survivors, the uplift dimension pushed the credit card above savings: even with comparable raw propensity, the customer’s savings conversion is a near-sure-thing (low incremental lift), so the flow prefers the card, where the offer actually changes the outcome. To see the uplift/propensity/impact breakdown behind each score, add ?explain=true — the response then carries an explanation.rankingScores object per decision. (The default response shape above does not embed internal ranking factors.) This is the cross-sell game: don’t pay attribution dollars for conversions you’d get for free.

8. How the uplift dimension reasons (conceptual)

Internally, the uplift dimension sorts every (customer × offer) pair into one of four behavioral buckets by its CATE estimate. These buckets are a mental model for why the ranking moves — they are not fields in the /recommend response:
SegmentCATEBehavior
Persuadablestrongly positiveOffer moves the needle; surface it
Sure thingnear zeroWill convert anyway; deprioritize
Lost causenegative, low base rateWon’t convert; suppress
Sleeping dognegative, high base rateWould have converted but the offer backfires; suppress strongly
Sleeping dogs are the dangerous one. A discount sent to a customer who would have converted at full price loses money on both sides — the conversion you’d have gotten plus the lost margin.

9. What’s next

  • Layer contact frequency. Add a customer_total_cap contact policy (e.g. maxTotal: 4, periodType: "monthly") so cross-sell doesn’t crowd out service messages.
  • Per-channel scoring. The same offer at rank-1 on email may not be rank-1 on in-app. Add overrides to the Score node scoped channel to route different weights per surface.
  • Measure incrementality. Once the holdout has run, the experiments z-test reports incremental revenue with a confidence interval — not just raw conversion rate.
  • Multi-step sequencing. For high-value cross-sells like mortgages, wire a Journey so an email lead is followed (after a qualifying response) by a branch-appointment offer.

See Churn Prevention for the retention scenario, Industry Templates for ready-made starter kits, or Uplift Modeling for the CATE math.