# SplitHub > A/B testing for web apps built with coding agents. Experiments are declared in `splithub.config.ts`, rendered with `@splithub/react` or `@splithub/next` (or the drop-in script), and the verdict is read from the `splithub` CLI, the `@splithub/mcp` server or the dashboard. Events go to your own database (self-hosted, free) or to SplitHub Cloud (50,000 free exposures per project per month, packs from $5, Pro $19/month). One command sets everything up in a project: `npx splithub` (interactive) or `npx splithub init --mode cloud --yes` (agents). The CLI installs agent skills (.claude/skills, .cursor/rules) and an MCP server entry, so Claude Code, Cursor and Codex know how to create and read experiments. Every docs page below is also available as raw Markdown by appending `.md` to its URL. The whole documentation in one file: https://splithub.io/llms-full.txt ## Start - [Getting started](https://splithub.io/docs/getting-started.md): Set up SplitHub, connect your app, create an experiment and read its verdict. - [How it works](https://splithub.io/docs/concepts.md): Config file as source of truth, deterministic assignment, exposures and goals, self-hosted vs cloud. - [splithub.config.ts](https://splithub.io/docs/config.md): Every field of the config: storage, goals, experiments, variants, traffic, schedule, targeting. ## SDKs - [React](https://splithub.io/docs/react.md): SplitHubProvider, /, useVariant, useValue, useTrack, pageview goals. - [Next.js](https://splithub.io/docs/nextjs.md): Middleware, Server Components, redirect experiments without flicker, live overrides. - [Drop-in script](https://splithub.io/docs/script.md): sh.js for sites without a build step: redirect experiments, page and click goals, window.splithub.track. ## Experiments - [Redirect experiments](https://splithub.io/docs/redirect-experiments.md): Test whole pages: match a path, send each variant to its own URL with middleware, a script or short links. - [Targeting flows](https://splithub.io/docs/targeting.md): Country, device, UTM, cookie, time and visitor conditions as a node graph, in the dashboard and in the config. - [Goals and revenue](https://splithub.io/docs/goals.md): Event, pageview and click goals, revenue props, attribution by first exposure. - [Statistics and verdicts](https://splithub.io/docs/statistics.md): SRM check, z-test with Bonferroni, confidence intervals, P(beat), when a verdict is final. ## Tools - [CLI reference](https://splithub.io/docs/cli.md): init, create, start/pause/finish, status, results, push, pull, login, doctor, review, skills. - [MCP server](https://splithub.io/docs/mcp.md): @splithub/mcp for Claude Code, Cursor and Codex: tools, setup, working rules. - [Agent skills](https://splithub.io/docs/agent-skills.md): The six skills the CLI installs and how an agent uses them to run tests correctly. - [HTTP API](https://splithub.io/docs/api.md): Event ingest with the public key, project endpoints with the API key, live overrides JSON, errors. ## Cloud and data - [Self-hosted (your database)](https://splithub.io/docs/self-hosted.md): The ingest handler, the Store interface, Postgres/MySQL/SQLite/D1, the events table, attribution SQL. - [SplitHub Cloud](https://splithub.io/docs/cloud.md): Dashboard, projects, keys, live overrides, event gate, retention, what is billed. - [Alerts, digest, AI reviews](https://splithub.io/docs/alerts.md): Rules that pause or finish tests, channels, the scheduled digest, AI reviews at start, during progress checks and on finish. - [Short links](https://splithub.io/docs/links.md): Weighted and targeted destinations, sticky choice, UTM pass-through, click stats, QR codes. - [Plans and limits](https://splithub.io/docs/pricing.md): Free, packs and Pro side by side, what an exposure is, what happens at the limit. ## Site - [SplitHub: A/B testing in one command](https://splithub.io/) - [Pricing](https://splithub.io/pricing) - [Documentation](https://splithub.io/docs) - [SplitHub compared to other A/B testing tools](https://splithub.io/compare) - [Free marketing tools](https://splithub.io/tools) - [UTM generator](https://splithub.io/tools/utm-generator) - [QR code generator](https://splithub.io/tools/qr-generator) - [Slack emoji converter](https://splithub.io/tools/slack-emoji-converter) - [Contact](https://splithub.io/contact) - [Terms](https://splithub.io/terms) - [Privacy](https://splithub.io/privacy) ## Packages - splithub (CLI): https://www.npmjs.com/package/splithub - @splithub/react, @splithub/next, @splithub/core, @splithub/node, @splithub/stats, @splithub/adapter-sqlite, @splithub/adapter-d1, @splithub/mcp - Source: https://github.com/andreybo/splithub-ab-testing-platform - Cloud API base URL: https://go.splithub.io (JSON, no envelope under /v1/*) - Dashboard: https://app.splithub.io --- # Getting started SplitHub is A/B testing for web apps where the code is written with a coding agent. Experiments are declared in `splithub.config.ts`, variants are rendered with React or Next.js (or decided by a drop-in script), and the verdict is read from the CLI, the MCP server, the dashboard or an email. Events go to your own database or to SplitHub Cloud. ## One command In the folder of your app: ```bash npx splithub ``` The interactive menu offers: sign in through a link (no key to paste), create or pick a cloud project, write `splithub.config.ts` and the framework files, print the provider integration snippet, install the agent skills and the MCP server entry, create the first experiment, push it. Every step is also a subcommand with flags and `--json`, which is what an agent uses: ```bash npx splithub init --mode cloud --yes # or --mode self for your own database npx splithub create --kind value --id hero-title --variants control,bold \ --value "control=Old headline" --value "bold=New headline" --goal signup --yes npx splithub push npx splithub start hero-title npx splithub results hero-title ``` Prefer to let the agent do it? Open the project in Claude Code, Cursor or Codex and say: *"Set up SplitHub and A/B test the headline on the home page."* After `init` the agent has the [skills](/docs/agent-skills) and the [MCP server](/docs/mcp) it needs. ## What `init` writes | File | Purpose | |---|---| | `splithub.config.ts` | Experiments and goals. The source of truth. | | Provider snippet in the command output | Add it to `app/layout.tsx` or `main.tsx` to wrap the app in the provider. | | `middleware.ts` (Next.js) | Visitor cookie, redirect experiments, header for Server Components. | | `app/api/splithub/[[...path]]/route.ts` (self mode) | Ingest and results handler with a store. | | `splithub/store.ts` (self mode, `--db postgres` or `mysql`) | A working store for your database. | | `.claude/skills/*`, `.cursor/rules/splithub.mdc`, `AGENTS.md` or `CLAUDE.md` section | Agent skills. | | `.mcp.json`, `.cursor/mcp.json` | MCP server registration. | ## Render a variant ```tsx import { useValue, useTrack } from '@splithub/react'; export function Hero() { const title = useValue('hero-title'); const track = useTrack(); return

track('signup')}>{title}

