Skip to main content

Tutorial: Onboarding & Activation

The first 30 days of a customer relationship are decisive. A new customer who hits their activation moment — the first action that proves the product works for them — converts to long-term retention. A new customer who doesn’t, churns silently. This tutorial builds a flow that nudges new customers through the activation funnel, escalates if they stall, and stops the program the moment activation fires — so the next message is the right one for an active customer, not another activation prompt. Business scenario: a SaaS product’s activation event is “first project created and shared with a teammate”. Roughly 40% of signups never get there. Marketing wants a 30-day sequence: day 1 welcome → day 3 tutorial nudge → day 7 use-case examples → day 14 success story → day 21 personal-help offer → day 28 cancellation-prevention. The moment a customer activates, the sequence terminates. What you’ll build:
  • A Customer schema with signup, activation, and progress signals
  • 6 onboarding offers staged across the 30-day window
  • A flow that selects the correct step by tenure-since-signup
  • An activation gate that stops the program per customer
  • Weekday-business-hours contact policies
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

The flow gates on tenure-since-signup and recency-of-login. The formula engine can’t compute those date differences at decision time, so daysSinceSignup and lastLoginDaysAgo are real columns your data pipeline refreshes daily. activatedAt stays null until the customer activates — that null is the signal the program should keep running.
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": "daysSinceSignup",     "dataType": "integer" },
      { "name": "activatedAt",         "dataType": "timestamp", "isNullable": true },
      { "name": "lastLoginDaysAgo",    "dataType": "integer" },
      { "name": "projectsCreated",     "dataType": "integer", "defaultValue": "0" },
      { "name": "teammateInvitesSent", "dataType": "integer", "defaultValue": "0" },
      { "name": "tutorialCompleted",   "dataType": "boolean", "defaultValue": "false" },
      { "name": "plan",                "dataType": "varchar" }
    ]
  }' | jq -r '.id')
echo "Schema id: $SCHEMA_ID"
activatedAt being null is what keeps the program alive; the offer rules below all require it to be absent.

2. Create the category with a progress score

A computed custom field surfaces a per-customer output value in the response’s personalization object — useful for an in-app progress bar. It runs in the Compute stage (after gating), so it’s a display value, not a gate. The formula uses only supported syntax (arithmetic, comparison, ternary); outputType is number or text.
CATEGORY_ID=$(curl -s -X POST "$BASE/api/v1/categories" "${AUTH[@]}" \
  -d '{
    "name": "Onboarding",
    "customFields": [
      {
        "name": "completionPct",
        "type": "computed",
        "formula": "(customer.projectsCreated > 0 ? 50 : 0) + (customer.teammateInvitesSent > 0 ? 50 : 0)",
        "outputType": "number"
      }
    ]
  }' | jq -r '.id')
echo "Category id: $CATEGORY_ID"
completionPct is a quick health score: 0 = “haven’t started”, 100 = “completed both activation precursors”.

3. Define the 6 staged offers

Each offer belongs to the Onboarding category and activates only during its day-window slot. Offers are active; eligibility is attached in step 4.
mk_offer () {   # $1 name  $2 priority  $3 businessValue
  curl -s -X POST "$BASE/api/v1/offers" "${AUTH[@]}" -d "{
    \"name\": \"$1\", \"status\": \"active\",
    \"category\": \"onboarding\", \"categoryId\": \"$CATEGORY_ID\",
    \"priority\": $2, \"businessValue\": $3 }" | jq -r '.id'
}

OFFER_D1=$(mk_offer  "Day 1 — Welcome"                 60 40)
OFFER_D3=$(mk_offer  "Day 3 — Try the tutorial"        65 45)
OFFER_D7=$(mk_offer  "Day 7 — See real-world use cases" 70 50)
OFFER_D14=$(mk_offer "Day 14 — How similar teams do it" 75 55)
OFFER_D21=$(mk_offer "Day 21 — Book a setup call"       85 80)
OFFER_D28=$(mk_offer "Day 28 — Stay another month"      95 50)
The Day-28 offer is a loss-leader — its up-front cost outweighs immediate margin, justified by the retained LTV the platform won’t see for months. Track that cost in the offer’s budget block; keep margin at its default (0 or higher — the API rejects negative margins).

4. Attach the staged eligibility rules

