Campaigns
A campaign wires (segment + template + provider) into a sendable artifact. Three types: broadcast (one-off send), triggered (re-fired by event), journey (multi-step — see Journeys).
Lifecycle
draft → scheduled → sending → sent
draft → cancelled
sending → cancelled (drains in-flight; no new fan-out)
Send fan-out
POST /v1/campaigns/:id/send walks the segment in batches of 1000 and:
- Filters — excludes self-excluded players (capability_mock), players exceeding frequency caps, players in quiet hours.
- Variant assigns — deterministic FNV-1a hash of
(campaign_id, player_id)→ CDF bucket lookup againstcampaign_variants. - Bulk inserts
message_sendsrows + enqueues BullMQ jobs.
Skipped rows land with status=failed + error_code=SKIPPED_*. The campaign stats panel shows them as "Suppressed" rather than provider failures.
A/B variants (Sprint 17)
Each campaign has N variants (table campaign_variants):
[
{ "name": "Variant A", "label": "A", "template_id": "...", "weight": 50, "sort_order": 0 },
{ "name": "Variant B", "label": "B", "template_id": "...", "weight": 50, "sort_order": 1 }
]
The migration backfilled a default variant for every existing campaign (referencing the campaign's own template_id), so the code path is uniform.
Operators can shift traffic mid-campaign by editing weights — only new sends respect the new split; already-enqueued message_sends keep their assigned variant_id.
Conversion goals (Sprint 17)
{
"goal_event": "deposit_confirmed",
"goal_attribution_days": 7,
"goal_value_property": "amount_base"
}
GET /v1/campaigns/:id/conversion-stats joins message_sends (PG) with raw_events (CH) — for each player who received a message, the earliest goal_event in [sent_at, sent_at + window) counts as a conversion.
Stats include per-variant recipients, conversions, conversion_rate, optional conversion_value sum + a winner pick (highest rate, min sample 30).
Frequency capping
tenant.config.messaging.frequency_caps:
{
"email": { "per_day": 1, "per_week": 5 },
"sms": { "per_day": 1, "per_week": 3 },
"push": { "per_day": 3, "per_week": 14 }
}
Cap query joins message_sends count per channel within rolling window. Suppressed rows (SKIPPED_*) don't count toward the cap.
Quiet hours
tenant.config.messaging.quiet_hours + per-player players.timezone (IANA name). Quiet-window check uses Intl.DateTimeFormat for DST-aware local-clock conversion. Players in window get error_code=SKIPPED_QUIET_HOURS.