Docs / Cloud and data View as Markdown

Self-hosted (your database)

The ingest handler, the Store interface, Postgres/MySQL/SQLite/D1, the events table, attribution SQL.

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.

// 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).

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

interface Store {
  insertEvents(events: StoredEvent[]): Promise<number>;                      // skip existing ids, return inserted count
  variantCounts(experiment: string, goal: string): Promise<VariantCounts[]>;  // per variant: exposures, conversions, revenue
  summaries(): Promise<ExperimentSummary[]>;                                 // per experiment: exposures, visitors, first/last ts
  close?(): Promise<void> | 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

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/<id> 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
Test ideas. Ship winners.