Every offer requires customer.activatedAt to be absent (the not_exists operator passes only when the field is null/unset) plus its day-window and step-specific conditions. Rules on the same offer are AND-combined.
add_rule () {   # $1 offerId  $2 name  $3 attribute  $4 operator  $5 valueJson
  curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
    \"name\": \"$2\", \"ruleType\": \"attribute_condition\",
    \"scope\": \"offer\", \"scopeId\": \"$1\", \"stage\": \"eligibility\",
    \"config\": { \"attribute\": \"$3\", \"operator\": \"$4\", \"value\": $5 } }"
}

# The activation stop-gate — every onboarding offer carries it.
for O in "$OFFER_D1" "$OFFER_D3" "$OFFER_D7" "$OFFER_D14" "$OFFER_D21" "$OFFER_D28"; do
  add_rule "$O" "Not activated yet" "customer.activatedAt" "not_exists" "null"
done

# Day 1 — window 0-2
add_rule "$OFFER_D1"  "D1 window start" "customer.daysSinceSignup" "gte" "0"
add_rule "$OFFER_D1"  "D1 window end"   "customer.daysSinceSignup" "lte" "2"

# Day 3 — window 3-5, tutorial not done
add_rule "$OFFER_D3"  "D3 window start" "customer.daysSinceSignup" "gte" "3"
add_rule "$OFFER_D3"  "D3 window end"   "customer.daysSinceSignup" "lte" "5"
add_rule "$OFFER_D3"  "D3 tutorial open" "customer.tutorialCompleted" "eq" "false"

# Day 7 — window 7-9, no project yet
add_rule "$OFFER_D7"  "D7 window start" "customer.daysSinceSignup" "gte" "7"
add_rule "$OFFER_D7"  "D7 window end"   "customer.daysSinceSignup" "lte" "9"
add_rule "$OFFER_D7"  "D7 no project"   "customer.projectsCreated" "eq" "0"

# Day 14 — window 14-16
add_rule "$OFFER_D14" "D14 window start" "customer.daysSinceSignup" "gte" "14"
add_rule "$OFFER_D14" "D14 window end"   "customer.daysSinceSignup" "lte" "16"

# Day 21 — window 21-23, only for recently-active users (logged in within 7 days)
add_rule "$OFFER_D21" "D21 window start" "customer.daysSinceSignup" "gte" "21"
add_rule "$OFFER_D21" "D21 window end"   "customer.daysSinceSignup" "lte" "23"
add_rule "$OFFER_D21" "D21 active user"  "customer.lastLoginDaysAgo" "lte" "7"

# Day 28 — window 28-30, stalled (still no project)
add_rule "$OFFER_D28" "D28 window start" "customer.daysSinceSignup" "gte" "28"
add_rule "$OFFER_D28" "D28 window end"   "customer.daysSinceSignup" "lte" "30"
add_rule "$OFFER_D28" "D28 stalled"      "customer.projectsCreated" "eq" "0"
Encoding the stop condition as a rule on every offer is the pattern: activation flips one column; the not_exists gate sees it; every onboarding offer stops firing at once (covered in step 7).

5. Contact policies — weekdays, business hours, once a day

Onboarding messages over the weekend feel desperate. A time_window policy restricts delivery to Mon–Fri business hours (day names are three-letter, capitalized; hours are 0–23), and a customer_total_cap limits to one onboarding contact per day.
curl -s -X POST "$BASE/api/v1/contact-policies" "${AUTH[@]}" \
  -d '{
    "name": "Onboarding — weekdays 09:00-18:00",
    "ruleType": "time_window",
    "scope": "global",
    "config": {
      "daysOfWeek": ["Mon","Tue","Wed","Thu","Fri"],
      "startHour": 9, "endHour": 18,
      "timezone": "America/New_York"
    }
  }'

curl -s -X POST "$BASE/api/v1/contact-policies" "${AUTH[@]}" \
  -d '{
    "name": "Onboarding — max 1 per day",
    "ruleType": "customer_total_cap",
    "scope": "global",
    "config": { "maxTotal": 1, "periodType": "daily" }
  }'
Together: at most one onboarding contact per customer per day, only Mon–Fri 09:00–18:00. A Saturday signup gets its day-1 welcome the following Monday morning.

6. Wire the onboarding flow

