# HTTP API

Cloud base URL: `https://go.splithub.io`. Requests and responses use JSON. The [self-hosted handler](/docs/self-hosted) 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)

```http
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](/docs/analytics) |
| `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)

```http
GET /v1/overrides/pk_live_….json
```

```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](/docs/apps).

## Project endpoints (API key)

```http
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)

```http
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](/docs/analytics-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](/docs/script). Public [short links](/docs/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.
