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/queryand the realtime stream need onlyread; creating sites/sources needswrite; API key and member management needadmin.
POST /v1/query HTTP/1.1
Host: api.example.com
Authorization: Bearer ak_live_...
Content-Type: application/jsonPOST /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"]]
}| Field | Type | Notes |
|---|---|---|
site_id | uuid | required |
metrics | Metric[] | required. pageviews, visitors, sessions, bounce_rate, avg_duration |
dimensions | Dimension[] | 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 |
filters | Filter[] | { field, op, value }; op is eq | neq | contains | in; value is a string or array of strings |
range | {from, to} | date range |
granularity | Granularity | auto | hour | day | week | month | none |
include_bots | boolean | default false |
source_ids | uuid[] | null | restrict to specific sources, e.g. to compare coverage |
limit | integer | caps 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) withts,event_name,source_kind,path,referrer_host,country,device_type,browser,is_botandbot_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 individualeventmessages 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 hourspageviews_24h: pageview + enrichment rows in the last 24 hoursis_primary: whether this source is currently the site's primary source under itsprimary_source_policylast_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}. APOSTalso creates the site'sscriptsource. 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
}| Limit | Free | Pro | Business |
|---|---|---|---|
| Sites | 3 | 20 | 100 |
| Events / month | 100k | 2M | 20M |
| Events / minute / site | 600 | 6,000 | 30,000 |
| Events / minute / visitor | 120 | 240 | 600 |
| API keys | 2 | 10 | 50 |
| Members | 2 | 10 | 100 |
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-siteFull reference
The complete OpenAPI 3.1 document (every schema, every endpoint, every
status code) is served at /v1/openapi.json on the API host.