# Analytiics HTTP API reference

Two hosts. The collector at `in.analytiics.co` takes events; the app at
`www.analytiics.co` manages projects and serves dashboards.

All request and response bodies are JSON.

## The machine-readable version

Everything on this page is also published as an OpenAPI 3.1 document, generated
from the same source rather than transcribed from this prose:

- [`/openapi.json`](/openapi.json) — and the identical
  [`/openapi.yaml`](/openapi.yaml).
- The same two documents answer under `/api/` as well, at
  [`/api/openapi.json`](/api/openapi.json) and
  [`/api/openapi.yaml`](/api/openapi.yaml).
- [`/.well-known/api-catalog`](/.well-known/api-catalog) is the
  [RFC 9727](https://www.rfc-editor.org/info/rfc9727) catalog that points at
  them.

Every operation there carries a unique `operationId`, a description of when to
call it and what it will not do, typed parameters, and a response schema for
every status — including the failures. It is meant to be usable as-is for
generating a client or a set of tool definitions.

Read the spec when you are writing code against this API. Read the rest of this
page when you want to know *why* an endpoint behaves the way it does.

## Authentication

There are two credentials, and they are not interchangeable.

- **A server write key** (`sk_live_…`), sent as
  `Authorization: Bearer <key>`, authorizes writes to exactly one project. It
  is shown once, when issued, and only its hash is stored. Keep it server-side.
- **A session token**, also sent as `Authorization: Bearer <token>`, is what
  `analytiics login` obtains through the device flow. It authorizes the
  management endpoints on the app host as the person who signed in. Browsers use
  the same session as a cookie.

The public collector needs no credential at all, which is why it refuses to
accept revenue.

## POST /v1/events

Public, CORS-enabled, unauthenticated. What the browser tracker calls.

```http
POST https://in.analytiics.co/v1/events
Content-Type: text/plain
```

```json
{
  "events": [
    {
      "site": "example.com",
      "name": "page_published",
      "url": "https://example.com/editor",
      "ref": "https://www.google.com/",
      "sid": "…",
      "uid": "",
      "props": { "template": "grid" }
    }
  ]
}
```

Up to 50 events per request. `site` and `name` are required; `ts` is
optional and defaults to arrival. Location and device are derived from the
request headers, so nothing about the visitor's IP is stored.

Responses: `202 {"accepted": n}` on success — including for traffic
identified as a bot, which is silently dropped with `accepted: 0` —
`400 {"error": "invalid payload"}` for anything that fails validation, and
`404` with code `project_not_found` when `site` names a project that does
not exist, and `403` with code `site_mismatch` when the page is not on that
project's domain or a subdomain of it. Events are only recorded for a project
somebody has created, from pages on its domain, so a snippet installed under the
wrong domain — or on the wrong site — fails visibly in the network tab rather
than filling a dashboard nothing can claim. Pages on localhost are let through
for local development. If the collector cannot check —
its control plane is unreachable and it has no recent answer for that site —
it answers `503` and records nothing rather than guess.

This endpoint **ignores revenue**. It is reachable by anyone who can read your
page source, so accepting money over it would let a stranger fill your dashboard
with fake sales.

## POST /v1/server/events

Authenticated, server-to-server, no CORS. This is the only path that may set
revenue; browser identify calls also carry identity.

```http
POST https://in.analytiics.co/v1/server/events
Authorization: Bearer sk_live_…
Content-Type: application/json
```

```json
{
  "site": "example.com",
  "events": [
    {
      "event_id": "evt_provider_123",
      "name": "subscription_started",
      "user_id": "usr_123",
      "revenue_cents": 2900,
      "currency": "USD",
      "props": { "plan": "pro" }
    }
  ]
}
```

Up to 200 events per request. Every event requires a stable `event_id`; reuse it
for retries of the same logical event. The Node SDK generates IDs by default,
and webhook callers should supply their provider event ID. `revenue_cents` is a signed integer, so refunds
and chargebacks are ordinary events; `currency` is a three-letter code and is
**required** whenever `revenue_cents` is set — an amount with no unit cannot be
summed, and defaulting to USD would silently misreport every other customer.

Responses: `202 {"accepted": n}`, `400` with a `detail` naming the first
validation failure, `401` for a missing, wrong or unknown key — identical
whether the project exists or not, so the endpoint cannot be used to enumerate
projects. `409` reports conflicting reuse of an event ID. `503` with
`Retry-After` means authorization or durable delivery could not be confirmed.
Retry the same IDs with backoff; a partial write may have committed. `202`
confirms storage of every logical event, including previous deliveries.

## GET /health

`200 {"ok": true}`. No authentication.

## GET /script.js

The browser tracker, cached and CORS-open. Under 2 KB gzipped. Named for what
it is rather than for us: filter lists carry path rules that apply on any host,
so a vendor-shaped filename would follow you onto your own collector domain.
Also served at `/aii.js`, the name it had first.

## Management endpoints

On the app host, authenticated with a session token or cookie.

- `GET /api/projects` — the projects you own.
- `POST /api/projects` — create one, from its domain. A project is its
  domain: it is what the tracker sends, where the dashboard lives, and the id
  on every stored event, so it is chosen once and there is no rename.
- `GET /api/projects/{slug}` — one project: domain, timezone, age.
- `DELETE /api/projects/{slug}` — delete it. Keys, settings and share links
  go with it; events already recorded stay in the analytics store, and the
  domain stays permanently reserved. Retained analytics cannot transfer to a new project.
- `GET /api/projects/{slug}/keys` — list a project's write keys, by prefix and
  creation date. Key material is never returned.
- `POST /api/projects/{slug}/keys` — issue one. The response is the only time
  the key is readable.
- `DELETE /api/projects/{slug}/keys/{id}` — revoke one. A tombstone, not a
  delete: the key stays listed with the date it died.
- `GET /api/projects/{slug}/dashboard` — the dashboard's publication settings.
- `PATCH /api/projects/{slug}/dashboard` — change what an anonymous reader
  sees, widget by widget, and set the project's timezone and the range its
  dashboard opens on.
- `GET /api/usage` — your plan, and what this month has used of it.
- `POST /api/checkout` — start a checkout for a paid plan.
- `POST /api/billing/portal` — invoices, the card on file, and cancelling.

## GET /api/usage

`GET https://www.analytiics.co/api/usage`

Your plan and this billing period's usage. Account-wide, because a plan is: the
cap is bought once and shared by every project you own.

```json
{
  "plan": { "id": "free", "name": "Free", "priceCents": 0, "events": 25000, "projects": 1 },
  "period": { "startsAt": "2026-08-01T00:00:00.000Z", "endsAt": "2026-09-01T00:00:00.000Z" },
  "events": 18402,
  "level": "approaching",
  "measured": true,
  "sampled": false,
  "projects": [{ "slug": "thiings", "events": 18402 }],
  "projectCount": 1
}
```

Three things it is worth knowing about that number:

- **What counts.** A page view, and the events you name yourself. The tracker's
  own `$outbound` and `$identify` calls do not — they are sent because the
  snippet is installed rather than because you asked for them.
- **Which month.** The calendar month in **UTC**, not each project's timezone.
  A dashboard's "today" belongs to its project; a bill has one clock, or an
  account with projects in Auckland and Los Angeles would be billed for
  overlapping months.
- **`measured: false` is not zero.** It means the analytics store could not
  be counted, and `events` is then `null`. Nothing has been capped — the
  meter is what is missing, not the events.

`level` is `ok`, then `approaching` from 80% of the cap, then `over` from
100%. None of them stops ingestion: caps here are soft, and nothing is cut
mid-period. What happens instead is sampling. An hourly sweep meters every
account; past 100% it marks the account, and from then until the month resets
the collector keeps one browser event in ten — whole sessions at a time, so a
funnel drawn from a sampled month is still a funnel. Server events (revenue,
identity) are never sampled. `sampled` says whether that is happening right
now; it clears when the month resets or the plan changes, including the
moment a checkout completes. The sweep emails once at each of the two levels
per month, with the next plan as an offer.

What the cap does refuse is a *new project* past the plan's project
ceiling — `POST /api/projects` answers `402` with `code: "plan_limit"`, and
names the plan that would cover it. Creation is also rate limited to twenty
an hour per account (`429`, with a `Retry-After`), whatever the plan.

## Paying, from a terminal

The whole upgrade is one command, and it is worth saying exactly what each half
of it does:

```sh
analytiics upgrade maker
```

It asks `POST /api/checkout` for a URL, opens it, and then polls
`GET /api/usage` until the plan actually changes.

```json
{ "url": "https://checkout.stripe.com/c/pay/cs_test_…", "plan": "maker" }
```

**Creating a checkout grants nothing.** All that endpoint can do is mint a link
to a page hosted by Stripe, where a person types a card number. The plan moves
afterwards, when Stripe tells our server that money moved — so there is no
request a client can make that upgrades an account, and nothing for an agent to
get wrong. Card details never reach us; the checkout page is the human's
confirmation step, and it is structurally the only one there is.

That is also why the CLI polls rather than reporting success: it cannot know
whether anybody paid, and the server can. If you are building your own flow,
do the same — treat a `201` here as "a link exists", and `getUsage`'s
`plan` as the truth.

`POST /api/billing/portal` returns a link to Stripe's billing portal, where
the same person can read invoices, change the card, or cancel. The customer is
taken from your session, so there is no way to point it at somebody else's
billing. `404 no_subscription` means the account has never been billed and
there is nothing to show.

Nothing else moves a plan. The webhook Stripe calls is not listed above and is
not part of this API: it is authenticated by a signature over the exact bytes
Stripe sent, not by a session, and it is the only writer of the plan on an
account.

## GET /api/online

`GET https://www.analytiics.co/api/online?project=example.com`

The lightweight endpoint for live stats pills. Returns only
`{"online": 42}`, counting people seen within the last five minutes, with
30-second shared caching. It does not fetch the activity feed. Poll every 30
seconds while visible; pause in background tabs.

The project must publish its dashboard and enable realtime. Publication is
checked on every request, including requests from the owner. Responses are
cross-origin readable and use `Cache-Control: private, no-store`. Private or
realtime-disabled projects return `404`; rate limits return `429` with
`Retry-After`. Hide the pill if the request fails.

For a complete embeddable component, see the
[live stats pill guide](/docs/live-stats-pill).

## GET /api/realtime

`GET https://www.analytiics.co/api/realtime?project=example.com`

Cross-origin readable on purpose: it returns exactly what an anonymous visitor
already sees on the public dashboard: the live count and activity feed. For a
count-only pill, use `/api/online`. Served for projects that publish it, with
30-second shared caching, under
per-project and per-client rate limits; past those it answers `429` with a
`Retry-After`.

For a complete embeddable component, see the
[live stats pill guide](/docs/live-stats-pill).

## GET /api/live

`GET https://www.analytiics.co/api/live?project=example.com`

What the live row on a dashboard shows: the count, the recent events, and the
coordinates the globe plots. Gated widget by widget — a project that publishes
the map but not the stream gets an empty `feed` here, not the data behind it.

Unlike `/api/realtime` this sends no CORS headers. A live count is something a
site displays on its own pages; a list of who is currently on it is not.

## POST /api/waitlist

Public. `{"email": "you@example.com", "source": "hero"}`, answering
`200 {"ok": true}`. The same answer whether the address was new or already on
the list — "you're already on it" would make this a way to ask whether a given
person signed up.

## Errors

Every failure on both hosts has the same JSON body:

```json
{
  "error": "no such project",
  "code": "project_not_found",
  "message": "no such project",
  "hint": "Check the domain. This is also the answer for a project that exists but belongs to someone else, so it never confirms whether a domain is taken.",
  "docs": "https://www.analytiics.co/docs/api"
}
```

Switch on `code`. It is stable, it maps to exactly one HTTP status, and unlike
the sentence it will not be reworded. `error` and `message` carry the same
sentence: `error` is the field existing Analytiics clients already read,
`message` the one a generic client looks for. A validation failure adds
`detail`, naming the first field that failed. The full list of codes is the
`code` enum in [the OpenAPI document](/openapi.json).

Three of them are worth knowing before you meet them:

- `rate_limited` (429) always comes with `Retry-After`. Wait it out and keep
  showing the last good response rather than clearing it.
- `key_verification_unavailable` (503) means your write key may well be valid
  and we could not check it. It is deliberately not a 4xx: retry rather than
  dropping the event.
- `unauthorized` (401) on the server event path is the same answer whether the
  key is wrong or the project does not exist, so it cannot be used to enumerate
  projects.

A path that does not exist answers `404` with `code: "not_found"` — as JSON on
both hosts, never as an HTML error page. A path that exists but does not accept
the method you used answers `405` with `code: "method_not_allowed"` and an
`Allow` header listing the methods it does take.

## Rate limits and ceilings

Public dashboard rendering, filtered rendering and realtime polling each have a
per-project ceiling per minute. Past a ceiling, the last good render is served
rather than a fresh query — a page going viral costs a cached response rather
than a bill.
