HTTP API
Ingest of exposures, goals, pageviews and events, experiment and property endpoints, key scopes, live overrides JSON, errors.
Cloud base URL: https://go.splithub.io. Requests and responses use JSON. The self-hosted handler 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 bynpx splithub loginthrough a browser approval, or in the dashboard under Settings → API keys. Never in client code.
Send events (public key)
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": "p_example_01", "type": "page", "prop": "sh-x1h78w", "url": "/pricing", "title": "Pricing", "ref": "https://www.google.com/", "vid": "v_abc", "sid": "s_1", "ts": 1760000020000 },
{ "id": "v_example_01", "type": "event", "prop": "sh-x1h78w", "name": "Video Played", "props": { "id": 3 }, "url": "/pricing", "vid": "v_abc", "sid": "s_1", "ts": 1760000030000 },
{ "id": "i_example_01", "type": "identify", "prop": "sh-x1h78w", "uid": "user_42", "traits": { "plan": "pro" }, "vid": "v_abc", "sid": "s_1", "ts": 1760000040000 }
]
}
type |
Fields | Meaning |
|---|---|---|
exposure |
exp, var, url? |
a visitor saw a variant |
goal |
goal, props? |
a declared goal, for experiments without an analytics property |
page |
prop, url, title?, ref? |
a pageview of an analytics property |
event |
prop, name, props?, url? |
a custom event; it also converts the experiments whose goal it is |
identify |
prop, uid, traits? |
ties the visitor to your user id (1 to 128 characters) |
With a running property, send each action once: a page row for a page and an event row for a track() call. The cloud turns them into the goal conversions of the experiments that measure them; do not send a goal row as well.
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 (thesh_vidcookie).sid: session id.ts: client time in ms; values more than 7 days off are replaced by server time.- Apps add
"app": { "platform": "ios", "id": "com.acme.shop", "version": "2.4.1", "build": "412", "os": "17.5", "model": "iPhone15,2", "device": "mobile" }to the batch (platformisios,android,macos,windowsorlinux; the rest is optional). It replaces the user agent for device and OS, fills theplatformandapp_versiondimensions, andidstands in for the origin in allowed-domain lists. Screens arepagerows with the screen's route asurl. - Up to 500 events per batch. Goal and event props, and identify traits, may contain up to 4,096 characters of serialized JSON. Event names: letters, digits, spaces and
_ . : / -, up to 64 characters. - Reply
202 { ok, received, inserted, dropped }. - From a server, send with an API key that has the
events:writescope instead of the public key: no origin check applies. - A project with no running experiment or analytics property, or no config yet, replies
202 { ok, inserted: 0, idle: 'no_running' | 'no_config' }: nothing is stored, and it is not an error. 429withcode∈quota_exceeded|deletedand aRetry-Afterheader: back off; nothing was stored.403 origin_not_allowedwhen nothing in the batch comes from an allowed domain.- Exposures of experiments that are not running, and rows of properties that are not running, are dropped and not billed. Pageviews and events of a running property count like exposures; goals, identify and engagement are never billed. Rows from bots are dropped.
Live config for clients (public key)
GET /v1/overrides/pk_live_….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.
?all=1 also puts every experiment pushed from splithub.config.ts that can still assign into added, in the pushed shape. Apps ask for it, because the experiments built into an old binary may be out of date; see Apps.
Project endpoints (API key)
GET /v1/status every experiment: status, visitors, days, verdict headline
GET /v1/results/<experiment> full verdict (see Statistics)
GET /v1/reviews[/<experiment>] 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/<experiment> { status | traffic | weights | values }
DELETE /v1/overrides/<experiment>
Authorization: Bearer sk_live_… on each. Status values: draft | running | paused | finished. A PUT /v1/overrides/<experiment> that starts or finishes an experiment triggers the start/finish emails to the project's members.
Analytics endpoints (API key)
GET /v1/properties every property with its last 30 days
POST /v1/properties { name, key?, kind?, description?, goals?, status? } create one (id generated unless given; kind site | app)
GET /v1/properties/<id> one property's settings
PATCH /v1/properties/<id> { status | archived | name | kind | description | goals }
DELETE /v1/properties/<id> delete a property made in the dashboard, with its numbers
GET /v1/properties/<id>/summary totals, goals, top pages and sources, notes
GET /v1/properties/<id>/breakdown?by=<dimension> rows by a dimension (goal=, key=, experiment=)
GET /v1/properties/<id>/funnel?steps=…&within= funnel steps
GET /v1/properties/<id>/paths?page=/pricing next (or previous) pages
GET /v1/properties/<id>/retention?period=day|week cohorts { start, users, retained[] }
GET /v1/properties/<id>/realtime the last 5 and 30 minutes
GET /v1/properties/<id>/props?name=<event> an event's props
GET /v1/properties/<id>/events the latest raw rows (before=, limit= up to 200)
GET /v1/properties/<id>/export.csv?by=day CSV (raw=1 for raw rows)
GET /v1/segments | POST /v1/segments | DELETE /v1/segments/<id>
GET /v1/event-catalog every event name seen and every goal
GET /v1/domains allowed domains per experiment and property
Reports take from and to (days like 2026-10-01 in the project's time zone, the last 30 days by default, at most 400), filters as query parameters (country=DE,FR, source=(none), t.plan=pro), and compare=previous | year | custom&cfrom=&cto=. The CLI and the MCP server call these routes; Reports shows what each one answers.
API key scopes
A key carries scopes, set when you create it in Settings → API keys. Each area has <area>:read and <area>:write (write includes read):
| Area | Covers |
|---|---|
experiments |
status, results, report, config, overrides, reviews, experiment breakdowns |
analytics |
properties and their reports, segments, the event catalog |
events |
raw rows (an experiment's or a property's events, raw CSV), and POST /v1/events from a server |
notes, links, alerts, domains |
those resources |
read and write alone cover every area, and * (the default) covers everything. A request outside a key's scopes answers 403 insufficient_scope with the missing scope. A key for a dashboard of totals needs only analytics:read; it cannot read visitors' raw events.
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. Public short links use GET https://sh2.uk/<code> and return 302. The API-host route GET /l/<code> 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, hint? }; hint is the next step, in one sentence.
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 an experiment or a property runs.