# Servoki HTTP API

Servoki is privacy-first web analytics. **Everything the dashboard does goes
through this HTTP API — the browser UI is just one client.** Product endpoints
(metrics, ingestion, sites, goals, exports, …) accept an **API key**. Only
account and tenancy management (login, signup, workspaces, API-key CRUD) uses a
**browser session**, and those endpoints deliberately reject API keys.

## Authentication

Create a key on the dashboard API-keys page, then send it as a Bearer token:

```
Authorization: Bearer servoki_xxxxxxxxxxxxxxxx
```

- Keys start with `servoki_` and are **scoped to a single workspace (org)**.
  A key can act on every site that workspace owns and nothing else.
- Cross-tenant requests (a key reaching for another workspace's site) return
  `403` (authenticated but not permitted) or `404` (site not visible).
- **Session-only endpoints reject API keys with `401`.** A leaked product key
  can therefore never mint new credentials, change workspace membership, or
  delete an org. Those endpoints are listed under
  *Account & tenancy (session-only)* below.

## Base URL

`https://servoki.com`

The API answers on all product hosts, so use whichever host serves your
workspace. Machine-readable companions to this document:

| Resource | Path | Content-type |
|---|---|---|
| OpenAPI 3.1 spec | `/api/openapi.json` | `application/json` |
| This reference | `/docs.md` | `text/markdown` |
| LLM quickstart | `/llms.txt` | `text/plain` |

All three are served without auth and with `Access-Control-Allow-Origin: *`.

## Conventions

- **JSON in, JSON out.** Request and response bodies are JSON unless a route
  explicitly streams (export, SSE).
- **Errors** use the shape `{ "error": "code" }` with an appropriate 4xx/5xx
  status. No stack traces are leaked. See *Error codes* below.
- **Dates** are `YYYY-MM-DD` in **UTC** (e.g. `from=2026-06-01&to=2026-06-30`).
  **Timestamps** are ISO-8601 in UTC (e.g. `2026-06-26T14:03:00Z`).
- **Rate limits**: ~300 requests/min per IP globally, with stricter buckets on
  authentication (login/signup/reset ~5/min), export (5/min), and waitlist
  (10/min) routes. Exceeding a limit returns `429`.
- **Idempotency**: server events dedupe on `event_id`; browser engagement
  beacons key on `pid` (the page-instance id). Site creation conflicts on an
  existing domain return `409`.
- **Tenancy semantics**: a request authenticated against the wrong workspace
  gets `403` when the caller is recognized but not permitted, and `404` when
  the site simply isn't visible to the caller — Servoki prefers `404` to avoid
  confirming a resource exists across tenants.
- **Consent gating**: when a site requires consent and none is present, events
  are still **accepted with `202`** but are **not stored** (analytics) or
  **not forwarded** (connectors/webhooks). The caller cannot distinguish a
  consent-gated drop from a stored event by the status code alone.

## Endpoints

`{domain}` is the site's domain as registered on the Sites page. `{id}` is a
resource id. All bodies are JSON unless noted.

### Ingestion

| Method & path | Auth | Notes |
|---|---|---|
| `POST /api/event` | none | Browser beacon (sent by tracker.js); validated per site. Required: `site`, `url`. Optional: `referrer`, `utm{source,medium,campaign}`, `event_type` (default `pageview`), `vid`, `pid`, `duration_ms`, `consent`, `props`. When `duration_ms` is present it updates an existing event's engagement (needs `pid`). Returns `202` (also for bot/consent-gated/engagement-update cases). |
| `POST /api/events/server` | key | Server-confirmed events/conversions. Required: `site`, `event_type`. Optional: `vid`, `occurred_at` (±30 days), `value`, `currency`, `event_source_url`, `country`, `event_id` (dedup), `consent`, `props`, `user_data` (email/phone/external_id/fbp/fbc/client_ip_address/client_user_agent — hashed in-memory for forwarding, **never persisted**). Returns `202`. |

### Sites

| Method & path | Auth | Notes |
|---|---|---|
| `GET /api/admin/sites` | key | List sites in the workspace → `{ sites: [{ id, domain, site_code, created_at }] }`. |
| `POST /api/admin/sites` | key | Create a site — body `{ "domain": "example.com" }`. `201` → the site object. `409` `duplicate_domain`. |
| `GET /api/admin/sites/{id}` | key | Fetch a single site by id. |
| `DELETE /api/admin/sites/{domain}` | key | Permanently delete a site and all of its data. `204`. |
| `GET /api/admin/sites/{domain}/install-status` | key | `{ receiving, last_event_at }` — is the tracker sending events? |
| `POST /api/admin/sites/{domain}/verify-install` | key | Fetch a page (default `/`) and check the tracker snippet. |
| `GET·PUT /api/admin/sites/{domain}/consent-policy` | key | Read/set `analytics_requires_consent`, `forwarding_requires_consent`. |
| `GET·POST·DELETE /api/admin/sites/{domain}/custom-domain` | key | First-party collection domain. POST body `{ custom_domain }` returns DNS records. |
| `POST /api/admin/sites/{domain}/custom-domain/verify` | key | Verify the custom domain's DNS → `{ verified }`. |

### Metrics & realtime

All metrics routes take `site`, `from`, `to` (and optional `path`); the
`top-*` and `breakdown` routes also accept `limit` (1–100, default 10).

| Method & path | Auth | Response shape |
|---|---|---|
| `GET /api/metrics/summary` | key | `{ views, visitors }` |
| `GET /api/metrics/timeseries` | key | `[{ ts, views, visitors }]` — `ts` is an ISO datetime (one row per day) |
| `GET /api/metrics/breakdown` | key | requires `prop`; `[{ value, views, visitors }]` |
| `GET /api/metrics/engagement` | key | `{ avgTimeOnPageMs, avgVisitMs, samples }` |
| `GET /api/metrics/top-pages` | key | `[{ key, views, visitors }]` |
| `GET /api/metrics/top-referrers` | key | `[{ key, views, visitors }]` |
| `GET /api/metrics/top-countries` | key | `[{ key, views, visitors }]` |
| `GET /api/metrics/top-utm-sources` | key | `[{ key, views, visitors }]` |
| `GET /api/metrics/top-events` | key | `[{ key, views, visitors }]` |
| `GET /api/metrics/top-browsers` | key | `[{ key, views, visitors }]` |
| `GET /api/metrics/top-os` | key | `[{ key, views, visitors }]` |
| `GET /api/realtime/snapshot` | key | `{ active, perMinute, feed, topPages, topCountries }` — `perMinute: [{ minute, views, visitors }]`, `feed: [{ occurredAt, urlPath, country, browser, os, eventType, referrerHost }]`, `topPages`/`topCountries` as `[{ key, views, visitors }]` |
| `GET /api/realtime/stream` | key | Server-Sent Events (`text/event-stream`). First message is the full snapshot `{ type: "snapshot", ... }`, then per-event `{ type: "event", event, active }`; `: ping` heartbeats. |

Malformed metrics queries (bad `site`/`from`/`to`) return `400 invalid_query`.

### Goals & funnels

Goal/funnel `conditions` are `[{ field, op, value }]` where `op` is one of
`is | is_not | contains | matches | exists` and `field` is e.g. `path`,
`event_type`, or `prop:<key>`.

| Method & path | Auth | Notes |
|---|---|---|
| `GET·POST /api/admin/sites/{domain}/goals` | key | Goal CRUD. Create body `{ name, conditions, valueProp?, archetype? }`. |
| `PUT·DELETE /api/admin/sites/{domain}/goals/{id}` | key | Update / delete a goal. |
| `GET /api/admin/sites/{domain}/goals/metrics` | key | Conversions + revenue per goal over a range. |
| `GET /api/admin/sites/{domain}/goals/suggestions` | key | Suggested goals from observed traffic. |
| `GET /api/admin/goal-templates` | key | Built-in goal templates. |
| `GET /api/admin/sites/{domain}/goals/{id}/accuracy` | key | Browser-vs-server accuracy for a goal → `{ browser, server, total, recoveredPct }`. |
| `GET /api/admin/sites/{domain}/accuracy` | key | Same, for a given `event_type` (query param). |
| `GET·POST /api/admin/sites/{domain}/funnels` | key | Funnel CRUD. Create body `{ name, steps (2+), valueProp? }`; each step is `{ name, conditions }`. |
| `PUT·DELETE /api/admin/sites/{domain}/funnels/{id}` | key | Update / delete a funnel. |
| `GET /api/admin/sites/{domain}/funnels/{id}/metrics` | key | Step-by-step conversion metrics. |
| `GET /api/admin/sites/{domain}/funnels/{id}/breakdown` | key | Conversion by `dimension` (`referrer`/`utm_source`/`country`/`browser`/`os`). |
| `GET /api/admin/sites/{domain}/funnels/suggestions` | key | Suggested funnels from observed traffic. |

### Privacy / masking

| Method & path | Auth | Notes |
|---|---|---|
| `GET·POST /api/admin/sites/{domain}/masking-rules` | key | URL-masking rule CRUD. Body `{ pattern, replacement }`. |
| `PUT·DELETE /api/admin/sites/{domain}/masking-rules/{id}` | key | Update / delete a rule. |
| `PUT /api/admin/sites/{domain}/masking-rules/order` | key | Reorder — body `{ rule_ids: [...] }`. |
| `POST /api/admin/sites/{domain}/masking-rules/{id}/preview` | key | Preview a rule without applying. |
| `POST /api/admin/sites/{domain}/masking-rules/{id}/apply` | key | Apply retroactively → `{ applied_count, applied_at }`. |
| `GET /api/admin/sites/{domain}/pii-candidates` | key | Auto-detected PII candidates from recent paths. |
| `GET·POST /api/admin/sites/{domain}/persistent-id` | key | Read/set cross-session id settings — `{ enabled, verified, token }`. |
| `POST /api/admin/sites/{domain}/persistent-id/verify` | key | Verify domain ownership → `{ verified, token, metaTag }`. |

### Alerts & insights

| Method & path | Auth | Notes |
|---|---|---|
| `GET·POST /api/admin/sites/{domain}/alerts` | key | Anomaly-alert rule CRUD. Body `{ metric (views|visitors), direction (drop|spike|both), sensitivity (1–10), webhook_url, active? }`. |
| `PUT·DELETE /api/admin/sites/{domain}/alerts/{id}` | key | Update / delete a rule. |
| `POST /api/admin/sites/{domain}/alerts/evaluate` | key | Evaluate all rules now → `{ fired: [...] }`. |
| `GET /api/admin/sites/{domain}/insights` | key | Narrative insights for a range → `{ from, to, insights: [{ kind, severity, headline, detail, metric, change }] }`. |

### Export & sharing

| Method & path | Auth | Notes |
|---|---|---|
| `GET /api/admin/sites/{domain}/export` | key | Stream a raw event export. Query: `format` (`ndjson`/`csv`/`parquet`, default `ndjson`), `gzip` (`1`/`true`), `from`, `to`, `event_type`, `columns`. Rate-limited 5/min; `visitor_token` is anonymized. |
| `GET·POST /api/admin/sites/{domain}/shares` | key | Public dashboard link CRUD. POST body `{ label?, expires_at? }` → `{ share: { token, url, ... } }`. |
| `DELETE /api/admin/sites/{domain}/shares/{id}` | key | Revoke a share link. |
| `GET /api/public/{token}` | none | Token-gated read-only dashboard data → `{ domain, from, to, summary, series, topPages, topCountries }`. Rate-limited ~60/min. `404` if revoked/expired. |

### Webhooks & connectors

| Method & path | Auth | Notes |
|---|---|---|
| `GET·POST /api/admin/sites/{domain}/webhooks` | key | Outbound webhook CRUD (HMAC-signed). POST body `{ url, event_type? (default `*`), active? }` → returns the signing `secret` **once**. |
| `PUT·DELETE /api/admin/sites/{domain}/webhooks/{id}` | key | Update / delete a webhook. |
| `GET /api/admin/sites/{domain}/webhooks/{id}/deliveries` | key | Recent delivery attempts (latest 50). |
| `GET·POST /api/admin/sites/{domain}/connectors` | key | Ad-platform connectors (Meta CAPI). POST body `{ kind: "meta_capi", config: { pixelId, ... }, secret, active? }`. **Requires the workspace package to include the `convert` module** (else `403 module_unavailable`). |
| `PUT·DELETE /api/admin/sites/{domain}/connectors/{id}` | key | Update / delete a connector. |
| `GET /api/admin/sites/{domain}/connectors/{id}/deliveries` | key | Recent delivery attempts (latest 50). |

### Sessions / overview / paths

| Method & path | Auth | Notes |
|---|---|---|
| `GET /api/admin/sites/{domain}/sessions` | key | List sessions with summary stats → `{ stats, sessions }`. Query: `from`, `to`, `limit` (1–500), `offset`. |
| `GET /api/admin/sites/{domain}/sessions/{ref}/journey` | key | Ordered event journey for one session → `{ events }`. |
| `GET /api/admin/sites/{domain}/paths` | key | Entry/exit paths and transitions → `{ entries, exits, transitions }`. |
| `GET /api/admin/sites/{domain}/cohorts` | key | Weekly retention cohorts → `{ enabled, cohorts }`. Query: `weeks` (2–26, default 8). |
| `GET /api/admin/overview` | key | Cross-site portfolio overview → `{ range, totals, sites }`. Query: `days` (30/90/180), `bucket` (`day`/`week`). |

### Account & tenancy (session-only)

These manage tenancy and credentials and **deliberately reject API keys
(`401`)** — a leaked product key can never touch them. They authenticate with
the browser session cookie.

| Method & path | Notes |
|---|---|
| `POST /api/admin/login` | Start a session. Body `{ username, password, next? }`. Rate-limited 5/min. |
| `POST /api/admin/logout` | End the session. |
| `GET /api/admin/whoami` | Current user + workspace memberships → `{ userId, email, orgs }`. |
| `GET·POST /api/admin/api-keys` | List keys / mint a new key (full key returned once). |
| `DELETE /api/admin/api-keys/{id}` | Revoke a key. |
| `PUT /api/admin/orgs/{id}/package` | Change a workspace's product package (owner only). |
| `DELETE /api/admin/orgs/{id}` | Delete an empty workspace you own (`409 org_not_empty` otherwise). |
| `GET /api/admin/safety-status` | Privacy/safety posture (salt rotation, masking). |
| `POST /api/auth/signup` | Self-serve sign up (creates user + workspace). Body `{ email, password }`. `409 email_taken`. |
| `GET /api/auth/verify` | Consume an email-verification token (redirects). |
| `POST /api/auth/resend-verification` | Resend verification email. Always `202`. |
| `POST /api/auth/forgot-password` | Request a reset email. Always `202`. |
| `POST /api/auth/reset-password` | Reset with a token and sign in. Body `{ token, password }`. |

There is also a public marketing route, `POST /api/waitlist` (no auth, always
`202`), and a `GET /health` liveness probe.

## Error codes

| code | status | meaning |
|---|---|---|
| unauthorized | 401 | Missing/invalid API key or session. |
| forbidden | 403 | Authenticated but cross-tenant. |
| unknown_site | 404 | Site not found or not visible to caller. |
| invalid_query | 400 | Malformed metrics query (site/from/to). |
| invalid_payload | 400 | Body failed schema validation. |
| duplicate_domain | 409 | Site already exists. |
| weak_password | 400 | Password policy not met. |
| module_unavailable | 403 | Workspace package lacks the required module (e.g. convert). |
| not_found | 404 | Resource (share, rule, webhook…) not found. |

## Recipes

All recipes use `https://servoki.com` as the base. Replace `servoki_...` with
your key and `example.com` with your site domain.

**Authenticate** — send the key as a Bearer token on every product request:

```bash
curl https://servoki.com/api/admin/sites \
  -H "Authorization: Bearer servoki_..."
```

**Create a site:**

```bash
curl -X POST https://servoki.com/api/admin/sites \
  -H "Authorization: Bearer servoki_..." \
  -H "Content-Type: application/json" \
  -d '{"domain":"example.com"}'
```

**Query summary metrics** (`{ views, visitors }`):

```bash
curl "https://servoki.com/api/metrics/summary?site=example.com&from=2026-06-01&to=2026-06-30" \
  -H "Authorization: Bearer servoki_..."
```

**Post a server-side event/conversion:**

```bash
curl -X POST https://servoki.com/api/events/server \
  -H "Authorization: Bearer servoki_..." \
  -H "Content-Type: application/json" \
  -d '{
    "site": "example.com",
    "event_type": "purchase",
    "value": 49.0,
    "currency": "USD",
    "event_id": "order-1234"
  }'
```

**Export raw events as NDJSON:**

```bash
curl "https://servoki.com/api/admin/sites/example.com/export?format=ndjson&from=2026-06-01&to=2026-06-30" \
  -H "Authorization: Bearer servoki_..."
```

**Subscribe to the realtime SSE stream** (`-N` disables buffering):

```bash
curl -N "https://servoki.com/api/realtime/stream?site=example.com" \
  -H "Authorization: Bearer servoki_..."
```
