---
title: "Run one analytics query"
method: POST
path: "/query"
tags: ["Stats"]
---

# Run one analytics query

`POST /query`

## Request

- Base URL: `https://statable.com/api/v1`
- URL: `https://statable.com/api/v1/query`
- Auth: HTTP bearer
- TypeScript: <https://skmtc.dev/statable/apis/statable-stats-api/docs/query/post.ts> (a function with typed inputs; its response is checked against the schema)

One query returns a list of result rows. With no `dimensions` it is an aggregate (one row); with one time dimension (`time[:hour|day|week|month]`) it is a time series; with one breakdown dimension it is a top-N list plus a `meta` pagination block. The response always echoes the resolved request in `query` (most usefully `date_range` normalized to concrete `[from,to]` dates). At most one dimension per query.

## Request body

- QueryRequest
  - `site_id` integer: Required for an all-sites key; optional for a single-site key (defaults to the key's site). Omitting it on an all-sites key → `site_id_required`.
  - `metrics` Metric[], required
  - `date_range` union, required: A scalar string — a preset (`7d`/`30d` = last N full days; `month` = current calendar month to date; `realtime` = last ~30 min) or a bare `Nd` for the last N days, N in 1–90 (e.g. `14d`) — OR a custom inclusive `[from, to]` pair of `YYYY-MM-DD` dates with `from <= to` and a span of at most 366 days. The scalar forms are exactly what `GET /sites` accepts; for a window over 90 days use a custom pair.
    - string
    - string[]
  - `dimensions` Dimension[]: Zero or one element. Omit / empty = aggregate.
  - `filters` Filter[]
    - `field` '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', required: Field name WITHOUT the visit:/event: prefix. Geo fields take codes, not names (country/region = ISO/subdivision codes, city = numeric geoname_id — the values geo breakdowns return). The `event` field filters by a custom event name (an `event:name` breakdown value) and restricts the whole query to that event's sessions; it accepts ONLY operator `is` with a single non-standard value (not `pageview` / `engagement`) — other forms return `invalid_filter`.
    - `operator` 'is' | 'is_not' | 'contains' | 'does_not_contain', required
    - `values` string[], required: OR-ed together. A value may contain commas.
  - `limit` integer: Breakdown queries only. Clamped to 1–1000. Sending it on an aggregate or time-series query → `limit_offset_misuse`.
  - `offset` integer: Breakdown queries only. Sending it on an aggregate or time-series query → `limit_offset_misuse`.
  - `compare` union: `"previous_period"` (equal-length window before date_range) or a custom `["YYYY-MM-DD","YYYY-MM-DD"]` pair. Adds a `compare` {value, change%} block — per metric (aggregate), per row (breakdown, each row's true previous value; not on event:status_code/event:goal), or per bucket (time-series) — plus `compare_date_range` to the query echo. The compare range must be the same length as date_range (`previous_period` always is) → an unequal custom pair yields `compare_length_mismatch`.
    - 'previous_period'
    - string[]

## Response `200`

Query result.

- QueryResponse
  - `results` ResultRow[], required
    - `dimensions` object: Absent for aggregate queries. Keyed by the requested dimension string. For geo breakdowns the value is the round-trip code (visit:country → ISO alpha-2, visit:region → subdivision code, visit:city → numeric geoname_id) — exactly what the matching filter accepts.
    - `labels` object: Present only on geo breakdowns — the human-readable display name for the code in `dimensions`, keyed by the same dimension.
    - `metrics` object: The requested metrics for this row.
    - `compare` object: Present only when the request set `compare`. Keyed by metric → {value, change}: the metric's value in the compare window and the percent change vs it. Not emitted on event:status_code / event:goal.
  - `meta` Meta: Present only on breakdown responses.
    - `total` integer: Total distinct values for the window, across all pages.
    - `limit` integer: Effective limit after clamping.
    - `offset` integer
    - `has_more` boolean: True when offset + returned rows < total. Page again with offset += limit.
  - `query` QueryEcho, required: Echo of the resolved request.
    - `site_id` integer
    - `metrics` Metric[]
    - `date_range` string[]: Always the resolved concrete [from, to] YYYY-MM-DD window (presets normalized).
    - `compare_date_range` string[]: Present only when `compare` was set — the resolved [from, to] of the compare window.
    - `dimensions` Dimension[]
    - `filters` Filter[]
      - `field` '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', required: Field name WITHOUT the visit:/event: prefix. Geo fields take codes, not names (country/region = ISO/subdivision codes, city = numeric geoname_id — the values geo breakdowns return). The `event` field filters by a custom event name (an `event:name` breakdown value) and restricts the whole query to that event's sessions; it accepts ONLY operator `is` with a single non-standard value (not `pageview` / `engagement`) — other forms return `invalid_filter`.
      - `operator` 'is' | 'is_not' | 'contains' | 'does_not_contain', required
      - `values` string[], required: OR-ed together. A value may contain commas.
  - `has_import` boolean: True when the site has imported (e.g. GA4) data overlapping the queried window.

## Other responses

- `400`: Invalid request. Branch on the stable `code`: invalid_request, metrics_required, unknown_metric, too_many_dimensions, unknown_dimension, invalid_date_range, invalid_interval, metric_not_available, invalid_filter, event_filter_required, invalid_compare, compare_length_mismatch, site_id_required, limit_offset_misuse.
- `401`: Missing/malformed/invalid/revoked bearer token (`unauthorized`), or a key past its expiry (`key_expired`; the message names the expiry time).
- `403`: `insufficient_scope` (the key lacks the `read` scope every endpoint here requires), `key_not_scoped` (single-site key asked for a different site), `tracking_inactive` (the site's tracking has stopped) or `plan_expired` (the site owner's plan has expired: tracking may still run through the grace period, but reports are closed until the owner chooses a plan).
- `404`: `unknown_site` — the `site_id` does not exist OR the key's owner has no access. Deliberately indistinguishable (same status AND code) so a key cannot enumerate which site_ids exist. `unknown_funnel` (funnel endpoints only) — the funnel id is not found for the resolved site.
- `429`: Hourly account (default 2000/h) or per-key (default 600/h) limit hit (`rate_limited`). Retry after the `Retry-After` seconds. The X-RateLimit-* headers report the window that tripped (account on an account limit).
- `500`: Unexpected server error (`internal`). Report it with the `request_id` from the body or the X-Request-ID header: it is what lets the exact log entry be found, and without it a 500 can only be matched by guessing at a time window.

---

[API](https://skmtc.dev/statable/apis/statable-stats-api.md) - [All operations](https://skmtc.dev/statable/apis/statable-stats-api/llms.txt) - [OpenAPI document](https://skmtc.dev/statable/apis/statable-stats-api/revisions/352b6d47c186?raw)
