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
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.
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 }
}' {
"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
| Metric | Meaning |
|---|---|
visitors | Unique visitors. Exact within a day, estimated over longer ranges — see the warning in meta. |
visits | Sessions. |
pageviews | Pageview events. |
views_per_visit | Pageviews divided by visits, to two decimals. |
bounce_rate | Percentage, to one decimal. |
visit_duration | Mean session length, whole seconds. |
events | Pageviews plus conversions. |
conversion_rate | Percentage. Requires event:goal as a dimension or in a filter. |
total_revenue | Revenue in major units. Business tier. |
average_revenue | Revenue per conversion. Business tier. |
scroll_depth | Accepted, but returns null today with a not_materialised warning. |
time_on_page | Same — 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.
"filters": [
["is", "visit:country", ["DE", "AT"]],
["matches", "event:page", ["/blog/**"]],
["is_not", "visit:utm_medium", ["cpc"]]
] | Operator | Form |
|---|---|
is / is_not | [op, dimension, values] — values are OR-ed together |
contains / contains_not | Substring match |
matches / matches_not | Path 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. -
matchesis 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.
| Value | Meaning |
|---|---|
"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
"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
curl "https://api.absolutelyanalytics.com/api/v2/realtime/visitors?site_id=example.com" \
-H "Authorization: Bearer aa_live_..." { "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.
{
"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" }]
} | Status | When |
|---|---|
| 401 | No credential, or one that is not valid. One answer for every cause. |
| 402 | Your plan does not carry what you asked for, or the range is outside retention. The body names the plan. |
| 403 | The key lacks the scope, or a shared link tried to widen its own filters. |
| 404 | No such site in your account. Never 403 — see the questions below. |
| 413 | Body over 64 KB. |
| 422 | The request did not validate, or used a filter operator that does not exist. |
| 429 | Over the request budget. Retry-After tells you how long to wait. |
| 500 | Ours. 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.