; } ``` `create` writes the experiment as `running`: it goes live for every visitor when you deploy the code that renders it. Check it locally first with `?sh_force=:`; development never sends events to the cloud. To hold a test, create it with `--status draft` and start it later with `npx splithub start `, or pause it in the dashboard. A `value` experiment changes data (text, a URL, a number, JSON) without new components. A `component` experiment renders different JSX per variant with `…`. A `redirect` experiment sends each variant to its own page. See [React](/docs/react), [Next.js](/docs/nextjs) and [Redirect experiments](/docs/redirect-experiments). ## Read the verdict ```bash npx splithub status # every experiment: status, visitors, days, verdict headline npx splithub results hero-title ``` The verdict is one of `no_data`, `insufficient_sample`, `srm_detected`, `winner_found`, `no_significant_difference`, with a message and the numbers behind it: visitors and conversions per variant, relative lift, p-value, 95% confidence interval, probability to beat. The [statistics page](/docs/statistics) explains each. ## Finish ```bash npx splithub finish hero-title ``` Replace the `useValue` call (or the ``) with the winning content, delete the losing code, keep the config entry for history or remove it. In cloud mode a finished experiment stops accepting events at the edge; nothing more is stored or billed. ## Next - [How it works](/docs/concepts) explains assignment, exposures and goals. - [splithub.config.ts](/docs/config) lists every option. - [Self-hosted](/docs/self-hosted) if the events must stay in your database. - [SplitHub Cloud](/docs/cloud) for the dashboard, live overrides, alerts and AI reviews. --- # How it works Four ideas explain the whole system. ## The config file is the source of truth `splithub.config.ts` declares goals and experiments. It is committed, reviewed and diffed like the rest of the code. The CLI edits it (`create`, `start`, `pause`, `finish`), the SDK reads it, and in cloud mode `push` uploads a normalized copy so the dashboard and the API can read the same thing. The dashboard never edits the file. Experiments it creates are "cloud-only" until `npx splithub pull` writes them into the file; experiments the file owns are read-only in the dashboard except for pausing and finishing. Live overrides (status, traffic, weights, `value` payloads) sit on top of the config. The browser SDK applies them from a first visit on: it waits briefly for them when nothing is cached and refreshes them in the background on later pages. It never changes a variant a page has shown. ## Assignment is a local hash A visitor gets a random id in the `sh_vid` cookie (first party, set by the SDK or the Next.js middleware). Assignment uses MurmurHash3 with the visitor id, experiment key and seed, then selects from the cumulative variant weights. A separate hash controls entry into the traffic share. Given the same config and targeting context, browser, edge and server agree. Bucketing is local; fetching cloud overrides or hosted script config may require a request. Consequences: changing `seed` reshuffles every visitor (never do it on a running test); changing `traffic` does not; a visitor keeps the same variant across pages and sessions in the same browser, while the id and experiment settings remain unchanged. ## Exposures and goals An **exposure** is "this visitor saw this experiment in this variant". The SDK records it once per visitor, experiment and session when the variant is actually rendered (`` mounts, `useValue` is read, the redirect fires). A **goal** is the thing you want more of: an `event` you call `track()` for, a `pageview` of a path, or a `click` on a selector. Goals are tracked for every visitor; attribution happens at analysis time: a visitor converts for a variant when they sent the goal **at or after their first exposure** to that variant. Units are unique visitors, not hits. In the cloud only exposures of running experiments are billed. Goals never are. Exposures for paused, finished or draft experiments are refused at the edge and not stored. ## Self-hosted or cloud, same everything | | Self-hosted | SplitHub Cloud | |---|---|---| | Where events go | your database through a `Store` (SQLite, D1, Postgres, MySQL, custom) | `https://go.splithub.io` | | Config line | `storage: { mode: 'self', endpoint: '/api/splithub' }` | `storage: { mode: 'cloud', key: 'pk_…' }` | | Results | `npx splithub results` against your app's endpoint | CLI, MCP, dashboard, email | | Live overrides | no (edit the file) | yes | | Price | free, unlimited | 50k exposures / project / month free, then packs or Pro | The SDK, the statistics engine (`@splithub/stats`), the CLI and the MCP server are identical in both modes. To switch, update `storage` and configure the destination: a handler and database for self-hosting, or a project, keys and config push for cloud. ## Vocabulary - **experiment**: one question with 2+ variants; `kind` is `value`, `component` or `redirect`. - **control**: the first declared variant, what exists today. - **weight**: relative share of traffic per variant (50/50, 1/1, 90/10). - **traffic**: share of visitors who enter the experiment at all (`0.5` = half; the rest see the control and record no exposure). - **verdict**: the one-line conclusion of the statistics, with its status code. - **SRM**: sample ratio mismatch, the split you got differs from the one you configured; the setup is broken. --- # splithub.config.ts The config is a plain TypeScript (or JavaScript) module that exports `defineConfig(...)` from `@splithub/core`. `defineConfig` validates and normalizes it at import time, so mistakes fail fast with a readable message. The file ships to the browser: never put secrets in it. ```ts import { defineConfig } from '@splithub/core'; export default defineConfig({ project: 'my-app', storage: { mode: 'cloud', key: 'pk_live_…' }, runDraftsInDev: true, goals: { signup: { type: 'event' }, purchase: { type: 'event', revenue: true }, pricing_view: { type: 'pageview', path: '/pricing' }, cta_click: { type: 'click', selector: '#cta' }, }, experiments: { 'hero-title': { kind: 'value', status: 'running', goal: 'signup', description: 'Feature-led vs outcome-led headline', variants: { control: { weight: 50, value: 'Ship experiments from your terminal' }, outcome: { weight: 50, value: 'Know which version wins, by Friday' }, }, }, 'pricing-cta': { kind: 'component', status: 'draft', goal: 'purchase', traffic: 0.5, variants: { control: 1, 'see-plans': 1, 'talk-to-us': 1 }, maxExposures: 20000, schedule: { startAt: '2026-11-01T00:00:00Z', endAt: '2026-11-30T00:00:00Z' }, }, 'landing': { kind: 'redirect', status: 'running', goal: 'signup', match: '/', variants: { control: { weight: 50, to: '/' }, 'new-landing': { weight: 50, to: '/start' } }, }, }, }); ``` ## Top level | Field | Required | Meaning | |---|---|---| | `project` | yes | Name shown in the CLI and the dashboard. | | `storage` | yes | `{ mode: 'self', endpoint }` or `{ mode: 'cloud', key, endpoint? }`. | | `goals` | no | Map of goal key → goal. | | `experiments` | yes | Map of experiment key → experiment. | | `visitorKey` | no | Cookie name for the visitor id, default `sh_vid`. | | `runDraftsInDev` | no | `true` serves `draft` experiments like running ones while the app runs in development. Default `false`. | `storage.endpoint` in self mode is the path (or URL) where the `@splithub/node` handler is mounted. In cloud mode `key` is the project's public key; `endpoint` defaults to `https://go.splithub.io/v1/events` and only changes for a self-hosted cloud or staging. ## Drafts in development A `draft` experiment is invisible to visitors: everyone sees the control and nothing is recorded. To see it in your own app before you start it, set `runDraftsInDev: true`. While `NODE_ENV` is `development` (`next dev`, `vite`), drafts behave exactly like running experiments: variants by visitor, targeting, redirects, exposures and goals. A production build ignores them as before, so the same config is safe to deploy. In development the schedule of a draft is ignored, so a test that starts next week can be checked today. In cloud mode development never sends events to the cloud, for drafts and running tests alike, so your own visits never reach the results and nothing is billed. Deployed builds, and `vite preview`, send as usual. While no experiment in the project is running the SDK holds goals back too, because the cloud would drop them. In development the SDK also explains itself, one line per experiment: a draft that everyone sees as the control, a draft shown because of this option and which variant you got, a paused test, events held back. Next.js server code prints it in the terminal that runs `next dev`. In a Vite app the SDK runs in the browser, so add the plugin to see it in the terminal that runs `vite`: ```ts // vite.config.ts import { splithub } from '@splithub/core/vite'; export default defineConfig({ plugins: [react(), splithub()] }); ``` On start and on every save of the config it prints each experiment and what it does in development, then forwards the SDK's notes from the browser. Without the plugin the notes stay in the browser console. Production builds print nothing. With `?sh_debug=1` the debug panel shows such assignments with the reason `dev`; its "new visitor" button lets you walk through every variant. `npx splithub init` turns the option on for new projects. ## Goals | Type | Fields | Fires when | |---|---|---| | `event` | `revenue?: boolean` | your code calls `track('', props?)`; with `revenue: true` the `props.revenue` number is summed per variant | | `pageview` | `path` | a page with that path is viewed (exact match, or prefix with a trailing `*`) | | `click` | `selector` | an element matching the CSS selector is clicked (the SDK and the drop-in script bind it) | Add `description` to any goal; the dashboard shows it. Goals created in the dashboard reach cloud clients. Add their definitions to the local file manually; `pull` adopts experiments, not goals. ## Experiments Keys are lowercase `a-z 0-9 - _`, 1–64 characters. The **first variant is the control**. | Field | Default | Meaning | |---|---|---| | `kind` | required | `value`, `component` or `redirect` | | `status` | required | `draft`, `running`, `paused`, `finished` | | `variants` | required | see below | | `goal` | none | the goal the verdict is computed on; must exist in `goals` | | `traffic` | `1` | share of visitors (0–1) entering the experiment; others see the control without an exposure | | `seed` | the key | hash seed; never change it on a running experiment | | `description` | none | shown in the dashboard and in emails | | `match` | required for `redirect` | path the redirect applies to (exact, or prefix with trailing `*`) | | `maxExposures` | none | the cloud finishes the experiment when stored exposure events reach this number | | `schedule` | none | `{ startAt?, endAt? }` ISO dates; outside the window the control is served and nothing is recorded | | `flow` | none | targeting graph, see [Targeting flows](/docs/targeting) | ### Variants by kind ```ts // component: weights only, JSX lives in the app variants: { control: 1, bold: 1 } // value: weight + payload (string, number, boolean, object, array) variants: { control: { weight: 50, value: 'Old' }, bold: { weight: 50, value: 'New' } } // redirect: weight + destination variants: { control: { weight: 50, to: '/' }, v2: { weight: 50, to: '/home-v2' } } ``` Weights are relative: `{ a: 1, b: 1 }` and `{ a: 50, b: 50 }` are the same split; `{ a: 90, b: 10 }` is a 90/10 split. ## What the CLI changes `create` adds an experiment, `start`/`pause`/`finish` change `status`, `pull` adds missing dashboard-created experiments (including flows), but not goal definitions, `init` writes the file the first time. Edits keep your formatting where possible; the file stays a normal TypeScript module you can edit by hand. ## Rules - Never change `seed`, `variants` or weights of a running experiment; create a new one. - Keep one `id` for things that must change together, separate ids for independent questions. - On low traffic run at most 2–3 experiments per page. - Keys that exist in the file are owned by the file: the dashboard cannot edit them (it can pause or finish them). - In cloud mode a status set in the dashboard wins over the file's `status`, from a visitor's first page, until you clear it. The dashboard shows both when they differ. --- # React `@splithub/react` renders experiments in any React 18+ app (Vite, CRA, Remix, Astro islands). Next.js App Router has its own package, see [Next.js](/docs/nextjs). ```bash npm i @splithub/react @splithub/core ``` ## Provider ```tsx import { SplitHubProvider } from '@splithub/react'; import config from '../splithub.config'; createRoot(document.getElementById('root')!).render( ); ``` The provider creates the client synchronously, reads or sets the `sh_vid` cookie, applies cached live overrides (cloud mode) and starts the event queue. Props: `config`, `visitorId?` (SSR: pass the id from the cookie so server and client agree), `overrides?`, `client?`, `pageviews?`, `context?`, `remote?`, `autoGoals?`, `devtools?`. ## Component experiments ```tsx import { Experiment, Variant } from '@splithub/react'; ``` One `` renders by assignment; the exposure is recorded on mount. Unknown or not-running experiments render the control. ## Value experiments ```tsx const title = useValue('hero-title'); const price = useValue('price-anchor'); const layout = useValue<{ columns: number; dense: boolean }>('grid'); ``` `useValue` returns the control's value when the experiment is not running, so the page never breaks when a test is paused. ## Hooks | Hook | Returns | |---|---| | `useVariant(id)` | the variant key (`'control'`, `'bold'`…); records an exposure the first time | | `useValue(id)` | the payload of a `value` experiment | | `useTrack()` | `(goal, props?) => void` | | `usePageview(path)` | fires `pageview` goals on client-side navigation | | `useSplitHub()` | the underlying `@splithub/core` client | `useVariant` is for the same experiment in several places: the visitor gets one variant everywhere. ## Goals ```tsx const track = useTrack(); return ( <> ); ``` Pageview goals fire on mount for the current path; in SPAs call `usePageview(location.pathname)` once in the layout. Click goals are bound automatically from the config's selectors. ## QA Append `?sh_force=hero-cta:bold` to any URL to see a variant, running or draft; no exposure is recorded. Keep the parameter in the URL while previewing; it is not saved across navigation. Several at once: `?sh_force=hero-cta:bold,hero-title:outcome`. To run drafts while you develop without forcing anything, set `runDraftsInDev: true` in the config (see [Drafts in development](/docs/config#drafts-in-development)). Add `?sh_debug=1`, or run `npx splithub dev`, to log every assignment with its reason and get a panel that switches variants. In Vite apps, `splithub()` from `@splithub/core/vite` prints what each experiment does in your dev terminal (see [Drafts in development](/docs/config#drafts-in-development)). ## SSR Any framework that renders on the server: read the `sh_vid` cookie, pass it as `visitorId` to the provider on both sides, set the cookie on the response when missing. `@splithub/core` exports `newVisitorId()` and `assign()` for server-side decisions. ## Rules - Never nest an `` inside a `` of the same experiment. - Hooks follow the rules of hooks: no conditional calls. - Keep the config import at the top of the app; the provider must be above every component that uses a hook. --- # Next.js `@splithub/next` covers the App Router (Next.js 14+): a middleware for the visitor cookie and redirect experiments, helpers for Server Components, and a client provider that agrees with the server on the first byte. `npx splithub init` creates the middleware and prints a provider snippet; add the layout integration below. ```bash npm i @splithub/next @splithub/react @splithub/core ``` ## Middleware ```ts // middleware.ts import { splithubMiddleware } from '@splithub/next'; import splitHubConfig from './splithub.config'; export default splithubMiddleware(splitHubConfig); export const config = { matcher: ['/((?!_next|api|.*\\..*).*)'] }; ``` It sets the `sh_vid` cookie, passes the id to Server Components in a header, rewrites the request for `redirect` experiments (the URL in the address bar does not change, the visitor sees no flash) and records edge exposures. In cloud mode it fetches live overrides at most once per 60 s per isolate; opt out with `splithubMiddleware(splitHubConfig, { remote: false })`. ## Layout ```tsx // app/layout.tsx (Server Component) import { SplitHubNextProvider } from '@splithub/next/client'; import { getSplitHub } from '@splithub/next/server'; import config from '../splithub.config'; export default async function RootLayout({ children }) { const sh = await getSplitHub(config); return ( {children} ); } ``` ## Client Components Same API as `@splithub/react`, imported from `@splithub/next/client`: `Experiment`, `Variant`, `useVariant`, `useValue`, `useTrack`, plus `Exposure`. ## Server Components ```tsx import { getSplitHub } from '@splithub/next/server'; import { Exposure } from '@splithub/next/client'; import config from '../../splithub.config'; export default async function Page() { const sh = await getSplitHub(config); const title = sh.value('hero-title'); return ( <>

