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 resultsor@splithub/statsfor the verdict.