Skip to content

Query reference

One endpoint answers nearly every question:

POST /api/v1/query

What it returns depends on dimensions. Leave it out and you get totals. Ask for a time dimension and you get a series. Ask for anything else and you get a ranked breakdown.

Three shapes

dimensionsShapeRows
OmittedAggregateOne, no dimensions key
One time dimensionTime seriesOne per bucket, oldest first
One other dimensionBreakdownTop N, most visitors first, plus meta

At most one dimension per query. More returns 400 too_many_dimensions.

Request body

{
  "site_id": 3093477,
  "metrics": ["visitors", "pageviews"],
  "date_range": "7d",
  "dimensions": ["time:day"],
  "filters": [
    { "field": "country", "operator": "is", "values": ["US"] }
  ],
  "limit": 100,
  "offset": 0
}
FieldTypeRequiredNotes
site_idintegerConditionalRequired on an all-sites key. Optional on a single-site key
metricsstring[]YesAt least one
date_rangestring or [string, string]YesPreset, Nd, or an explicit pair
dimensionsstring[]NoZero or one element
filtersobject[]NoNarrow the query
limitintegerNoBreakdowns only. Default 100, clamped to 1 to 1000
offsetintegerNoBreakdowns only. Default 0

Sending limit or offset on an aggregate or time series returns 400 limit_offset_misuse.

Date ranges

ValueWindow
"7d"Last 7 days
"30d"Last 30 days
"14d", any "Nd"Last N days, 1 to 90
"month"Current month to date
"realtime"Last 30 minutes or so. Pair with time:minute
["2026-06-01","2026-06-30"]Explicit range, inclusive, up to 366 days

Anything else is 400 invalid date_range. For a window longer than 90 days, use an explicit pair.

Windows follow the site's own timezone. The response echoes the range it actually used as concrete dates, so you never have to reproduce the calendar math yourself.

Comparing periods

Add compare to get the previous value and the percent change alongside each number:

  • "compare": "previous_period" uses the equal window immediately before date_range.
  • "compare": ["2026-05-01","2026-05-31"] uses a window you name.

An explicit compare range must be the same length as date_range, or the request is 400 compare_length_mismatch.

Compare works on all three shapes. Aggregates get a compare block per metric, time series get one per bucket, breakdowns get one per row. The per-row case is the valuable one: each row is compared against its own true previous value, even if it ranked differently then, which two separate top-N queries cannot reconstruct.

Not emitted on the event:status_code and event:goal breakdowns. The request still succeeds and the echo still shows the compared range, so check for the compare key rather than expecting an error.

Metrics

MetricMeaning
visitorsUnique visitors
pageviewsTotal pageviews
visitsSessions
visit_durationAverage visit length in seconds
bounce_ratePercent
views_per_visitPageviews per visit
engagement_timeAverage active seconds, not the same as visit_duration
eventsRaw event count. Breakdowns only
conversion_rateConverters as a percent of visitors. event:goal only
time_on_pageAverage seconds on a page. Breakdowns only
scroll_depthAverage furthest scroll, 0 to 100. Breakdowns only
exit_rateExits as a percent of pageviews. visit:exit_page only

The first seven work on aggregates and time series. The last five exist only in breakdowns, and only on dimensions that compute them. Ask for one elsewhere and the request fails with 400 rather than returning a null column.

Dimensions

Time

DimensionBucket
timeDay, alias of time:day
time:minuteMinute. Realtime only
time:hourHour
time:dayDay
time:weekWeek
time:monthMonth

The bucket has to suit the range. Hourly buckets across a long custom range return 400 dimension not valid for date_range.

Breakdowns

Each dimension computes only the metrics listed:

DimensionMetrics
visit:source, visit:referrer, visit:channelvisitors, visit_duration, bounce_rate
visit:utm_source, visit:utm_medium, visit:utm_campaign, visit:utm_content, visit:utm_termvisitors, visit_duration, bounce_rate
visit:country, visit:region, visit:cityvisitors, visit_duration, bounce_rate
visit:browser, visit:browser_version, visit:os, visit:os_version, visit:devicevisitors, visit_duration, bounce_rate
visit:entry_pagevisitors, visits, visit_duration
visit:exit_pagevisitors, visits, exit_rate
event:page, event:foldervisitors, pageviews, bounce_rate, time_on_page, scroll_depth
event:hostnamevisitors, visits, pageviews, visit_duration, bounce_rate, views_per_visit
event:status_codepageviews
event:namevisitors, events
event:props:<key>visitors, events
event:goalvisitors, events, conversion_rate

There is no property dimension beyond event:props:<key>, and no dimension outside this list.

