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, raw analytics counts.

In self mode the app hosts the ingest route and the store; exposures, goals, pageviews and events stay in your database and nothing leaves your infrastructure. The SDK, the CLI and the MCP server are the same as in the cloud; reads are raw counts, while verdicts and analytics reports come with SplitHub 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 exposures, visitors and days
GET /results/:id visitors, conversions, rate and revenue per variant
GET /properties every analytics property with raw counts (?days=30, or ?from=&to=)
GET /properties/:id one property's raw counts
GET /properties/:id/events its latest raw pageviews and events (?before=&limit=)
GET /health liveness

The handler accepts up to 500 events per batch and 4,096 characters of serialized props. Set adminToken in production; without it, every read above is 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
  propertyCounts?(property: string, since: number, until?: number): Promise<PropertyCounts>; // analytics: raw counts
  propertyEvents?(property: string, options: { before: number; limit: number }): Promise<StoredEvent[]>; // newest rows first
  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, goal, page, event, engage or identify
experiment, variant text for exposures; on analytics rows experiment holds the property id
goal text for goals; on event rows the event name
visitor_id, session_id text the sh_vid cookie / the session
ts bigint client time, unix ms
url text page
props json goal or event payload, e.g. { "revenue": 49 }; a page's title and referrer; identify traits
received_at bigint server time, unix ms
host, country, device, browser text optional request metadata for breakdowns
platform, app_version text web for websites, else the app's platform (ios, android…) and version

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.

Analytics

Self-hosted projects keep their analytics properties in the config file. npx splithub analytics create --name "Marketing site" writes one with a generated id:

// splithub.config.ts
properties: {
  'sh-x1h78w': { name: 'Marketing site' },
},

Name it on the provider (property="sh-x1h78w") and the handler stores its page, event, engage and identify rows in the same table. Rows of a property that is not in the config or not running are dropped. Each event and page row also converts the experiments whose goal is that event or page, so one track() call serves both.

Reads are raw counts for a range of UTC days: pageviews, visitors, visits, identified users and events by name.

GET /api/splithub/properties?days=7
GET /api/splithub/properties/sh-x1h78w?from=2026-10-01&to=2026-10-31

npx splithub analytics sh-x1h78w --days 7 prints the same. The SQLite, D1 and generated stores implement propertyCounts; a custom store without it answers 501 on a property's counts, and propertyCountsSql() from @splithub/node gives the queries for the SplitHub table layout. Reports with pages, sources, channels, visits, conversions, filters, funnels, paths and retention come with SplitHub Cloud.

Reports into your warehouse (cloud mode)

If you use the cloud but want data in your own database: CSV export per experiment and per property (summary, daily timeline, raw events), a webhook channel for alerts and the digest, or a cron that calls GET /v1/status, GET /v1/results/<id> and GET /v1/properties/<id>/summary with the API key and stores the answers. Raw events are a short tail in the cloud (7 days on Free, 30 with packs or Pro) while daily numbers stay much longer: 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: raw rates ignore sample size and peeking. Verdicts come with SplitHub Cloud.
  • Read analytics counts as an experiment result: use npx splithub results for experiments.
SplitHub
See what works. Ship what wins.