Docs / Experiments View as Markdown

Targeting flows

Country, device, UTM, cookie, time, platform and app version conditions as a node graph, in the dashboard and in the config.

Who enters an experiment, and with which split, can depend on country, device, browser, UTM parameters, cookies, time, the app's platform and version, or whether the visitor is new or returning. In SplitHub this is a small node graph, the flow, on the experiment.

In the dashboard

Open an experiment → Targeting. The editor has three node types:

  • Condition: one or more conditions, combined with match: 'all' or 'any'. Fields include country, region, city, device, browser, os, language, referrer, url, path, utm_source, utm_medium, utm_campaign, utm_term, utm_content, query, cookie, visitor, hour, weekday, platform, app_version. For query and cookie, set key to the parameter or cookie name. Operators: is, is_not, in, not_in, contains, not_contains, starts_with, matches, exists, not_exists, plus gte (at least) and lt (below) for app_version and hour. Values are strings; in uses a comma-separated list.
  • Split: weighted outputs; each output goes to a variant or to another node.
  • Variant / Skip: the leaf. Skip means "not in the experiment": the visitor sees the control and no exposure is recorded.

A toggle switches to a simple list view (sections top to bottom, drag to reorder) for flows that are just "if country in EU → split A, else → split B". While the flow editor is open, variant editing is locked so the two cannot drift; on save the editor collapses to a one-line summary.

In the config

The same graph serializes as flow on the experiment and comes into splithub.config.ts with npx splithub pull:

'hero-title': {
  kind: 'value',
  status: 'running',
  variants: { control: { weight: 1, value: 'A' }, bold: { weight: 1, value: 'B' } },
  flow: {
    start: 'geo',
    nodes: {
      geo:   { type: 'condition', conditions: [{ field: 'country', op: 'in', value: 'DE,AT,CH' }], then: 'eu', else: 'rest' },
      eu:    { type: 'split', outputs: [{ weight: 50, to: 'control' }, { weight: 50, to: 'bold' }] },
      rest:  { type: 'split', outputs: [{ weight: 90, to: 'control' }, { weight: 10, to: 'bold' }] },
      control: { type: 'variant', variant: 'control' },
      bold: { type: 'variant', variant: 'bold' },
    },
  },
}

defineConfig validates the flow: node references, supported fields and operators, split weights, and declared variant names are checked. Design each path to end in a variant or skip; avoid cycles.

Apps: platform and version

platform is ios, android, macos, windows, linux, or web for a browser. app_version is the app's version (2.4.1) and is empty on websites. Versions compare part by part, so 2.10.0 is at least 2.9:

conditions: [{ field: 'app_version', op: 'gte', value: '2.4' }]   // the dashboard reads it as "App version at least 2.4"

Gate an app experiment on the first version that ships its variant code; older builds see the control and are not counted. See Apps.

Where it runs

Everywhere the assignment runs, with the same answer:

  • the React SDK (browser context: device, browser, path, query, cookies, time in the visitor's time zone; country, region and city from the cloud's ctx when in cloud mode);
  • the Next.js middleware (request headers and Cloudflare/Vercel geo);
  • the drop-in script sh.js, which walks the same flow as the SDK;
  • the React Native, iOS and Android SDKs (platform, app version, language, device; country from the cloud);
  • server code using assign(config, experimentId, visitorId, { context }).

Pass supported context fields with createClient(config, { context: { country: 'DE' } }) or the provider's context prop. For a plan stored in a cookie, use { field: 'cookie', key: 'plan', op: 'is', value: 'pro' }.

Order of checks

For each visitor and experiment the SDK, the middleware and sh.js decide in the same order:

  1. a ?sh_force=<id>:<variant> preview wins and records nothing;
  2. crawlers see the control and are never counted;
  3. the experiment must be running and inside its schedule;
  4. a visitor in the holdout sees the control (reason holdout);
  5. a visitor outside the experiment's layer range sees the control (reason layer);
  6. the traffic share, then the flow: a Skip leaf gives the control without an exposure (reason targeting).

So a flow only ever sees the visitors the holdout and the layer let in. ?sh_debug=1 shows the reason for each decision.

When to use it

  • Roll a change out to one market first.
  • Keep a mobile-only variant out of desktop traffic.
  • Test only paid traffic (utm_medium is cpc).
  • Exclude employees by cookie.
  • Run an app experiment only on the versions that have its code.

Remember the statistics: targeting narrows the sample. The verdict is about the visitors who entered, not about everyone. When conditions decide which variant a visitor reaches, the expected split depends on who visits, so the split check (SRM) is skipped and the verdict notes say so.

SplitHub
See what works. Ship what wins.