Docs / Tools View as Markdown

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 by npx splithub login through 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 (the sh_vid cookie). 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 (platform is ios, android, macos, windows or linux; the rest is optional). It replaces the user agent for device and OS, fills the platform and app_version dimensions, and id stands in for the origin in allowed-domain lists. Screens are page rows with the screen's route as url.
  • 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:write scope 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.
  • 429 with code ∈ quota_exceeded | deleted and a Retry-After header: back off; nothing was stored. 403 origin_not_allowed when 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.

SplitHub
See what works. Ship what wins.