{title}

); } ``` `getSplitHub(config)` gives `.variant(id)` and `.value(id)` computed from the cookie the middleware set. `` records the exposure once on the client, so a server-rendered variant counts exactly like a client-rendered one. ## Self-hosted ingest route Install `@splithub/node` and `@splithub/adapter-sqlite` for this example. SQLite requires Node.js 22.13 or newer. ```ts // app/api/splithub/[[...path]]/route.ts import { createSplitHubHandler } from '@splithub/node'; import { sqliteStore } from '@splithub/adapter-sqlite'; import config from '../../../../splithub.config'; const handler = createSplitHubHandler({ config, store: sqliteStore(), adminToken: process.env.SPLITHUB_ADMIN_TOKEN }); export const POST = handler; export const GET = handler; export const runtime = 'nodejs'; ``` In cloud mode this file is not needed. ## Redirect experiments Declare `kind: 'redirect'` with `match` and a `to` per variant; no code. The middleware rewrites `/` to `/start` for the `new-landing` variant and records the exposure at the edge. Details and the drop-in alternative: [Redirect experiments](/docs/redirect-experiments). ## Why there is no flicker The middleware decides the visitor id before anything renders. Server Components compute the same deterministic assignment as the client, and the client provider receives that id and the same overrides as props. Both sides agree on the first byte, so hydration never swaps a variant. --- # Drop-in script For sites without a build step (static HTML, Webflow, Framer, Shopify themes, WordPress, landing-page builders) SplitHub Cloud serves a small script that runs redirect experiments and tracks goals from the hosted config. ```html ``` Put it in ``, as early as possible. The public key is safe to publish: it can only send events and read the public config. ## What it does on every page 1. Reads the hosted config and live overrides (cached in `localStorage`, refreshed on load when older than 60 s). 2. For `redirect` experiments whose `match` fits the current path: assigns the visitor with the same hash as the SDK (targeting flows included) and, when the variant points elsewhere, navigates to that destination. The rendering hold below reduces flashes. 3. Records the exposure, fires `pageview` goals, binds `click` goals from their selectors, and exposes `window.splithub.track()`. ```html ``` ## Hold rendering during the decision On a page that is the subject of a redirect experiment, name it so the script holds rendering until the decision is made, even on the first visit with an empty cache: ```html ``` The script also holds rendering when there is no cached config or a cached redirect matches the page. The meta tag explicitly requests a decision for this page. The hold lasts up to 1,500 ms by default; a timeout can reveal the original page. Put the meta tag before the script. ## What it cannot do `component` and `value` experiments need the SDK in the app: the script does not change your markup. Use the script for whole-page tests and goal tracking, the SDK for everything inside the page. ## Options | Attribute | Default | Meaning | |---|---|---| | `data-key` | required | the project's public key | | `data-endpoint` | script origin + `/v1/events` | full events URL for a self-hosted cloud or staging | | `data-hold` | `1500` | maximum rendering hold in ms; `off` disables the hold | Use `?sh_debug=1` in the page URL for console diagnostics. ## Where experiments come from Create redirect experiments in the dashboard (New experiment → Redirect) or in `splithub.config.ts` and `push`. Targeting flows (country, device, UTM…) created in the dashboard are evaluated by the script with the visitor's geo from the edge. --- # Redirect experiments A redirect experiment tests whole pages: visitors who match a path are sent to a different URL per variant. No component code is involved, so it works for a redesign, a new landing page, a different checkout flow, or two pages built in a no-code tool. ```ts 'landing': { kind: 'redirect', status: 'running', goal: 'signup', match: '/', variants: { control: { weight: 50, to: '/' }, 'new-landing': { weight: 50, to: '/start' }, }, } ``` `match` is a path, exact or a prefix with a trailing `*` (`/pricing*`). `to` is the destination per variant; the control usually points at the matched page itself. Use same-site paths for Next.js middleware rewrites. The drop-in script and short links can navigate to absolute URLs on another host. ## Three ways to serve it | Where | How | Flash | |---|---|---| | Next.js | `splithubMiddleware` rewrites the request at the edge; the address bar keeps the original URL | none | | Drop-in script | `sh.js` assigns and navigates; put `` before the script | rendering held for up to 1,500 ms by default | | Short links | a SplitHub link with two weighted destinations (no config needed) | a 302, like any short link | A draft redirect is served in development when the config has `runDraftsInDev: true`, by the middleware and by the script alike. `?sh_force=landing:new-landing` previews one destination of a running experiment. ## Creating one From the CLI: `npx splithub create --kind redirect --id landing --variants control,new-landing --match / --to control=/ --to new-landing=/start --goal signup --yes`. From the dashboard: New experiment → Redirect. It asks for the page to test and the variant pages, and offers "create with a link" for the short-link variant. Experiments created there are cloud-only until `npx splithub pull` writes them into the file. ## Goals on the destination pages The goal has to fire on the page the visitor lands on. A `pageview` goal with `path: '/thanks'` works everywhere. With the script, `click` goals bind on any page; `event` goals need `splithub.track('…')` on the destination. The visitor id is in the first-party cookie, so attribution crosses pages on the same site. Cookies are not shared across unrelated domains, and the SDK does not import visitor ids from `?sh_vid=`. Cross-domain conversion attribution needs a custom identity integration; short-link click stats work independently. ## Pitfalls - Caching: a CDN that caches the HTML of `/` for everyone cannot serve per-visitor redirects. The middleware route and the script both run per visitor; make sure the matched page is not fully static-cached, or use the script. - Loops: do not match the destination path of a variant with the same experiment (`match: '/start*'` while `to: '/start'`). - Crawlers: assignment does not automatically force the control for bots. Configure crawler handling separately if a stable crawlable page is required. --- # Targeting flows Who enters an experiment, and with which split, can depend on country, device, browser, UTM parameters, cookies, time, 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`. 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`. 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`: ```ts '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. ## Where it runs Everywhere the assignment runs, with the same answer: - the React SDK (browser context: device, browser, path, query, cookies, time; country from the cloud's `ctx` when in cloud mode); - the Next.js middleware (request headers and Cloudflare/Vercel geo); - the drop-in script; - 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' }`. ## 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. Remember the statistics: targeting narrows the sample. The verdict is about the visitors who entered, not about everyone. --- # Goals and revenue A goal is the metric an experiment is judged on. Each experiment names one goal; the verdict is computed on it. Other declared goals can still be tracked, but the experiment verdict uses its selected goal. ## Kinds ```ts goals: { signup: { type: 'event' }, purchase: { type: 'event', revenue: true }, pricing_view: { type: 'pageview', path: '/pricing' }, docs_any: { type: 'pageview', path: '/docs/*' }, cta_click: { type: 'click', selector: 'a[href="/start"]' }, } ``` | Type | Fires when | |---|---| | `event` | your code calls `track('signup')` (React `useTrack`, `client.track`, `window.splithub.track`) | | `pageview` | a page with that path is viewed; `*` suffix matches a prefix | | `click` | an element matching the selector is clicked; bound automatically by the SDK and the script | Goals can also be created in the dashboard (Settings → Goals, or "+ New goal" when creating an experiment) and are delivered to cloud clients. Add them to the local config yourself: `pull` does not write goal definitions. A goal in use cannot be deleted. ## Revenue ```tsx track('purchase', { revenue: 49 }); ``` With `revenue: true` on the goal, results include revenue per variant in `verdict.sample.variants`, available through the API or `results --json`. Revenue per visitor is revenue divided by exposures. The verdict stays on the conversion rate; revenue is a guardrail (a variant that converts more but earns less is not a win). ## Attribution A visitor converts for a variant when they sent the goal **at or after their first exposure** to that variant. Units are unique visitors: ten purchases by one visitor are one conversion (and the summed revenue). A goal sent before any exposure counts for nothing, which is the right behavior for "did the change cause it". Only declared goals are tracked. In cloud mode the project must have a running experiment and available quota for ingest to accept events. Earlier goals are not retroactively attributed to a later exposure. ## Props `track(goal, props)` accepts a small JSON object (up to 4,096 characters when JSON-serialized). `revenue` is the only key with built-in meaning; the rest is stored with the event and shown in the raw-events view and the CSV export. ## Pageviews in SPAs Client-side navigation does not reload the page, so call `usePageview(pathname)` (React) in the layout, or `client.pageview(path)` with `@splithub/core`. In Next.js, pass `usePathname()` to `usePageview` in a client component; the provider only tracks the initial pageview. ## Good goals - One primary goal per experiment, decided before the test starts. - As close to the change as makes sense: a headline test on the home page is judged on "clicked the primary button" or "signed up", not on "paid 30 days later". - Counts of unique visitors, not of hits. Repeated clicks by one visitor do not make a variant better. --- # Statistics and verdicts `@splithub/stats` is a zero-dependency package that turns per-variant counts into a verdict. The same code runs in the CLI, in the self-hosted handler, in the cloud, and in the emails, so every surface quotes the same numbers. ## What is computed For each variant: unique exposed visitors, converted visitors, rate, revenue (when the goal has it). For each non-control variant against the control: | Field | Meaning | |---|---| | `absoluteLift` | rate − control rate, as a fraction; multiply by 100 for percentage points | | `relativeLift` | (rate − control rate) / control rate | | `zScore`, `pValue` | two-proportion z-test, two-sided | | `significant` | p < α after Bonferroni correction for the number of comparisons | | `ci` | 95% confidence interval of the absolute difference | | `probabilityToBeat` | probability that the variant's true rate is above the control's (normal approximation) | Plus the sample-ratio-mismatch check: a chi-square test of the observed split against the configured weights, flagged when p < 0.001. ## The verdict | `status` | When | What to do | |---|---|---| | `no_data` | no exposures yet | wait, or check the setup with `splithub doctor` | | `insufficient_sample` | fewer than 100 visitors in a variant or fewer than 30 conversions in total (defaults) | keep running; the message estimates the total visitors needed per variant | | `srm_detected` | the split does not match the weights | the setup is broken (bots, caching, a variant that errors, a redirect loop); fix it, restart as a new test | | `winner_found` | a variant beats the control, or the control beats a significantly worse variant | read `winner` and `message`, then finish the test | | `no_significant_difference` | enough sample, no significant difference | ship the simpler variant, finish | `message` is one sentence written for a person or an agent to act on, for example: *Stop: "bold" beats "control" (+18.3%, p = 0.012). Ship "bold".* Agents are told in the skills to quote it verbatim and never to call a winner from raw rates. ## Sample size Rule of thumb for 95% confidence and 80% power: visitors per variant ≈ `16 · p(1−p) / (p·L)²` with baseline rate `p` and relative lift `L`. Baseline 5% and lift 20% → about 7,600 per variant; baseline 2% and lift 10% → about 78,000. The `insufficient_sample` message uses the observed rates to estimate a total per variant, not the number still missing. ## Peeking Checking every day and stopping at the first significant moment inflates false positives several times over. Decide the sample or the duration before starting, run whole weeks, and treat an early "winner" with a confidence interval touching zero as weak. The cloud's AI reviewer and the alert rules apply the same caution: the `winner` rule waits for the verdict's own minimum sample. ## Several variants With three or more variants every comparison is against the control and the significance threshold is divided by the number of comparisons (Bonferroni). Pairwise comparisons between non-control variants are not reported; create a new test with the two finalists if you need that. ## Segments Breakdowns by country, device, browser and host are for hypotheses, not verdicts: slicing after the fact finds noise. If a segment looks interesting, run a targeted experiment on it. ## Using the package directly ```ts import { computeVerdict } from '@splithub/stats'; const r = computeVerdict('hero-title', [ { variant: 'control', exposures: 4107, conversions: 251 }, { variant: 'bold', exposures: 4248, conversions: 301 }, ], { expectedWeights: [50, 50] }); r.status; r.message; r.comparisons[0].pValue; ``` --- # CLI reference The `splithub` package is the command line for people (prompts, menus) and for agents (flags, `--json`). Run it with `npx splithub` or install it as a dev dependency. ```bash npx splithub # interactive menu npx splithub --help ``` Run commands from the app directory. `--json` requests machine-readable output; errors are written to stderr with exit code 1. ## Commands | Command | Does | |---|---| | `init` | Writes `splithub.config.ts`, Next.js middleware and a self-hosted route where applicable, agent skills and MCP entries. Prints the provider snippet for you to add. `--mode cloud` signs in through a link; `--mode self` adds the ingest route and a store. | | `create` | Adds an experiment to the config. Wizard without flags; `--kind value|component|redirect --id --variants a,b [--weights] [--goal] [--value k=v] [--match] [--to k=/path] [--status] [--traffic 50] [--max-visitors] [--start] [--end] [--description] --yes`. Scaffolds variant components for `component`. | | `start ` / `pause ` / `finish ` | Changes `status` in the file and attempts the cloud override. Check the reported `live` result; the experiment must already be pushed and credentials available. | | `status` | Every experiment with status, visitors, days and the verdict headline. | | `results ` | Full verdict: sample, comparisons, CI, P(beat), SRM. | | `review []` | AI reviews stored in the cloud (Pro): at start, on verdict changes or a weekly check, on finish, or on demand. | | `push` | Uploads the normalized config to the cloud. The first push returns the public key and writes it into `storage.key`. Adopts missing dashboard-created experiments into the file. | | `pull` | Adds missing dashboard-created experiments, including their flows, to the config file. Add any referenced goals yourself; existing entries are not overwritten. | | `login` / `logout` / `whoami` | Connects a folder to a cloud project through a browser approval; shows the project and its public key. | | `projects` | Lists local directories connected to cloud projects. | | `dev` | Opens the app with `?sh_debug=1` and watches results. | | `doctor` | Checks config, provider, middleware, route, code references, endpoint reachability, key. | | `skills [--mcp]` | (Re)installs the agent skills; `--mcp` also writes `.mcp.json` and `.cursor/mcp.json`. | ## init in detail ```bash npx splithub init --mode cloud --yes # link sign-in, then writes everything npx splithub init --mode cloud --key pk_live_… --yes # public key known; add --no-login to skip browser sign-in npx splithub init --mode self --db sqlite # also: postgres | mysql | custom npx splithub init --no-install --no-login # files only ``` The public key ends up in `storage.key` from the first of: `--key`, the saved credentials of `login`, `SPLITHUB_API_KEY` in the environment (init asks the cloud which project it belongs to), or the first `push`. `init` prints where the key came from. ## Where results come from | `storage.mode` | `status` / `results` read from | Auth | |---|---|---| | `self` | your app's handler on the local dev server (`localhost:3000` for Next.js, `5173` for Vite) or `--url` / `SPLITHUB_URL` | `--token` / `SPLITHUB_ADMIN_TOKEN` when the handler has `adminToken` | | `cloud` | `https://go.splithub.io/v1` (or `--api` / `SPLITHUB_API`) | the key saved by `login`, `SPLITHUB_API_KEY`, or `--token sk_…` | ## Credentials `login` prints a code and a URL. You approve it in the dashboard (and can create the project right there); the dashboard mints a project API key for that terminal and the CLI stores it per directory in `~/.config/splithub/credentials.json` (`%APPDATA%\splithub` on Windows). Nobody pastes a secret into a chat. CI and headless agents use `SPLITHUB_API_KEY=sk_…` from the project's Settings → API keys. `pk_…` is public: it sends events and reads client config. `sk_…` reads results and pushes config: keep it out of git and out of the browser. ## Exit codes and JSON `0` success, `1` error (message on stderr, including with `--json`). `status --json` returns `{ project, experiments: [{ experiment, kind, status, goal, description, exposures, visitors, days, verdict, headline, best }] }`; `results --json` returns the full `ExperimentResults` object described in [Statistics](/docs/statistics). --- # MCP server `@splithub/mcp` is a stdio Model Context Protocol server that exposes the `splithub` CLI as tools. Claude Code, Cursor, Codex, Windsurf or any MCP client can create experiments, read verdicts, start, pause, finish and push, using the same config file and credentials as the CLI. ## Register it `npx splithub init` and `npx splithub skills --mcp` write the entries. By hand: ```json // .mcp.json (Claude Code, committed) · .cursor/mcp.json (Cursor) { "mcpServers": { "splithub": { "command": "npx", "args": ["-y", "@splithub/mcp"] } } } ``` ```bash claude mcp add splithub -- npx -y @splithub/mcp ``` Other clients: command `npx`, args `-y @splithub/mcp`. Environment: `SPLITHUB_PROJECT_DIR` (defaults to the client's working directory), `SPLITHUB_API` (staging or a self-hosted cloud), `SPLITHUB_API_KEY` (CI), `SPLITHUB_CLI` (path to a specific CLI build). ## Tools | Tool | Does | Notes | |---|---|---| | `splithub_status` | every experiment with status, visitors, days, verdict headline | start here | | `splithub_results` | full verdict for one experiment | the agent should report `verdict.message` verbatim | | `splithub_review` | AI reviews stored in the cloud (Pro): at start, on verdict changes or a weekly check, on finish, on demand | an analyst's opinion; the verdict stays the source of truth | | `splithub_create_experiment` | adds an experiment to `splithub.config.ts` | kind, id, variants, weights, goal, values, match/to, status, description | | `splithub_set_status` | `running` / `paused` / `finished` | live in cloud mode | | `splithub_push` / `splithub_pull` | sync the config with the cloud | pull adopts dashboard-created experiments | | `splithub_doctor` | checks the installation | run after wiring code | | `splithub_login` | returns a sign-in link and a code | the agent shows it to the user and waits | | `splithub_whoami` | connected project and public key | | | `splithub_init` | sets up SplitHub in the project | once per project | Every tool returns the CLI's `--json` output as text. Errors come back with `isError` and, when the folder is not connected, a hint to call `splithub_login`. ## A typical session 1. User: "A/B test the pricing page headline." 2. Agent calls `splithub_status` (is anything running on that page?), then `splithub_create_experiment` with `kind: value`, two variants, the `signup` goal. 3. Agent edits the component to read `useValue('pricing-title')`, calls `splithub_doctor`. 4. Agent asks the user to confirm, then `splithub_set_status running` and `splithub_push`. 5. Days later: `splithub_results` → "keep running; the estimate is 3,200 visitors per variant in total" or "winner found". The agent ships the winner and calls `splithub_set_status finished`. The [agent skills](/docs/agent-skills) tell the agent these rules; the MCP tools are what it executes them with. --- # Agent skills `npx splithub init` (and `npx splithub skills`) installs six skills into the project. A skill is a Markdown file with a front-matter description that a coding agent loads when the task matches. They go to `.claude/skills//SKILL.md` for Claude Code, to `.cursor/rules/` for Cursor, and a short section in `AGENTS.md` (or an existing `CLAUDE.md`) points Codex and other agents at them. | Skill | Loaded when | Teaches | |---|---|---| | `splithub` | the user asks to A/B test something, or asks which variant wins | where things live (config, credentials, results), how to create each kind of experiment, how to read a verdict, what never to do | | `ab-testing` | before creating a test or interpreting results | hypothesis and primary metric, sample size rule of thumb, SRM, peeking, when to stop, vocabulary | | `splithub-api` | writing a server, a script or a non-JS client | the HTTP API: public-key ingest, API-key endpoints, overrides JSON, errors | | `splithub-mcp` | connecting SplitHub to Claude Code, Cursor or Codex | how to register the MCP server, the tools, working rules | | `splithub-self-hosted` | the user wants data in their infra, asks about tables or reports | the events table, the Store interface, Postgres/MySQL/Prisma/Drizzle/Supabase notes, attribution SQL, pulling cloud summaries into a warehouse | | `splithub-ship` | a test has a verdict and the user says ship it, roll out the winner or remove the test | finish first, then keep only the winning value, component or page, delete the losers, clean the config, verify, never reuse the id | ## What the main skill says, in short - `splithub.config.ts` is the source of truth; it ships to the browser, so no secrets. - Credentials come from `npx splithub login` (a link the human approves). Never ask the user to paste keys. - Prefer `value` experiments (data only) over `component` ones; `redirect` for whole pages. - Create the test (running by default), wire the code, run `doctor` and `push`. Then always tell the user it goes live when they deploy, and that Pause in the dashboard or `npx splithub pause ` holds it. - To check a draft before it starts: `runDraftsInDev: true` in the config (development only), `?sh_force=:` on any page, `?sh_debug=1` for the decisions. - Report `verdict.message` verbatim with the numbers behind it. `srm_detected` means fix the setup first. - Never change `seed`, weights or variants of a running experiment; never nest experiments; keep 2–3 tests per page at most. ## Reading the full text The skills are plain files in your project after `init`. They are also what the dashboard's "Copy for your agent" prompt refers to, and this documentation covers the same ground page by page. For a single file with everything: [llms-full.txt](/llms-full.txt). ## Updating `npx splithub skills` rewrites the skill files from the installed CLI version. They are safe to commit; agents on the team get the same rules. --- # HTTP API Cloud base URL: `https://go.splithub.io`. Requests and responses use JSON. The [self-hosted handler](/docs/self-hosted) has its own mount path and admin-token authentication. Cloud integrations use two credentials: - **Public key `pk_…`**: identifies the project to browsers and SDKs. Safe to ship. Can only send events and read the public overrides JSON. - **API key `sk_…`**: project-scoped secret for servers, CI and the CLI. Minted by `npx splithub login` through a browser approval, or in the dashboard under Settings → API keys. Never in client code. ## Send events (public key) ```http POST /v1/events Authorization: Bearer pk_live_… Content-Type: application/json { "project": "my-app", "sdk": "custom/1.0", "events": [ { "id": "e_example_01", "type": "exposure", "exp": "hero-title", "var": "bold", "vid": "v_abc", "sid": "s_1", "ts": 1760000000000, "url": "/" }, { "id": "g_example_01", "type": "goal", "goal": "signup", "vid": "v_abc", "sid": "s_1", "ts": 1760000010000, "props": { "revenue": 49 } } ] } ``` - `id`: unique per event, 8–64 characters of `[A-Za-z0-9_-]`; stored rows are deduplicated by id. Reuse ids when retrying a failed request; do not replay successful batches. - `vid`: stable visitor id (the `sh_vid` cookie). `sid`: session id. `ts`: client time in ms; values more than 7 days off are replaced by server time. - Up to 500 events per batch. Goal props may contain up to 4,096 characters of serialized JSON. - Reply `202 { ok, received, inserted, dropped }`. - A project with no running experiment or no config yet replies `202 { ok, inserted: 0, idle: 'no_running' | 'no_config' }`: nothing is stored, and it is not an error. - `429` with `code` ∈ `quota_exceeded` | `deleted` and a `Retry-After` header: back off; nothing was stored. `403 origin_not_allowed` when the project restricts domains. - Exposures of experiments that are not running are dropped and not billed. Goals are never billed. ## Live config for clients (public key) ```http GET /v1/overrides/pk_live_….json ``` ```json { "version": 12, "experiments": { "hero-title": { "status": "paused" }, "pricing-cta": { "traffic": 0.5, "weights": { "control": 1, "b": 3 } } }, "added": [], "goals": [ { "key": "signup", "type": "event", "path": null, "revenue": false, "description": null } ], "ctx": { "country": "DE", "region": "BE", "city": "Berlin" } } ``` The browser SDK caches it in `localStorage` for 60 seconds and refreshes stale data when a client is created; with nothing cached (a first visit) it waits for this file up to `remote.waitMs` (default 400 ms) before deciding. `ctx` is the visitor's geo for targeting flows. ## Project endpoints (API key) ```http GET /v1/status every experiment: status, visitors, days, verdict headline GET /v1/results/ full verdict (see Statistics) GET /v1/reviews[/] AI reviews, newest first (Pro) GET /v1/config the config stored in the cloud (file + dashboard experiments) PUT /v1/config { config } push a normalized config (what defineConfig returns) GET /v1/overrides live overrides PUT /v1/overrides/ { status | traffic | weights | values } DELETE /v1/overrides/ ``` `Authorization: Bearer sk_live_…` on each. Status values: `draft` | `running` | `paused` | `finished`. A `PUT /v1/overrides/` that starts or finishes an experiment triggers the start/finish emails to the project's members. ## Health `GET /v1/health` needs no key. It returns `{ ok, service, version, schema, problems }`; status `503` means the schema check failed. Use the API-key endpoints above for integrations. Session-based dashboard routes are internal and are not an integration contract. ## Drop-in script `GET /sh.js` serves the [drop-in script](/docs/script). Public [short links](/docs/links) use `GET https://sh2.uk/` and return `302`. The API-host route `GET /l/` remains supported for compatibility; copy the short URL shown in the dashboard when sharing links. ## Errors | Code | Meaning | |---|---| | 400 | validation; `error` has the message | | 401 | missing or bad key | | 403 | forbidden (role, origin) | | 404 | unknown experiment or project | | 409 | config has not been pushed (`config_not_pushed`) | | 429 | gate or rate limit; see `code` and `Retry-After` | Bodies are `{ error, code? }`. ## Ingest limits Send at most 500 events per batch. Honor `Retry-After` on `429`. A reply with `idle` means the project records nothing right now; sending more changes nothing until a test starts. --- # Self-hosted (your database) In self mode the app hosts the ingest route and the store; nothing leaves your infrastructure. The SDK, the CLI, the statistics and the MCP server are the same as in the cloud. It is free and has no limits. ```ts // splithub.config.ts storage: { mode: 'self', endpoint: '/api/splithub' } ``` ## The handler `@splithub/node` exports one fetch-style handler that works in Next.js route handlers, Remix, Hono, Bun, Deno and plain Node (`toNodeListener` for Express-style servers). ```ts import { createSplitHubHandler, toNodeListener } from '@splithub/node'; import { sqliteStore } from '@splithub/adapter-sqlite'; import config from './splithub.config'; const handler = createSplitHubHandler({ config, store: sqliteStore({ file: './splithub.db' }), adminToken: process.env.SPLITHUB_ADMIN_TOKEN, // protects status and results; health remains public }); // Next.js app router: export const POST = handler; export const GET = handler; // Express / Vite / http: app.use('/api/splithub', toNodeListener(handler)); ``` Routes relative to the mount path: | Method | Path | Purpose | |---|---|---| | POST | `/` | ingest a batch from the SDK | | GET | `/status` | every experiment with counts and a verdict headline | | GET | `/results/:id` | the full verdict | | GET | `/health` | liveness | The handler accepts up to 500 events per batch and 4,096 characters of serialized goal props. Set `adminToken` in production; without it, results and status are public. The CLI reads `status` and `results` from the local dev server (`localhost:3000` for Next.js, `5173` for Vite) or from `--url https://your.app/api/splithub`, with `--token` / `SPLITHUB_ADMIN_TOKEN`. ## Stores | Store | Package | Notes | |---|---|---| | SQLite | `@splithub/adapter-sqlite` | `node:sqlite`, Node 22.13+, zero setup; fine for one server | | Cloudflare D1 | `@splithub/adapter-d1` | for Workers; `d1Schema()` gives the DDL | | Postgres | generated | `npx splithub init --mode self --db postgres` writes `splithub/store.ts` using `pg` | | MySQL | generated | `--db mysql`, uses `mysql2` | | anything else | `--db custom` | a documented stub with the three methods to fill in (Prisma, Drizzle, Supabase, Mongo…) | ## The Store interface ```ts interface Store { insertEvents(events: StoredEvent[]): Promise; // skip existing ids, return inserted count variantCounts(experiment: string, goal: string): Promise; // per variant: exposures, conversions, revenue summaries(): Promise; // per experiment: exposures, visitors, first/last ts close?(): Promise | void; } ``` Attribution rule, keep it exactly so self-hosted results equal the cloud's: **a visitor converts for a variant when they sent the goal at or after their first exposure to that variant; exposures and conversions count unique visitors.** `createMemoryStore()` is the reference implementation. ## The events table One table, one row per event. Column names are yours; the generated stores use these: | column | type | meaning | |---|---|---| | `id` | text, primary key | client-generated, unique; dedupe on it | | `type` | text | `exposure` or `goal` | | `experiment`, `variant` | text | for exposures | | `goal` | text | for goals | | `visitor_id`, `session_id` | text | the `sh_vid` cookie / the session | | `ts` | bigint | client time, unix ms | | `url` | text | page | | `props` | json | goal payload, e.g. `{ "revenue": 49 }` | | `received_at` | bigint | server time, unix ms | | `host`, `country`, `device`, `browser` | text | optional request metadata for breakdowns | Indexes: `(type, experiment, visitor_id, ts)` and `(type, goal, visitor_id, ts)`. ## Attribution in SQL ```sql WITH first_exposure AS ( SELECT visitor_id, variant, MIN(ts) AS first_ts FROM splithub_events WHERE type = 'exposure' AND experiment = $1 GROUP BY visitor_id, variant ) SELECT fe.variant, COUNT(DISTINCT fe.visitor_id) AS exposures, COUNT(DISTINCT g.visitor_id) AS conversions, COALESCE(SUM((g.props->>'revenue')::numeric), 0) AS revenue FROM first_exposure fe LEFT JOIN splithub_events g ON g.type = 'goal' AND g.goal = $2 AND g.visitor_id = fe.visitor_id AND g.ts >= fe.first_ts GROUP BY fe.variant; ``` Prisma or Drizzle: map the table in your schema and run this one raw query for `variantCounts`. Supabase: the same SQL as a Postgres function called through `rpc`. Mongo: `$group` by variant and visitor with `$min ts`, then `$lookup` goals. ## Reports into your warehouse (cloud mode) If you use the cloud but want data in your own database: CSV export per experiment (summary, daily timeline, raw events up to 50k rows), a webhook channel for alerts and the digest, or a cron that calls `GET /v1/status` and `GET /v1/results/` with the API key and stores the verdicts. Raw events live 30–90 days in the cloud, aggregates a year: pull summaries, not raw rows, for long-term reporting. ## Do not - Put database credentials in `splithub.config.ts`; it ships to the browser. They belong in the route's environment. - Compute winners from raw rates in SQL; use `npx splithub results` or `@splithub/stats` for the verdict. --- # SplitHub Cloud The hosted side: a dashboard at `app.splithub.io`, the ingest and API at `go.splithub.io`. It runs on Cloudflare's edge, so events are accepted close to the visitor and verdicts are computed from one database. ## Projects and keys A project is a workspace: one app, its experiments, its members, its billing. Every project has a **public key** (`pk_…`, in the config and the browser, sends events) and any number of **API keys** (`sk_…`, for the CLI, CI and servers). Members join by invitation email with viewer, editor or admin roles; there is no per-seat price. ## Connecting an app ```bash npx splithub init --mode cloud # or: npx splithub login in an existing project npx splithub push ``` `login` shows a code and a link; approving it in the dashboard (where you can also create the project) mints an API key for that folder. `push` uploads the normalized config; the first push writes the public key into the file. No manual key paste is needed; the CLI stores the secret locally. ## The dashboard - **Experiments**: status, visitors, days, verdict per experiment; start, pause, finish; a quick-start with the one command and a "copy for your agent" prompt. - **Experiment page**: results, timeline (daily visitors and conversions, cumulative rate), breakdowns by country, device, browser and host, realtime (last 30 minutes), raw events, notes, CSV export, the AI tab, targeting. - **New experiment**: Redirect, Component or Value, with weights, traffic share, visitor cap, schedule, notes, and the CLI command that creates the same thing in the file. - **Links**: short links with weighted and targeted destinations and QR codes. - **Alerts**: rules, channels and the digest (Pro). - **Setup**: the key, the install command, the drop-in script, the MCP entry. - **Domains**: restrict which origins may send events. - **Team**, **Billing**, **Settings** (goals, API keys, project). ## Live overrides Pause, resume, finish, change `traffic`, reweight, or swap a `value` payload from the dashboard or with `PUT /v1/overrides/`. Overrides sit on top of the config without a deploy. The browser SDK keeps them in a 60-second cache: later pages apply the cached copy at once and refresh it in the background. A visitor's first page has no cache, so the SDK waits for the overrides (about 150 ms, at most `remote.waitMs`, default 400) and renders control meanwhile; then each test is decided once, and the file decides if they are late. Next.js reads them on the server, middleware refreshes its cache on requests, and the script fetches them on page load. A variant never changes once a page has shown it. Experiments the file owns are read-only in the dashboard except for status changes; `pull` brings dashboard-created ones into the file. ## The gate Before touching the database the edge checks the project. A project with no running experiments or no pushed config gets `202` with `idle` and the reason; one with no quota left or deleted gets `429` with a reason and `Retry-After`. Exposures for paused, finished or draft experiments are dropped. Nothing of that is stored or billed. An exposure-event cap (`maxExposures`) finishes the experiment automatically and emails the team. ## Emails Everyone on the project gets an email when an experiment starts (who, which goal, link) and when it finishes (verdict, per-variant numbers, the AI review on Pro), on every plan. Each member switches these on or off in their profile. Alerts and the digest are additional (Pro). ## Retention | | Raw events | Daily aggregates and verdict snapshots | |---|---|---| | Free | 30 days | 1 year | | Packs or Pro | 90 days | 1 year | Timelines and verdicts of finished experiments keep working after raw events expire. ## Limits Up to 500 events per batch, 4,096 characters of serialized props per goal, and 64-character experiment, variant and goal keys. Experiments, goals, members and projects are unlimited. --- # Alerts, digest, AI reviews Three ways to be told instead of checking. All three live on the Alerts tab and the AI tab of a cloud project and need Pro. ## Alert rules A rule is a scope (one experiment or every running one), a condition with thresholds, and actions. Rules are evaluated every hour and fire at most once per experiment per cooldown window. | Condition | Fires when | |---|---| | `srm` | the split differs from the configured weights (p < 0.001) | | `winner` | the verdict is `winner_found` (optionally only once every variant has N visitors) | | `loser` | a variant is at least X% worse than the control with enough sample | | `exposures` | unique visitors on the experiment reach N | | `conversions` | conversions reach N | | `duration` | the experiment has run N days | | `no_events` | no events for N hours while running | | `quota` | the project used X% of its monthly exposures | Actions: send to channels (email addresses or webhooks), pause the experiment, finish it, drop the losing variant (set its weight to 0). A rule can combine them: "SRM → pause and email", "winner → finish and email", "loser after 10k visitors → drop the loser". Every project starts with sensible rules created **disabled**; enabling one needs Pro. **Test** runs the rule as a dry run and lists the experiments it would hit right now. ## Channels Saved once, reused by rules and the digest: an email address, a webhook URL (JSON POST with the rule, the experiment, the message and the actions taken), more to come. Add them on the Alerts tab; pick them in a rule's "deliver to" multiselect. ## The digest A scheduled summary, separate from the rules: every day at a chosen hour (UTC), on chosen weekdays, weekly or monthly. It covers running experiments with their verdicts, finished ones since the last digest, quota usage, and what to decide next. Sent to the chosen channels; each member can also switch it off in their profile. ## AI reviews An analyst's second opinion written by a language model from the experiment setup, aggregate results and available variant screenshots. Raw event rows and visitor ids are not supplied as statistical input: - **On start**: a setup review, including available variant screenshots. - **On finish**: when an experiment finishes (dashboard, CLI, API, cap, rule), a review is written and attached to the finish email. - **Progress checks**: the daily job checks running experiments with at least 100 visitors. It writes a review when the verdict changes from the previous snapshot or a week has passed since the last review, subject to the automatic review cooldown. - **On demand**: the AI tab's "Review now". Reviews are stored with the experiment and readable from the dashboard, `npx splithub review []`, the MCP tool `splithub_review`, and `GET /v1/reviews[/]` with an API key. They are advice; the verdict from the statistics engine stays the source of truth, and the skills tell agents exactly that. ## Webhook payload ```json { "kind": "srm", "project": "project-id", "subject": "[SplitHub] My app: Broken split (SRM)", "text": "Alert summary and dashboard link", "rule": "Broken split (SRM)", "experiment": "checkout-steps", "message": "Sample ratio mismatch in \"checkout-steps\" (p=2.1e-4). The split is not what the config says.", "actions": ["paused"], "link": "https://app.splithub.io/p/project-id/e/checkout-steps" } ``` --- # Short links Every cloud project has short links at `https://sh2.uk/`, for example `https://sh2.uk/launch`. The code sits directly after the domain, without an `/l/` prefix. Use the short URL shown in the dashboard when sharing a link or creating a QR code. A link has one or more **destinations**, each with a weight and optional country and device rules. They are useful for campaigns, for QR codes, and for testing landing pages on sites where you cannot install anything. ## Destinations ```json { "destinations": [ { "url": "https://example.com/", "weight": 50, "label": "current" }, { "url": "https://example.com/start", "weight": 50, "label": "new landing" }, { "url": "https://example.com/de", "weight": 100, "countries": ["DE", "AT", "CH"] }, { "url": "https://m.example.com/", "weight": 100, "devices": ["mobile"] } ] } ``` Matching targeted destinations take priority and are chosen by weight. When none match, the link uses the untargeted destinations. A cookie makes the choice **sticky**, so a visitor who returns lands on the same page. UTM parameters and any other query string on the short link are forwarded unless the destination already defines the same parameter. ## Stats Total clicks, unique visitors, daily clicks, and breakdowns by destination, country, device and referrer. ## QR codes Each link has a QR code in the dashboard with a PNG download. For colors, a logo and other payload types use the [QR code generator](/tools/qr-generator) with the short URL. ## Links as a lightweight test Two weighted destinations are a redirect test without the SDK or the script. The dashboard shows the split and the clicks; to judge conversions you need a goal on the destination pages, which is what a [redirect experiment](/docs/redirect-experiments) with the drop-in script gives you. The "Create experiment and its link" option creates the experiment and opens a prefilled link form for you to save. Short-link clicks alone are not experiment exposures; install experiment tracking on the destination site to measure conversions. ## Redirect behavior The public redirect is `GET https://sh2.uk/` and answers `302`. The API-host route `GET https://go.splithub.io/l/` also remains supported, but the dashboard uses the shorter `sh2.uk` URL. Unknown links return `404`; paused links and links that have reached their click limit return `410`. --- # Plans and limits Self-hosted is free with no limits. SplitHub Cloud bills per project and per **exposure**; goals, members, experiments and projects are unlimited. | | Self-hosted | Cloud Free | Packs | Pro | |---|---|---|---|---| | Price | $0 | $0 | from $5, prepaid | $19 / project / month | | Exposures | unlimited | 50,000 / month | $5 → 250k · $10 → 550k · $25 → 1.5M · $50 → 3.2M | 500,000 / month included | | Dashboard, live overrides, targeting, links | — (CLI and MCP only) | ✓ | ✓ | ✓ | | Alerts, channels, digest | — | — | — | ✓ | | AI reviews (start, progress checks, finish, on demand) | — | — | — | ✓ | | Packs | — | — | — | 25% more exposures per pack | | Raw events kept | your database | 30 days | 90 days | 90 days | | Aggregates and verdicts kept | your database | 1 year | 1 year | 1 year | | Members | — | unlimited | unlimited | unlimited | | Support | GitHub | email | email | priority email | ## What an exposure is One visitor seeing one running experiment, counted once per visitor, experiment and session. A page with two running experiments costs two exposures per visitor. Not counted: goals and conversions, exposures of paused, finished or draft experiments (refused at the edge), visitors outside `traffic` or skipped by the targeting flow. ## Order of consumption Free monthly quota → Pro allowance → packs. The free quota and the Pro allowance reset on the first of the month (UTC); packs never expire. ## At the limit Ingest answers `429 quota_exceeded` with `Retry-After`; the SDK keeps assigning variants locally and your app behaves normally, nothing is stored. The dashboard shows usage. Pro alert rules can notify you at a configured quota threshold. Buying a pack or the month rolling over reopens the gate within a minute. ## Payments Stripe Checkout for packs and the Pro subscription; invoices and cancellation in the Stripe customer portal from the project's Billing tab. Prices in USD, taxes added where they apply. Check the checkout and billing portal for the applicable payment and cancellation terms. ## Bigger Several million exposures a month, SSO, an SLA, or a reseller arrangement: hello@splithub.io with your expected volume and stack.