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
curl + jq.
Time: 25–30 minutes.
0. Set up your shell
1. Define the schema with product holdings
2. Define cross-sell offers
Offers are createdactive (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.
3. Attach holding-aware qualification rules
Each rule is anattribute_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.
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.
5. Wire the uplift-aware decision flow
The Score node’smethod: "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).
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: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:
| Segment | CATE | Behavior |
|---|---|---|
| Persuadable | strongly positive | Offer moves the needle; surface it |
| Sure thing | near zero | Will convert anyway; deprioritize |
| Lost cause | negative, low base rate | Won’t convert; suppress |
| Sleeping dog | negative, high base rate | Would have converted but the offer backfires; suppress strongly |
9. What’s next
- Layer contact frequency. Add a
customer_total_capcontact 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
overridesto the Score node scopedchannelto 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.