Skip to main content

Holdouts and uplift measurement

A recipient who converts might have converted anyway. Every other campaign metric — delivery, opens, clicks, replies — answers “how did the message perform?”; none of them answer “did the campaign cause anything.” Orbit answers two distinct causal questions with two distinct randomized holdout cohorts, each compared through the same lift engine:
  1. “Did sending lift conversion?” — the campaign-wide holdout. You set a percentage of the eligible audience that receives nothing, then compare the senders’ conversion against that do-nothing control.
  2. “Did optimizing WHEN we send lift conversion?” — the smart-send holdout, available on campaigns running recipient-optimal send-time optimization (smart_send). You hold a percentage of contacts out of the time optimization; they receive the campaign immediately in natural batch order while the rest receive their predicted best-time window. Comparing the two isolates the value of the scheduling optimization above the campaign’s baseline effect.
Both cohorts are randomized and deterministic, both use 95% confidence intervals, and both are read-only measurements — they never gate, slow, or re-route a send.

Enable a holdout

Safety defaults are opt-out. Both holdout percentages read unset, 0, or a malformed value as no holdout — nobody is held out and the lift endpoint reports “no holdout configured.” A holdout only carves out a cohort when you explicitly set a positive percentage. Values above 50 are clamped so a bad value can never invert which arm is the control.
Size holdout cohorts from the governance-eligible audience (suppressed, capped, and opted-out contacts netted out — the same recipients the dry-run preview reports), not the raw match count. A control cohort that receives nothing also consumes no frequency-cap slot.

Campaign-wide holdout — did sending lift conversion?

Set variables.campaign_holdout_percent (a number, 1–50) on the campaign before launch:
During execution that 10% of recipients is held out of the send and the campaign persists an aggregate command breadcrumb (treatment and control counts) on the campaign — not the contact IDs — so the JSONB column stays bounded on very large audiences. You then read the answer with GET /campaigns/:id/holdout-lift.

Smart-send holdout — did optimizing WHEN we send lift conversion?

Prerequisite: the campaign is on recipient-optimal scheduling (smart_send = true — either that flag or send_time_optimization = "recipient-optimal"). On an eligible campaign, opt in with variables.smart_send_holdout_pct (a number, 1–50) before launch:
Held-out contacts are sent immediately in natural batch order — exactly as they would have been with the optimization disabled — while the rest receive their predicted best-time window. The lift endpoint re-resolves the same eligible audience the sender used (suppressed, opted-out, address-less, and campaign-wide-holdout recipients netted out) and re-partitions it deterministically, so each contact’s arm is reconstructed exactly rather than approximated from counts.
The two holdouts are independent. You can run the campaign-wide holdout and the smart-send holdout on the same send: the campaign-wide control never hears from you (measuring the campaign against doing nothing), and within the senders the smart-send control keeps the un-optimized schedule (measuring the optimizer against just sending).

How assignment stays random

Cohorts are split by deterministic hashing, not by sampling at send time — the same contact lands in the same arm on every pass, across every pause/resume and audience re-resolution. A contact is hashed with SHA-256 into a bucket from 0 to 99; a bucket below the configured percentage is held out. Determinism also makes the audit replayable: given the campaign id and a contact id, you can re-derive the assignment exactly. Each experiment family hashes on its own seed prefix, so a contact’s smart-send-holdout bucket is uncorrelated with their campaign-wide holdout bucket, their A/B variant bucket, and their send-time-experiment bucket — one experiment’s assignment can never bias another’s. The campaign-wide holdout uses the campaign-… family; the smart-send holdout uses the smart-send-holdout:… family.

Read lift: the two endpoints

Both endpoints return the same lift shape: a treatment arm and a control arm with counts and conversion rates, the absolute lift (treatment rate minus control rate, in percentage points), the relative lift, and a Wald confidence interval with a two-sided z-test — z_score, p_value, and an is_significant flag that is true when the interval excludes zero. A “winning” variant proves correlation; a treatment-vs-control lift whose confidence interval excludes zero proves the send caused incremental conversions. For full request/response schemas, see the campaigns API reference.
  • GET /campaigns/:id/holdout-lift — did sending lift conversion? The card reports has_holdout: false and tracking_method: "no_holdout" on an un-configured campaign; counts_only when the control cohort was carved out but per-arm conversions haven’t been joined yet; and a full lift + confidence interval once both arms’ conversions are known.
  • GET /campaigns/:id/smart-send-holdout-lift — did optimizing when we send lift conversion? On a campaign not using recipient-optimal scheduling (or with no smart-send holdout set) the card reports configured: false and tracking_method: "no_holdout" — a benign read state, not an error.

Outcome metrics on the smart-send card

Click lift is silent for channels with no tracked link (plain SMS, most WhatsApp sends). The smart-send endpoint therefore also returns a metrics object scored against the same treatment/control partition for three more outcomes, each with its own lift card and confidence interval:
  • delivered — the contact’s message reached a terminal delivered status.
  • read — a WhatsApp/RCS read receipt, or the email open-pixel stamp (the same predicate the deliverability dashboard’s “Read Rate” card uses).
  • replied — an inbound message arrived on the contact’s conversation attributed to this campaign.
Each metric is scored independently, so one outcome’s join degrading does not blank out the other two.

A worked workflow

  1. Set both flags before launch. Say, a 10% campaign-wide holdout plus a 10% smart-send holdout on a recipient-optimal campaign. Launch.
  2. Read the campaign-wide lift. GET /campaigns/:id/holdout-lift — until its is_significant reads true, treat the campaign’s effect as unproven vs. doing nothing.
  3. Read the optimization lift. GET /campaigns/:id/smart-send-holdout-lift — check lift (clicks) and each card under metrics (delivered/read/replied).
  4. Decide whether the optimization earns its keep. When the lift’s confidence interval spans zero, the optimization is not demonstrably moving conversion at your current volume — either widen the audience, accept the un-optimized schedule, or re-run on a larger send. When the interval clears zero, keep the optimizer enabled and read the same card on future sends to confirm the effect holds.
The same discipline applies to the campaign-wide holdout: a significant positive lift against the do-nothing control is the only number that justifies the campaign’s existence. Everything else in the dashboard is a performance metric; this is the causal one.