Onboarding is deterministic by design — the right offer for day-7 is always the day-7 offer, so the Score node uses method: "priority_weighted" (it honors the priority field directly rather than an ML propensity). The Compute node attaches completionPct.
FLOW_ID=$(curl -s -X POST "$BASE/api/v1/decision-flows" "${AUTH[@]}" \
  -d "{
    \"key\": \"onboarding-activation\",
    \"name\": \"Onboarding & Activation\",
    \"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\": [\"daysSinceSignup\", \"activatedAt\", \"lastLoginDaysAgo\",
              \"projectsCreated\", \"teammateInvitesSent\", \"tutorialCompleted\"] } ] } },
        { \"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\": \"priority_weighted\" } },
        { \"id\": \"rank\",    \"type\": \"rank\",           \"phase\": 2, \"position\": 5,
          \"config\": { \"method\": \"topN\", \"maxCandidates\": 1 } },
        { \"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\" }"
A day-7 customer who hasn’t created a project gets exactly one decision back:
{
  "customerId": "cust_new_1",
  "decisionFlowKey": "onboarding-activation",
  "count": 1,
  "decisions": [
    { "offerId": "…", "offerName": "Day 7 — See real-world use cases", "score": 70, "rank": 1,
      "priority": 70, "categoryName": "Onboarding", "personalization": { "completionPct": 0 } }
  ],
  "meta": { "totalCandidates": 6, "afterQualification": 1, "afterContactPolicy": 1, "degradedScoring": false }
}
Five of the six offers failed qualification (wrong day-window); only the day-7 offer survived. With method: "priority_weighted", score mirrors the offer’s priority.

7. The activation stop

Activation is a fact about the customer record, so the stop is driven by data, not by a special API call. Two things happen when a customer activates:
  1. Your product writes activatedAt into the customer record — through the same data path that populates the ds_customer table (a pipeline, a bulk upsert, or your app’s own write). On the next /recommend, the Enrich stage loads a non-null customer.activatedAt, every onboarding offer’s not_exists rule fails, and the flow returns count: 0 — the program has stopped for this customer.
  2. Optionally, record the activation as an outcome for measurement. Register an outcome type once, then record it against the recommendation the customer acted on. (/respond records interactions and updates models; it does not itself mutate schema columns.)
# Register the activation outcome type (one-time)
curl -s -X POST "$BASE/api/v1/outcome-types" "${AUTH[@]}" \
  -d '{ "key": "activated", "name": "Activated", "classification": "positive", "category": "response" }'

# Record it against a prior recommendation (idempotencyKey is required)
curl -s -X POST "$BASE/api/v1/respond" "${AUTH[@]}" \
  -d '{
    "customerId": "cust_new_1",
    "recommendationId": "<recommendationId from /recommend>",
    "rank": 1,
    "outcome": "activated",
    "idempotencyKey": "cust_new_1-activated-2026-01-15"
  }'
Encoding the stop as a qualification rule — rather than as branching logic inside the flow — keeps the flow simple: activation flips one column, the rules see it, and the offers stop firing.

8. What success looks like

Track three numbers on the Operations dashboard:
MetricSourceTarget
Activation rate (30d)customers with non-null activatedAt ÷ signups> 60%
Avg days to activatemean of activatedAt − signupDate< 14 days
Onboarding cost per activated customeronboarding impressions × cost-per-message ÷ activations< $0.50
If activation rate drops below target, the most common cause is the schema not capturing the right signal. Add a column, update the pipeline that maintains it, re-deploy — the flow picks up the new signal at the next decision.

9. What’s next

  • Activation-funnel A/B. Add a flowConfig.experiment holdout that gets no onboarding messages. If activation rate is similar, the program is decorating outcomes that would have happened anyway.
  • Plan-specific tracks. Enterprise customers need longer, more white-glove onboarding than free-tier. Add a customer.plan rule to fork the flow per tier.
  • Inactivity side-flows. If customer.lastLoginDaysAgo climbs while still in onboarding, route to a re-engagement sub-flow via a conditional node — see Journeys.
  • In-product nudges. Some onboarding steps are in-app banners, not emails. Request them with channel: "in_app" to surface the right step contextually during a session.

See Churn Prevention, Cross-Sell, and Winback for the other three core lifecycle flows. Together they cover the four phases of the customer relationship: onboard, grow, retain, recover.