A/B testing + conversion goals
Per-campaign N variants with deterministic per-player assignment + conversion attribution against any canonical event.
Variants
Each campaign has 1..N rows in campaign_variants:
{
"name": "Variant A",
"label": "A", // short badge label
"template_id": "...", // operator clones template, tweaks copy
"weight": 50, // relative weight 0..10000
"sort_order": 0
}
Weights need not sum to 100 — assignment normalises by total. Two variants at weight=1 each = 50/50 split. One at 70, one at 30 = 70/30.
Deterministic assignment
bucket = FNV1a32(`${campaign_id}|${player_id}`) mod sum_weights
variant = first variant where cumulative_weight > bucket
Same player on the same campaign always lands in the same variant — even across re-sends. Prevents variant-drift attribution mud.
Same player on different campaigns can land in different variants (the campaign_id varies the hash input).
Conversion goals
// On the campaign:
{
"goal_event": "deposit_confirmed", // canonical event name
"goal_attribution_days": 7, // 1..90
"goal_value_property": "amount_base" // jsonpath for revenue attribution
}
GET /v1/campaigns/:id/conversion-stats joins message_sends (PG) with raw_events (CH):
- For each player who received a message in
(sent | delivered | opened | clicked)status, - Find the earliest
goal_eventin[sent_at, sent_at + attribution_days * 24h) - Group by
variant_id→ count + value sum
Earliest because a player might do the goal multiple times — we credit ONE conversion per player per campaign.
Stats response
{
"campaign_id": "...",
"goal_event": "deposit_confirmed",
"goal_attribution_days": 7,
"total_recipients": 3085,
"total_conversions": 151,
"variants": [
{
"variant_id": "...",
"variant_name": "Variant A",
"variant_label": "A",
"recipients": 1547,
"conversions": 87,
"conversion_rate": 0.0563,
"conversion_value": 870000 // sum in tenant base currency cents
},
{
"variant_id": "...",
"variant_name": "Variant B",
"variant_label": "B",
"recipients": 1538,
"conversions": 64,
"conversion_rate": 0.0416,
"conversion_value": 640000
}
],
"winner_variant_id": "...A's id"
}
Winner picker
A variant is declared the winner when:
- It has the highest conversion rate among all variants
- Its
recipientscount is ≥ 30 (avoids declaring winners on noise) - Tie-break: highest recipient count (more confidence) → first by sort_order
Below 30 sample size, winner_variant_id is null until more sends accumulate.
Mid-campaign weight changes
Operators can edit weights mid-campaign — only new sends respect the new split. Already-enqueued message_sends keep their assigned variant_id so conversion attribution stays clean.