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
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.
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.
| Plan | API access | Retention |
|---|---|---|
| Growth | Yes | 3 years |
| Scale | Yes | 5 years |
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"
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.
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": "…" } }| HTTP | code | When |
|---|---|---|
| 401 | unauthenticated | Missing/malformed Bearer token, unknown key, revoked, or expired. |
| 403 | plan_required | Valid key, but the plan lacks API access. Upgrade — do not retry. |
| 404 | not_found | Site/group is unknown, unowned, or outside this key's allowlist. |
| 400 | invalid_request | A validation failure — an unknown argument, a bad range, or nonsense limit. |
| 429 | rate_limited | A rate limit was exceeded. |
| 500 | internal | An 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.
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.
| Plan | Burst (per key) | Refill | Daily (per org) |
|---|---|---|---|
| Growth | 120 tokens | 60/min | 10,000 |
| Scale | 600 tokens | 300/min | 50,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.
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_sitesGET /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_summaryGET /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_timeseriesGET /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_pagesGET /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_referrersGET /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_countriesGET /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_breakdownGET /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_campaignsGET /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_breakdownGET /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_eventsGET /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_realtimeGET /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
}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.pctisnullwhen the previous period was zero — that means there was no baseline, not zero change.- For
range=todaythe comparison is the same elapsed window yesterday (midnight → the same time yesterday), not all of yesterday. compareis not supported on the top-N endpoints in v1; passing it there is aninvalid_request.
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_sites | List sites |
| get_traffic_summary | Traffic summary |
| get_traffic_timeseries | Traffic timeseries |
| get_top_pages | Top pages |
| get_top_referrers | Top referrers |
| get_countries | Top countries |
| get_breakdown | Technology breakdown |
| get_top_campaigns | Top campaigns |
| get_utm_breakdown | UTM breakdown |
| get_top_events | Top custom events |
| get_realtime | Realtime |
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.