event:name lists custom events fired with data-statable-event. Automatic pageview and engagement events are excluded.

event:folder groups pages by first path segment, matching the Folders report.

event:status_code ranks HTTP status codes, value as a string such as "404".

event:goal ranks the site's configured goals. visitors is unique converters, events is total conversions, conversion_rate is converters as a percent of all visitors in the window. Add a filter to measure conversions inside a segment.

event:props:<key> is dynamic. Replace <key> with a property name to rank that property's values, (none) where unset. It belongs to a single custom event, so it requires an event filter naming that event, otherwise 400 event_filter_required:

{
  "site_id": 3093477,
  "metrics": ["visitors", "events"],
  "date_range": "30d",
  "dimensions": ["event:props:plan"],
  "filters": [{ "field": "event", "operator": "is", "values": ["Signup"] }]
}

Discover which keys a site records with GET /api/v1/props.

Geo values

visit:country, visit:region and visit:city return a code as the dimension value and the readable name in a sibling labels map. Country is ISO alpha-2, city is a numeric geoname id.

The code is exactly what the matching filter accepts, so a breakdown value drops straight into the next query. No other breakdown carries labels, because its value is already both.

Filters

{ "field": "country", "operator": "is", "values": ["US"] }

Fields: browser, browser_version, os, os_version, device, country, city, region, hostname, utm_source, utm_medium, utm_campaign, utm_content, utm_term, entry_page, exit_page, source, channel, referrer, page, code, event.

Note the field name drops the visit: or event: prefix the dimension carries.

Operators: is, is_not, contains, does_not_contain.

Values: at least one, OR'd together. A value may not contain a comma.

Geo fields take codes, never display names. country wants "US", city wants a geoname id like "5128581". Run the matching breakdown first to collect them.

The event filter is special. It restricts the whole query to sessions in which that custom event fired, so a sources breakdown filtered by event answers "where did the people who signed up come from". It accepts only is, only one value, and rejects pageview and engagement.

Filters on arbitrary custom properties are not supported.

Responses

Every response is {"results": [...], "query": {...}}. The query block echoes what the server resolved, including date_range as concrete dates. A top-level has_import flag is true when the window overlaps GA4-imported data, meaning the numbers blend imported and native traffic.

Aggregate:

{
  "results": [
    { "metrics": { "visitors": 1234, "pageviews": 5678 } }
  ],
  "query": {
    "site_id": 3093477,
    "metrics": ["visitors", "pageviews"],
    "date_range": ["2026-07-17", "2026-07-23"]
  }
}

Time series, oldest bucket first. Day, week and month labels are YYYY-MM-DD; hour labels are YYYY-MM-DD HH:00.

{
  "results": [
    { "dimensions": { "time:day": "2026-07-14" }, "metrics": { "visitors": 210 } },
    { "dimensions": { "time:day": "2026-07-15" }, "metrics": { "visitors": 198 } }
  ]
}

Breakdown, with meta:

{
  "results": [
    { "dimensions": { "visit:country": "US" }, "labels": { "visit:country": "United States" }, "metrics": { "visitors": 500 } },
    { "dimensions": { "visit:country": "DE" }, "labels": { "visit:country": "Germany" }, "metrics": { "visitors": 180 } }
  ],
  "meta": { "total": 57, "limit": 100, "offset": 0, "has_more": false }
}
metaMeaning
totalDistinct values in the window, across all pages
limitThe limit actually applied, after clamping
offsetThe offset actually applied
has_moreMore rows exist. Page on with offset += limit

meta appears on breakdowns only.

Examples

Totals for last week:

curl -s -X POST https://statable.com/api/v1/query \
  -H "Authorization: Bearer stbl_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"site_id":3093477,"metrics":["visitors","pageviews"],"date_range":"7d"}'

Daily visitors for one country, over a named month:

curl -s -X POST https://statable.com/api/v1/query \
  -H "Authorization: Bearer stbl_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "site_id": 3093477,
        "metrics": ["visitors"],
        "date_range": ["2026-06-01","2026-06-30"],
        "dimensions": ["time:day"],
        "filters": [{"field":"country","operator":"is","values":["US"]}]
      }'

Top 10 pages this month, with last month beside them:

curl -s -X POST https://statable.com/api/v1/query \
  -H "Authorization: Bearer stbl_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "site_id": 3093477,
        "metrics": ["visitors","pageviews","time_on_page"],
        "date_range": "month",
        "dimensions": ["event:page"],
        "compare": "previous_period",
        "limit": 10
      }'

Next steps


Ready to take control of your web analytics? Try Statable free for 30 days — no credit card required, full feature access, GDPR-compliant by default. Start your free trial or view a live demo.