Read API · v1

Point an agent at your
own analytics.

A read-only HTTP API and an MCP server over the same handlers. Because our events carry no personal data, connecting an AI agent ships no user-level behavior to a model provider — the thing that makes pointing GA4 or Amplitude at an LLM a privacy problem simply isn't here.

Base URL · https://rationalmetrics.io/api/v1

01

Authentication

Every request carries an API key as a bearer token. Mint one in Settings → API keys (organization admins only); the plaintext is shown exactly once at creation.

Authorization: Bearer rm_read_xxxxxxxxxxxxxxxxxxxx

Keys are scoped to a single organization and are read-only. A key may optionally be restricted to a subset of sites — anything outside that allowlist is invisible to it, indistinguishable from a site in another organization. The organization is derived from the key and can never be passed as a parameter.

02

Access & retention

The read API and MCP server are a Growth-and-above feature. A valid key on the Free or Starter plan authenticates but is answered with 403 plan_required.

A request that reaches further back than the plan's retention window is clamped, not rejected: from is moved forward to the earliest retained day and the response says so in meta.range_clamped and meta.requested_from.

PlanAPI accessRetention
GrowthYes3 years
ScaleYes5 years
03

Base URLs

The REST API is rooted at /api/v1; the MCP server is a single endpoint at /api/mcp. Both authenticate with the same key.

REST   https://rationalmetrics.io/api/v1
MCP    https://rationalmetrics.io/api/mcp

A first request end to end:

curl -s "https://rationalmetrics.io/api/v1/stats/summary?site=aB3xK9&range=7d" \
  -H "Authorization: Bearer rm_read_xxxxxxxxxxxx"
04

Response envelope

Every successful response is a data payload plus a meta block describing the window that was actually served — which may differ from what was asked for if the range was clamped.

{
  "data": [ ... ] | { ... },
  "meta": {
    "site": "aB3xK9",       // or null for org-wide / group scope
    "group": null,
    "from": "2026-07-07T00:00:00.000Z",
    "to": "2026-07-14T00:00:00.000Z",
    "range_clamped": false
    // "requested_from" is present only when range_clamped is true
  }
}

On endpoints with no time window (/sites, /stats/realtime), from and to are null.

05

Errors

Every error is a single error object with a stable code and a message written to be actionable — by a human or by a model reading it directly.

{ "error": { "code": "rate_limited", "message": "…" } }
HTTPcodeWhen
401unauthenticatedMissing/malformed Bearer token, unknown key, revoked, or expired.
403plan_requiredValid key, but the plan lacks API access. Upgrade — do not retry.
404not_foundSite/group is unknown, unowned, or outside this key's allowlist.
400invalid_requestA validation failure — an unknown argument, a bad range, or nonsense limit.
429rate_limitedA rate limit was exceeded.
500internalAn unexpected server error. Internals are never leaked.

A 401 means we don't know who you are; a 403 means we do and you may not. An unknown site is always a 404, never a 403 — a 403 would confirm the site exists.

06

Rate limits

Two layers. A per-key burst bucket absorbs the natural shape of agent traffic — a turn that fans out ten calls in a few seconds, then goes quiet — while a per-organization daily ceiling caps total spend across every key the org holds.

PlanBurst (per key)RefillDaily (per org)
Growth120 tokens60/min10,000
Scale600 tokens300/min50,000

Cost is weighted by range, not by call count: a request spends 1 + floor(rangeDays / 30) tokens, so a 7-day summary costs 1 while a full-year breakdown costs 13. Long-range queries are self-limiting without being forbidden.

Responses carry X-RateLimit-Limit and X-RateLimit-Remaining; a 429 adds Retry-After (seconds) and a message stating how long to wait.

07

Endpoints

Eleven read endpoints. Each maps one-to-one to an MCP tool of the same shape (the tool name is noted on each). Unless stated otherwise every stats endpoint accepts the shared window parameters below.

Shared window parameters

sitestring
Site publicId from list_sites. Omit to span every site this key can read.
groupuuid
Site group id. Mutually exclusive with site.
rangetoday | 7d | 30d | 90d
Preset window (default 7d). Mutually exclusive with from/to.
fromISO 8601
Explicit window start. Clamped forward to the plan's retention limit if older — check meta.range_clamped.
toISO 8601
Explicit window end. Defaults to now.

List sites

MCP: list_sites

GET /sites

The sites this key may read. Start here to discover the site publicId values every other call accepts.

Response · data

[
  {
    "site": "aB3xK9",
    "name": "Acme Marketing",
    "domain": "acme.com",
    "timezone": "UTC"
  }
]

Traffic summary

MCP: get_traffic_summary

GET /stats/summary

Headline totals for the window. Supports compare for period-over-period deltas.

Additional parameters

compareboolean
When true, also return the immediately preceding same-length period and the delta.

Response · data

{
  "uniqueVisitors": 4820,
  "pageviews": 9310,
  "engagedViews": 6740,
  "totalEvents": 512
}

Traffic timeseries

MCP: get_traffic_timeseries

GET /stats/timeseries

Visitors and pageviews bucketed over the window. Granularity is derived from the range (≤2 days → hourly, else daily) and is not requestable. Supports compare.

Additional parameters

