Docs

Stats API reference

One POST endpoint that answers almost any question, one GET for realtime, and a compatibility layer for anything already written against the older shape.

Verified against the shipping code, September 2026

Before you start

The Stats API is a Business-tier capability. On a plan that does not carry it, every request returns 402 with the plan you would need named in the body. The same applies to two things inside a query: breaking down by custom properties and reading revenue metrics are gated on the same tier.

Create an API key in your dashboard. Keys are shown once; only a hash of the key is kept, so a lost key is replaced rather than recovered.

Authentication

Every request
Authorization: Bearer aa_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

Live keys begin aa_live_ and sandbox keys aa_test_. Anything missing, malformed, unknown, revoked or expired returns the same 401 invalid-credentials document — one undifferentiated answer, so that a caller cannot use the error to tell a wrong key from a revoked one.

A shared dashboard link can also be used as a credential, by sending its slug in an X-Shared-Link header and its password, if it has one, in X-Shared-Link-Password. Such a request can only ever narrow the link's own filters, never widen them.

POST /api/v2/query

The whole API, more or less. One body describes the question.

Visitors and pageviews by country, last 7 days
curl https://api.absolutelyanalytics.com/api/v2/query \
  -H "Authorization: Bearer aa_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "site_id": "example.com",
    "metrics": ["visitors", "pageviews"],
    "date_range": "7d",
    "dimensions": ["visit:country"],
    "order_by": [["visitors", "desc"]],
    "pagination": { "limit": 10 }
  }'
The response
{
  "results": [
    { "metrics": [1284, 3011], "dimensions": ["GB"] },
    { "metrics": [902, 2140], "dimensions": ["DE"] }
  ],
  "meta": { "time_labels": null },
  "query": {
    "site_id": "example.com",
    "metrics": ["visitors", "pageviews"],
    "date_range": ["2026-08-31T00:00:00+00:00", "2026-09-06T23:59:59+00:00"],
    "dimensions": ["visit:country"]
  }
}

metrics and dimensions in each row are positional — they line up with the arrays you sent, in order. The query block echoes what was actually run, which matters because the date range may have been clamped to your retention.

Request body

Unknown keys are rejected rather than ignored, so a typo is an error and not a silently dropped option. The body is capped at 64 KB.

Field Required Notes
site_id yes The site's domain, as registered, or its opaque identifier.
metrics yes 1 to 12, no duplicates.
date_range yes A shorthand string or an explicit pair — see below.
dimensions no Up to 5, no duplicates, at most one time dimension.
filters no Array of filter trees, combined with AND. Max 100 nodes, 8 deep.
order_by no Pairs of [key, "asc" | "desc"]. Every key must be in metrics or dimensions.
pagination no limit 1–10000 (default 10000), offset 0–1000000.
include no time_labels, total_rows, comparisons.

Metrics

MetricMeaning
visitorsUnique visitors. Exact within a day, estimated over longer ranges — see the warning in meta.
visitsSessions.
pageviewsPageview events.
views_per_visitPageviews divided by visits, to two decimals.
bounce_ratePercentage, to one decimal.
visit_durationMean session length, whole seconds.
eventsPageviews plus conversions.
conversion_ratePercentage. Requires event:goal as a dimension or in a filter.
total_revenueRevenue in major units. Business tier.
average_revenueRevenue per conversion. Business tier.
scroll_depthAccepted, but returns null today with a not_materialised warning.
time_on_pageSame — accepted, returns null today.

The last two are documented rather than hidden because the API accepts them. They will not error, and they will not give you a number. Do not build a chart on them yet.

Dimensions

Event

event:name, event:page, event:hostname, event:goal

Visit

visit:source, visit:channel, visit:referrer, visit:utm_medium, visit:utm_source, visit:utm_campaign, visit:utm_content, visit:utm_term, visit:screen, visit:device, visit:browser, visit:browser_version, visit:os, visit:os_version, visit:country, visit:region, visit:city, visit:country_name, visit:region_name, visit:city_name, visit:entry_page, visit:exit_page, visit:entry_page_hostname, visit:exit_page_hostname

Time

time (bucket chosen from the range), time:hour, time:day, time:week, time:month

Custom properties

event:props:<key>, where the key is one you sent on an event. A Business-tier capability.

Filters

A filter is a JSON array, not an object. The outer filters array is combined with AND.

German or Austrian visitors, on any blog page, not from a paid campaign
"filters": [
  ["is", "visit:country", ["DE", "AT"]],
  ["matches", "event:page", ["/blog/**"]],
  ["is_not", "visit:utm_medium", ["cpc"]]
]
OperatorForm
is / is_not[op, dimension, values] — values are OR-ed together
contains / contains_notSubstring match
matches / matches_notPath glob — * within a segment, ** across
and / or["and", [node, node]]
not["not", node]
has_done / has_not_done["has_done", node] — the visitor did this at some point in the range
  • Matching is case-sensitive by default. Add a fourth element, { "case_sensitive": false }, to a leaf to change that.
  • matches is a bounded glob, not a regular expression. Regex-only characters are rejected with a clear error rather than being silently treated as literals, and so is mixing * and ** in one pattern.
  • A goal filter is an ordinary leaf on event:goal.
  • Up to 1,000 values per leaf, each up to 2,000 bytes.

