MetrikStack

API

Authentication, the query endpoint, granularity, realtime, sources and members.

The public API is the single interface used by the dashboard, the CLI and the MCP server. There are no dashboard-only endpoints. It is versioned under /v1, JSON in and out, and the OpenAPI spec is served at /v1/openapi.json on the API host.

Authentication

Two schemes, both accepted on every authenticated endpoint:

  • Session cookie (metrikstack_session, httpOnly, same-site), used by the dashboard after magic-link sign-in.
  • API key: Authorization: Bearer ak_.... Keys are created from the dashboard (session auth only; keys can't mint other keys) and carry scopes: read, write, admin. POST /v1/query and the realtime stream need only read; creating sites/sources needs write; API key and member management need admin.
POST /v1/query HTTP/1.1
Host: api.example.com
Authorization: Bearer ak_live_...
Content-Type: application/json

POST /v1/query

The single analytics query endpoint. Every report in the dashboard (the main chart, top pages, referrers, countries, devices, custom events) is one shape of this request.

{
  "site_id": "…",
  "metrics": ["pageviews", "visitors", "sessions", "bounce_rate", "avg_duration"],
  "dimensions": ["path"],
  "filters": [{ "field": "country", "op": "eq", "value": "NL" }],
  "range": { "from": "2026-09-01", "to": "2026-09-11" },
  "granularity": "auto",
  "include_bots": false,
  "source_ids": null,
  "limit": 50,
  "order": [["pageviews", "desc"]]
}
FieldTypeNotes
site_iduuidrequired
metricsMetric[]required. pageviews, visitors, sessions, bounce_rate, avg_duration
dimensionsDimension[]path, hostname, referrer_host, utm_source, utm_medium, utm_campaign, country, region, device_type, browser, os, language, event_name, bot_name, bot_category, source_kind, cache_status, status_code
filtersFilter[]{ field, op, value }; op is eq | neq | contains | in; value is a string or array of strings
range{from, to}date range
granularityGranularityauto | hour | day | week | month | none
include_botsbooleandefault false
source_idsuuid[] | nullrestrict to specific sources, e.g. to compare coverage
limitintegercaps returned rows
order[field, "asc"|"desc"][]

Response (QueryResult):

{
  "granularity": "day",
  "dimensions": ["path"],
  "metrics": ["pageviews"],
  "rows": [
    { "bucket": 1756684800, "dimensions": ["/blog/hello"], "metrics": [128] }
  ]
}

bucket is Unix seconds of the bucket start, or null for an un-bucketed (no time dimension) query. dimensions and metrics on each row line up positionally with the arrays in the request.

Example 1: time series

The main dashboard chart: daily pageviews and visitors over a range, no dimension breakdown.

{
  "site_id": "b6c4...",
  "metrics": ["pageviews", "visitors"],
  "range": { "from": "2026-08-01", "to": "2026-09-11" },
  "granularity": "auto"
}
{
  "granularity": "day",
  "dimensions": [],
  "metrics": ["pageviews", "visitors"],
  "rows": [
    { "bucket": 1754006400, "dimensions": [], "metrics": [812, 640] },
    { "bucket": 1754092800, "dimensions": [], "metrics": [790, 615] }
  ]
}

Example 2: top pages

A ranked breakdown by path, no time bucketing (granularity: "none"):

{
  "site_id": "b6c4...",
  "metrics": ["pageviews", "visitors"],
  "dimensions": ["path"],
  "range": { "from": "2026-09-01", "to": "2026-09-11" },
  "granularity": "none",
  "limit": 10,
  "order": [["pageviews", "desc"]]
}
{
  "granularity": "none",
  "dimensions": ["path"],
  "metrics": ["pageviews", "visitors"],
  "rows": [
    { "bucket": null, "dimensions": ["/"], "metrics": [4210, 3190] },
    { "bucket": null, "dimensions": ["/blog/hello"], "metrics": [1284, 1100] }
  ]
}

Example 3: filtered breakdown

Countries for traffic from a single campaign, excluding bots (the default), restricted to a specific source (e.g. to check what the edge integration alone is seeing):

{
  "site_id": "b6c4...",
  "metrics": ["pageviews", "visitors", "bounce_rate"],
  "dimensions": ["country"],
  "filters": [{ "field": "utm_campaign", "op": "eq", "value": "launch-2026" }],
  "range": { "from": "2026-09-01", "to": "2026-09-11" },
  "granularity": "none",
  "include_bots": false,
  "source_ids": ["a1e2..."]
}

Granularity rules and point capping

granularity: "auto" picks a bucket size from the range so charts stay readable and fast:

  • range ≤ 2 days → hour
  • range ≤ 90 days → day
  • longer → week

Whatever granularity is chosen, the number of returned points is capped so a chart never has to render more points than it can usefully show. Long ranges are downsampled to the bucket size above rather than returning raw per-hour data for a year, for example.

Query performance

Queries over ranges longer than 2 days may be served from pre-aggregated data. One exception: bounce_rate broken down per dimension is always computed from individual events, because bounce rate is not additive once you slice by a dimension.

This is transparent to API consumers, the query shape is identical either way, but it explains why a bounce_rate-by-path query over a long range can be slower than the equivalent pageviews-by-path query.

Realtime: GET /v1/sites/{id}/realtime

Server-Sent Events stream of a site's live traffic. Each data: line is a JSON RealtimeMessage, tagged by type:

  • visitors: { "type": "visitors", "current": 42 }. Visitors seen in the last 5 minutes (humans only). Sent on connect and every 10 seconds.
  • event: { "type": "event", "event": LiveEvent }, a privacy-safe projection of one event (no hashes) with ts, event_name, source_kind, path, referrer_host, country, device_type, browser, is_bot and bot_name. Sent for each event as it arrives, throttled to at most 10 per second per stream.
  • burst: { "type": "burst", "count": 37, "pageviews": 30, "bots": 7 }. Sent instead of individual event messages when more than 10 events arrive within one second. Only aggregate counts, no per-event detail.

Sources status: GET /v1/sites/{id}/sources

Returns each source with:

  • events_24h: events received in the last 24 hours
  • pageviews_24h: pageview + enrichment rows in the last 24 hours
  • is_primary: whether this source is currently the site's primary source under its primary_source_policy
  • last_event_at, status, kind, name

Coverage of a secondary source relative to the primary is pageviews_24h / primary.pageviews_24h. This is what the dashboard's Sources page uses to show, e.g., "the edge integration is seeing 94% of what the script sees."

POST /v1/sites/{id}/sources creates a source and returns an ingest_token once (never again) for any kind except script, which has none.

Members and orgs

  • GET /v1/me: the current session/key's identity, orgs and scopes.
  • GET/POST /v1/orgs/{org_id}/members, PATCH/DELETE /v1/orgs/{org_id}/members/{user_id}: invite, list, change role (owner \| admin \| member), remove.
  • GET/POST /v1/api-keys, DELETE /v1/api-keys/{id}: API key management. Minting a key requires session auth (keys can't create keys).

Sites

  • GET/POST /v1/sites, GET/PATCH/DELETE /v1/sites/{id}. A POST also creates the site's script source. Deleting a site removes it and its sources; event data already ingested is kept until the end of its normal retention period.

Plans, limits and usage

Every organisation is on a plan, and the plan carries every limit in the system. GET /v1/orgs/{org_id}/usage returns the plan, its limits and what the org is currently using:

{
  "org_id": "0f8e...",
  "plan": "free",
  "limits": {
    "sites": 3,
    "events_per_month": 100000,
    "site_events_per_min": 600,
    "visitor_events_per_min": 120,
    "api_keys": 2,
    "members": 2
  },
  "events_this_month": 21877,
  "sites": 2,
  "api_keys": 1,
  "members": 1
}
LimitFreeProBusiness
Sites320100
Events / month100k2M20M
Events / minute / site6006,00030,000
Events / minute / visitor120240600
API keys21050
Members210100

events_this_month counts events stored for the org's sites since the start of the calendar month (UTC). It is refreshed every few minutes, so it lags real time slightly.

How limits are enforced. The resource counts (sites, API keys, members) are enforced at creation time: POST /v1/sites, POST /v1/api-keys and POST /v1/orgs/{org_id}/members return 402 with a body like { "error": "plan limit reached: the free plan allows 3 sites" }. The rate and volume limits are enforced by ingest, which returns 429 once a site exceeds its per-minute rate, a visitor exceeds theirs, or the org is over its monthly event quota ({ "error": "monthly quota exceeded" }).

Plan changes are not available through the API. Contact us to move your organisation to another plan.

Rotating a source token

POST /v1/sites/{id}/sources/{source_id}/rotate issues a new ingest token for a source and returns it once, in the same shape as source creation. Script sources have no token, so rotating one is a 400.

curl -X POST https://api.example.com/v1/sites/$SITE/sources/$SOURCE/rotate \
  -H "authorization: Bearer $KEY" -H 'content-type: application/json' \
  -d '{"grace_hours": 24}'

grace_hours (0-168, default 24) is how long the previous token keeps working, so a fleet of collectors can be redeployed without dropping events. Pass 0 to revoke the old token immediately. During the grace window ingest accepts either token. A revocation can take up to a minute to take effect.

The source's token_rotated_at and previous_token_expires_at appear in GET /v1/sites/{id}/sources; the CLI surfaces the same thing as a warning:

metrikstack sources rotate my-site edge --grace-hours 2
metrikstack sources status my-site

Full reference

The complete OpenAPI 3.1 document (every schema, every endpoint, every status code) is served at /v1/openapi.json on the API host.

On this page