compareboolean
When true, also return the immediately preceding same-length period and the delta.

Response · data

{
  "buckets": [
    { "bucket": "2026-07-07", "visitors": 610, "pageviews": 1180 },
    { "bucket": "2026-07-08", "visitors": 705, "pageviews": 1322 }
  ]
}

Top pages

MCP: get_top_pages

GET /stats/pages

Most-viewed page paths in the window.

Additional parameters

limitint
Rows to return (default 10). Values above 100 are clamped to 100.

Response · data

[
  { "pathname": "/", "pageviews": 3120 },
  { "pathname": "/pricing", "pageviews": 880 }
]

Top referrers

MCP: get_top_referrers

GET /stats/referrers

Top referring external domains driving traffic.

Additional parameters

limitint
Rows to return (default 10). Values above 100 are clamped to 100.

Response · data

[
  { "referrer": "news.ycombinator.com", "count": 412 },
  { "referrer": "google.com", "count": 388 }
]

Top countries

MCP: get_countries

GET /stats/countries

Visitor counts by country, derived from a hashed, discarded IP. No IP is stored.

Additional parameters

limitint
Rows to return (default 10). Values above 100 are clamped to 100.

Response · data

[
  { "country": "US", "visitors": 2140 },
  { "country": "DE", "visitors": 610 }
]

Technology breakdown

MCP: get_breakdown

GET /stats/breakdown

Visitor counts by one technology dimension.

Additional parameters

dimensionrequiredbrowser | os | device
Which technology dimension to break down by.
limitint
Rows to return (default 10). Values above 100 are clamped to 100.

Response · data

[
  { "value": "Chrome", "count": 3010 },
  { "value": "Safari", "count": 1450 }
]

Top campaigns

MCP: get_top_campaigns

GET /stats/campaigns

Top marketing campaigns (utm_campaign) by traffic.

Additional parameters

limitint
Rows to return (default 10). Values above 100 are clamped to 100.

Response · data

[
  {
    "source": "newsletter",
    "medium": "email",
    "campaign": "july-launch",
    "visitors": 240
  }
]

UTM breakdown

MCP: get_utm_breakdown

GET /stats/utm

Traffic broken down by one UTM dimension.

Additional parameters

dimensionrequiredutm_source | utm_medium | utm_campaign
Which UTM dimension to break down by.
limitint
Rows to return (default 10). Values above 100 are clamped to 100.

Response · data

[
  { "value": "newsletter", "count": 240 },
  { "value": "twitter", "count": 180 }
]

Top custom events

MCP: get_top_events

GET /stats/events

Most frequent custom (non-pageview) events in the window.

Additional parameters

limitint
Rows to return (default 10). Values above 100 are clamped to 100.

Response · data

[
  { "eventType": "signup", "count": 96 },
  { "eventType": "add_to_cart", "count": 61 }
]

Realtime

MCP: get_realtime

GET /stats/realtime

Visitors and pageviews in the last 30 minutes. Ignores range arguments — the window is fixed.

Parameters

sitestring
Site publicId from list_sites. Omit to span every site this key can read.
groupuuid
Site group id. Mutually exclusive with site.

Response · data

{
  "visitors": 37,
  "pageviews": 52
}
08

Comparing periods

Pass compare=true to /stats/summary or /stats/timeseriesto get the same-length period immediately before the window, plus the delta — so an agent doesn't have to make two calls and do arithmetic. Each compared value becomes:

{ "current": 1234, "previous": 1000, "change": { "abs": 234, "pct": 23.4 } }
  • change.pct is null when the previous period was zero — that means there was no baseline, not zero change.
  • For range=today the comparison is the same elapsed window yesterday (midnight → the same time yesterday), not all of yesterday.
  • compare is not supported on the top-N endpoints in v1; passing it there is an invalid_request.
09

MCP server

The same eleven handlers are exposed as an MCP server over stateless streamable HTTP at /api/mcp. There is nothing to install — point a remote-MCP-capable client at the URL and paste the key as a bearer token.

{
  "mcpServers": {
    "rational-metrics": {
      "url": "https://rationalmetrics.io/api/mcp",
      "headers": { "Authorization": "Bearer rm_read_xxxxxxxxxxxx" }
    }
  }
}

Tool results return the same data payload as the REST API, with meta folded into the tool text so the model sees the effective window it actually got. The eleven tools:

list_sitesList sites
get_traffic_summaryTraffic summary
get_traffic_timeseriesTraffic timeseries
get_top_pagesTop pages
get_top_referrersTop referrers
get_countriesTop countries
get_breakdownTechnology breakdown
get_top_campaignsTop campaigns
get_utm_breakdownUTM breakdown
get_top_eventsTop custom events
get_realtimeRealtime
10

Reading the numbers

The dataset is pseudonymous by construction — the collector hashes and discards the IP and user-agent at ingest, so there is no PII to reason about. A few semantics matter when interpreting results:

  • A visitor (uniqueVisitors) is a daily-salted pseudonymous identifier — not a person, and not stable across days (on strict-privacy sites, not even within a day). It is not a count of people or accounts.
  • Engaged views are pageviews where a human was verifiably present, and are always ≤ pageviews. They are not interchangeable with pageviews.
  • All dates and time buckets are UTC in v1.