Date ranges

Every range is resolved in the site's timezone, not yours and not UTC. A bare end date includes that whole day.

ValueMeaning
"day"Today.
"month"The current calendar month.
"year"The current calendar year.
"all"From the site's creation, within retention.
"7d", "30d", "365d"N days including today. 1 to 3650.
"6mo", "12mo"N months. 1 to 120.
["2026-08-01", "2026-08-31"]An explicit range. Dates or ISO datetimes.

Comparisons and extras

This month against the same period last year, weekdays aligned
"include": {
  "time_labels": true,
  "total_rows": true,
  "comparisons": {
    "mode": "year_over_year",
    "match_day_of_week": true
  }
}

mode is previous_period, year_over_year or custom; custom requires its own date_range. Each row then carries a comparison object with the earlier period's metrics and the percentage change — which is the string "NEW" where the earlier period was zero and this one is not, because a percentage change from nothing is not a number.

time_labels emits every bucket in the range including empty ones, so a chart does not silently skip a quiet Sunday. It requires a time dimension.

include.imports is accepted and does nothing today. It sets a flag in meta and unions no data. It is listed here so that nobody plans an import reconciliation around it.

GET /api/v2/realtime/visitors

Visitors on the site right now
curl "https://api.absolutelyanalytics.com/api/v2/realtime/visitors?site_id=example.com" \
  -H "Authorization: Bearer aa_live_..."
The response
{ "visitors": 17 }

The v1 endpoints

Three query-string endpoints exist for code written against the older shape: GET /api/v1/stats/aggregate, /api/v1/stats/timeseries and /api/v1/stats/breakdown, plus /api/v1/stats/realtime/visitors, which returns a bare integer rather than an object. They take period, date, metrics as a comma-separated list, filters in the older string syntax, and interval.

They are translated onto the v2 engine, so the numbers agree. Write new code against v2 — it can express things the v1 shape cannot, including comparisons and nested filters.

Errors

Every non-2xx response is RFC 9457 application/problem+json, with a stable type URI, a status, a human detail and an instance carrying the request id. Validation failures add an errors array of JSON-pointer paths, so you are told which field was wrong rather than that something was.

A validation failure
{
  "type": "https://absolutelyanalytics.com/errors/invalid-request",
  "title": "Invalid request",
  "status": 422,
  "detail": "metrics/0: unknown metric",
  "instance": "req_01J8ZC4Q9K7X2M5N6P8R0T3V5W",
  "errors": [{ "pointer": "metrics/0", "detail": "unknown metric" }]
}
StatusWhen
401No credential, or one that is not valid. One answer for every cause.
402Your plan does not carry what you asked for, or the range is outside retention. The body names the plan.
403The key lacks the scope, or a shared link tried to widen its own filters.
404No such site in your account. Never 403 — see the questions below.
413Body over 64 KB.
422The request did not validate, or used a filter operator that does not exist.
429Over the request budget. Retry-After tells you how long to wait.
500Ours. The instance id is the thing to quote.

Error bodies never contain a stack trace, a query, or anything about how the request was served. If you need us to look something up, the request id in instance is what identifies it.

Request budget

Business plans carry a budget of 600 requests per minute, in a fixed sixty-second window. Enterprise agreements carry more. Every response — successful or not — carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, so a client can pace itself rather than discovering the limit by hitting it.

Budget is counted against the API key, never against an IP address. Responses for a closed date range are cacheable and say so in Cache-Control; a repeated identical query is answered from cache, which you can see in the X-AA-Cache header. A cached answer still costs a request against the budget.

Provisioning sites

There is a Sites API — creating sites, goals and shared links over /api/v1/sites — and it is Enterprise only. It is mentioned here so that nobody discovers that fact halfway through building against it. For every other plan, sites and goals are created in the dashboard.

Common questions

Which plan includes the Stats API?

Business, and Enterprise. It is not on the two tiers below, and asking for it there returns a payment-required error naming the plan you would need rather than an empty result.

If the API is what you are buying and your traffic is small, say so before you sign up — hello@absolutelyanalytics.com — rather than paying for a rung you do not need.

Why did I get a 404 for a site I know exists?

Because the site is not in the account that owns your API key. A request for a site belonging to someone else returns 404 site-not-found, never 403 — the same answer you get for a site that has never existed.

That is deliberate. A 403 would confirm that a site with that name exists somewhere, which turns the API into a way of enumerating other people's domains. Check the key and the site identifier before assuming a bug.

Are unique visitors exact?

Within a single day, yes. Over a longer range they are an estimate, and the response tells you so: the meta.metric_warnings object carries an approximate_uniques entry naming the expected error, which is a fraction of one percent.

Every analytics product does this, because exact distinct counts over long ranges are ruinously expensive. Not every one of them tells you which numbers are estimated. Read the warning rather than assuming.

Can I query a range older than my retention window?

Partly. A range that reaches back before your plan's retention is silently clamped to what you can read, and the range actually used comes back in query.date_range — so compare that against what you asked for rather than trusting your own input. A range entirely outside retention is refused with a payment-required error naming the earliest readable date.

Is there a client library?

No. It is one POST with a JSON body and a bearer token, which is a smaller thing to learn than a library would be. Every example on this page is plain fetch and copies straight into a script.