openapi: 3.2.0 info: title: Statable Stats API version: 1.0.0 description: Read-only public analytics API for Statable. servers: - url: https://statable.com/api/v1 description: Production - url: https://dev.statable.com/api/v1 description: Development security: - bearerAuth: [] tags: - name: Stats description: Read analytics data. paths: /sites: get: tags: - Stats operationId: listSites summary: List the sites this key can read description: Returns the sites the authenticating key may read — useful for discovering `site_id` values and token introspection. An all-sites key returns every accessible site (sorted by name); a single-site key returns just its one site (or an empty list if access was removed). parameters: - name: date_range in: query required: false description: 'A preset (`7d`/`30d`/`month`/`realtime`) or `Nd` = last N full days, N in 1–90 (e.g. `7d`, `14d`) — the same scalar forms POST /query accepts (custom `[from,to]` pairs are /query-only). When present, each site gains a `stats` block with basic aggregate metrics for the window. Omit to keep the list lean (no analytics read). These batch stats are NOT per-site-timezone — use POST /query for TZ-exact numbers. ' schema: type: string pattern: ^(month|realtime|([1-9]|[1-8][0-9]|90)d)$ examples: - 7d - 30d responses: '200': description: The accessible sites. headers: X-RateLimit-Limit: $ref: '#/components/headers/X-RateLimit-Limit' X-RateLimit-Remaining: $ref: '#/components/headers/X-RateLimit-Remaining' X-RateLimit-Reset: $ref: '#/components/headers/X-RateLimit-Reset' content: application/json: schema: $ref: '#/components/schemas/SitesResponse' '401': $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /query: post: tags: - Stats operationId: query summary: Run one analytics query description: 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. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/QueryRequest' examples: aggregate: summary: Aggregate, last 7 days value: site_id: 3093477 metrics: - visitors - pageviews date_range: 7d timeseries: summary: Daily series, one country value: site_id: 3093477 metrics: - visitors date_range: - '2026-06-01' - '2026-06-30' dimensions: - time:day filters: - field: country operator: is values: - US breakdown: summary: Top 10 countries value: site_id: 3093477 metrics: - visitors - bounce_rate date_range: 30d dimensions: - visit:country limit: 10 responses: '200': description: Query result. headers: X-RateLimit-Limit: $ref: '#/components/headers/X-RateLimit-Limit' X-RateLimit-Remaining: $ref: '#/components/headers/X-RateLimit-Remaining' X-RateLimit-Reset: $ref: '#/components/headers/X-RateLimit-Reset' content: application/json: schema: $ref: '#/components/schemas/QueryResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /subscription: get: tags: - Stats operationId: getSubscription summary: The account's subscription state description: 'The subscription of the key''s owner, in one line''s worth of fields: enough to say "Trial — N days left", not a billing page. Hobby-only accounts have no subscription and answer `status: "none"` with `only_hobby: true`.' x-required-scope: read responses: '200': description: The current subscription state. headers: X-RateLimit-Limit: $ref: '#/components/headers/X-RateLimit-Limit' X-RateLimit-Remaining: $ref: '#/components/headers/X-RateLimit-Remaining' X-RateLimit-Reset: $ref: '#/components/headers/X-RateLimit-Reset' content: application/json: schema: type: object required: - status - is_trial - only_hobby properties: status: type: string enum: - trialing - active - past_due - expired - trial_expired - none is_trial: type: boolean ends_at: type: string format: date-time description: 'When the current state stops being true — the trial''s end while trialing, the paid period''s end otherwise. Absent when unknown. ' only_hobby: type: boolean description: True when the account has hobby sites and nothing else. '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /current-visitors: get: tags: - Stats operationId: currentVisitors summary: Live count of visitors active in the last 5 minutes parameters: - name: site_id in: query required: false description: Numeric site id. Optional for a single-site key; required for an all-sites key. schema: type: integer format: int64 responses: '200': description: Realtime unique-visitor count. headers: X-RateLimit-Limit: $ref: '#/components/headers/X-RateLimit-Limit' X-RateLimit-Remaining: $ref: '#/components/headers/X-RateLimit-Remaining' X-RateLimit-Reset: $ref: '#/components/headers/X-RateLimit-Reset' content: application/json: schema: type: object properties: site_id: type: integer format: int64 visitors: type: integer format: int64 '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /props: get: tags: - Stats operationId: listPropKeys summary: List a site's custom-property keys description: 'The custom-property keys a site has recorded (each paired with the event it was sent with) — discovery for the event:props: breakdown. Returns the full set (no pagination) for the window.' parameters: - name: site_id in: query required: false description: Numeric site id. Optional for a single-site key; required for an all-sites key. schema: type: integer format: int64 - name: date_range in: query required: false description: 'Same forms as /query (default 30d): a preset `7d`/`30d`/`month`, or a URL-encoded custom pair `["YYYY-MM-DD","YYYY-MM-DD"]`. ' schema: type: string responses: '200': description: The site's custom-property keys. headers: X-RateLimit-Limit: $ref: '#/components/headers/X-RateLimit-Limit' X-RateLimit-Remaining: $ref: '#/components/headers/X-RateLimit-Remaining' X-RateLimit-Reset: $ref: '#/components/headers/X-RateLimit-Reset' content: application/json: schema: type: object properties: props: type: array items: type: object properties: key: type: string event: type: string count: type: integer format: int64 first_seen: type: string format: date-time '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /funnels: get: tags: - Stats operationId: listFunnels summary: List a site's configured conversion funnels description: Returns the site's saved funnel definitions (managed in the dashboard). Use a funnel `id` with POST /funnels/{id}/report. Funnels are read-only over the API — this endpoint does not create or edit them. parameters: - name: site_id in: query required: false description: Numeric site id. Optional for a single-site key (defaults to its site); required for an all-sites key. schema: type: integer format: int64 responses: '200': description: The site's funnel definitions. headers: X-RateLimit-Limit: $ref: '#/components/headers/X-RateLimit-Limit' X-RateLimit-Remaining: $ref: '#/components/headers/X-RateLimit-Remaining' X-RateLimit-Reset: $ref: '#/components/headers/X-RateLimit-Reset' content: application/json: schema: type: object required: - funnels properties: funnels: type: array items: $ref: '#/components/schemas/FunnelDefinition' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /funnels/{id}/report: post: tags: - Stats operationId: runFunnelReport summary: Run a saved funnel and return per-step conversions description: Runs a saved funnel over a date range (+ optional segment filters) and returns an ORDERED per-step result with top-level scalars — a bespoke, non-tabular shape (funnel output is not rows). `steps` must not be sorted; `conversion_rate` is cumulative versus the first step (`entering`), not step-to-step. The intra-conversion window is a fixed 1 day, independent of `date_range` (which only bounds the sessions scanned). parameters: - name: id in: path required: true description: Funnel id (from GET /funnels). schema: type: integer format: int64 requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/FunnelReportRequest' examples: basic: summary: Last 30 days value: site_id: 3093477 date_range: 30d segmented: summary: Segmented by country value: site_id: 3093477 date_range: - '2026-06-01' - '2026-06-30' filters: - field: country operator: is values: - DE responses: '200': description: The funnel result. headers: X-RateLimit-Limit: $ref: '#/components/headers/X-RateLimit-Limit' X-RateLimit-Remaining: $ref: '#/components/headers/X-RateLimit-Remaining' X-RateLimit-Reset: $ref: '#/components/headers/X-RateLimit-Reset' content: application/json: schema: $ref: '#/components/schemas/FunnelReportResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' components: schemas: FunnelStep: type: object description: 'One step of a funnel definition. `kind` selects which other fields apply. ' required: - kind properties: kind: type: string enum: - page - event - goal - scroll - entry_page - exit_page path: type: string description: URL path — for kind page/entry_page/exit_page. operator: type: string description: Path match operator (e|b|c|r) — for kind page. event: type: string description: Custom event name — for kind event. prop_key: type: string description: Optional custom-property key — for kind event. prop_value: type: string description: Optional custom-property value — for kind event. goal_id: type: integer format: int64 description: Goal id — for kind goal. threshold: type: integer description: Scroll depth 0..100 — for kind scroll. Error: type: object required: - error - code properties: hint: type: string description: 'One sentence on what to do next. Present only on the errors a client meets while exploring (`not_found`, `method_not_allowed`); other errors omit it. Human-readable — do not branch on it. ' example: 'Use one of: GET, POST.' docs: type: string format: uri description: 'Where to read more. Present together with `hint`, omitted otherwise. ' example: https://statable.com/api/v1/openapi.yaml request_id: type: string description: 'Same value as the X-Request-ID response header, repeated here because clients log bodies more often than headers. Quote it in a support request. Present on every error, including the `not_found` and `method_not_allowed` answers for a path or method that has no route. ' example: ch-node01-01997f2a8b3c7d5e8f01abcdef123456 error: type: string description: Human-readable detail. May be reworded — do not branch on it. code: type: string description: 'Stable machine-readable slug (contract — never changes). The `ambiguous_domain` code is emitted only by the MCP tools (when a `site` domain matches more than one stored site), not by /query. ' enum: - 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 - unauthorized - insufficient_scope - key_not_scoped - tracking_inactive - unknown_site - unknown_funnel - rate_limited - internal - ambiguous_domain - invalid_scope - scope_escalation - invalid_expiry - key_limit_reached - api_key_not_found - self_modification - write_disabled - terms_not_accepted - otp_invalid - site_exists - domain_not_allowed - email_undeliverable - not_site_owner - idempotency_conflict - goal_exists - goal_not_found - funnel_exists - hobby_always_public - not_found - method_not_allowed Filter: type: object additionalProperties: false required: - field - operator - values properties: field: type: string description: '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`. ' enum: - 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 operator: type: string enum: - is - is_not - contains - does_not_contain values: type: array minItems: 1 description: OR-ed together. A value may not contain a comma. items: type: string ResultRow: type: object properties: dimensions: type: object description: '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. ' additionalProperties: type: string labels: type: object description: 'Present only on geo breakdowns — the human-readable display name for the code in `dimensions`, keyed by the same dimension. ' additionalProperties: type: string metrics: type: object description: The requested metrics for this row. additionalProperties: type: number compare: type: object description: '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. ' additionalProperties: type: object properties: value: type: number change: type: number DateRange: description: '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. ' oneOf: - type: string pattern: ^(month|realtime|([1-9]|[1-8][0-9]|90)d)$ - type: array minItems: 2 maxItems: 2 items: type: string format: date examples: - 7d - 14d - - '2026-06-01' - '2026-06-30' FunnelReportResponse: type: object properties: funnel: type: object properties: id: type: integer format: int64 name: type: string scope: type: string enum: - visitor - session strict_order: type: boolean entering: type: integer format: int64 description: Visitors that reached step 0 — the funnel's denominator. all_visitors: type: integer format: int64 description: Total visitors/visits in the period (funnel-independent). steps: type: array items: $ref: '#/components/schemas/FunnelReportStep' query: type: object description: The resolved request (site_id, funnel_id, normalized date_range, filters). FunnelReportStep: type: object description: One step's outcome. Steps are ordered — do not sort them. properties: index: type: integer description: 0-based position in the funnel. name: type: string kind: type: string enum: - page - event - goal - scroll - entry_page - exit_page visitors: type: integer format: int64 description: Visitors/visits that reached this step. conversion_rate: type: number description: visitors / entering * 100 — cumulative vs the first step, NOT step-to-step. dropoff: type: integer format: int64 description: Visitors lost versus the previous step. SitesResponse: type: object required: - sites properties: sites: type: array items: $ref: '#/components/schemas/SiteSummary' Metric: type: string description: '`visits` = sessions. `visit_duration` in seconds. `bounce_rate` is a 0–100 percentage. `views_per_visit` = pageviews/visits (2 decimals). `engagement_time` = avg active-engagement seconds (aggregate + time-series, distinct from visit_duration). `events` = raw event count (breakdowns event:name/props/goal). `conversion_rate` = converters percent (event:goal). `time_on_page`/`scroll_depth` = avg seconds / avg max scroll% 0-100 (event:page, event:folder). `exit_rate` = exits percent of pageviews (visit:exit_page). The last five are breakdown-only: off their dimension, and on an aggregate or a time series, they return `metric_not_available` naming the shape and the dimensions that do compute them. `unknown_metric` means the name itself is not a metric we compute. ' enum: - visitors - pageviews - visits - visit_duration - bounce_rate - views_per_visit - engagement_time - events - conversion_rate - time_on_page - scroll_depth - exit_rate SiteSummary: type: object required: - site_id - name - hash - timezone - hobby properties: site_id: type: integer format: int64 description: The numeric id you pass as `site_id` to /query. name: type: string description: 'The site''s domain. For the configured public demo site this is masked to `example.com` — see "The demo site is masked" in docs/public-api-v1.md; breakdown values carrying the host are masked the same way. ' hash: type: string description: 'The site''s public token, used by widget script URLs and the shared dashboard link. Integrations need it and previously had to parse it out of a widget snippet URL — which only worked for hobby sites, since a paid site''s tracker URL is keyed by the numeric id. ' timezone: type: string description: The site's reporting timezone. hobby: type: boolean description: 'True for a site on the free plan. It decides how the site counts at all: a hobby site is tracked by the bundled `/t/` build (widget plus counter) carrying a `data-id` attribute, and an event reaching us from any other build is refused. Install `GET /sites/{id}/snippet` verbatim and this is handled for you; branch on it only if you build the tag yourself. ' stats_start_date: type: - string - 'null' format: date description: Stored value only (not computed on the fly); null if not yet computed. created_at: type: string format: date-time stats: $ref: '#/components/schemas/SiteStats' QueryRequest: type: object additionalProperties: false required: - metrics - date_range properties: site_id: type: integer format: int64 description: '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: type: array minItems: 1 items: $ref: '#/components/schemas/Metric' date_range: $ref: '#/components/schemas/DateRange' dimensions: type: array maxItems: 1 description: Zero or one element. Omit / empty = aggregate. items: $ref: '#/components/schemas/Dimension' filters: type: array items: $ref: '#/components/schemas/Filter' limit: type: integer minimum: 1 maximum: 1000 default: 100 description: Breakdown queries only. Clamped to 1–1000. Sending it on an aggregate or time-series query → `limit_offset_misuse`. offset: type: integer minimum: 0 default: 0 description: Breakdown queries only. Sending it on an aggregate or time-series query → `limit_offset_misuse`. compare: description: '`"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`. ' oneOf: - type: string enum: - previous_period - type: array minItems: 2 maxItems: 2 items: type: string format: date QueryEcho: type: object description: Echo of the resolved request. properties: site_id: type: integer format: int64 metrics: type: array items: $ref: '#/components/schemas/Metric' date_range: type: array description: Always the resolved concrete [from, to] YYYY-MM-DD window (presets normalized). minItems: 2 maxItems: 2 items: type: string format: date compare_date_range: type: array description: Present only when `compare` was set — the resolved [from, to] of the compare window. minItems: 2 maxItems: 2 items: type: string format: date dimensions: type: array items: $ref: '#/components/schemas/Dimension' filters: type: array items: $ref: '#/components/schemas/Filter' SiteStats: type: object properties: date_range: type: string metrics: type: object additionalProperties: type: number FunnelReportRequest: type: object required: - date_range properties: site_id: type: integer format: int64 description: Optional for a single-site key; required for an all-sites key. date_range: $ref: '#/components/schemas/DateRange' filters: type: array description: 'Segment/session filters only (country, browser, os, device, source, channel, referrer, utm_*, entry_page, exit_page, hostname). Event-level fields (event, page, code) return `invalid_filter`. ' items: $ref: '#/components/schemas/Filter' FunnelDefinition: type: object description: A saved funnel definition (managed in the dashboard). properties: id: type: integer format: int64 site_id: type: integer format: int64 name: type: string scope: type: string enum: - visitor - session strict_order: type: boolean description: When true, steps must occur in exact order (windowFunnel strict_order). steps: type: array items: $ref: '#/components/schemas/FunnelStep' created_at: type: string format: date-time updated_at: type: string format: date-time Dimension: type: string description: 'At most one per query. A time dimension yields a series; a breakdown dimension yields top-N rows. Not every metric is valid for every breakdown — a metric the builder does not compute returns `metric_not_available`. In addition to the enum below, a dynamic `event:props:` dimension breaks a custom event down by one of its property values (metrics `visitors` + `events`); it requires an `event` filter to name the event, else `event_filter_required`. ' enum: - time - time:minute - time:hour - time:day - time:week - time:month - visit:source - visit:referrer - visit:channel - visit:utm_source - visit:utm_medium - visit:utm_campaign - visit:utm_content - visit:utm_term - visit:country - visit:region - visit:city - visit:browser - visit:browser_version - visit:os - visit:os_version - visit:device - visit:entry_page - visit:exit_page - event:page - event:folder - event:hostname - event:name - event:goal - event:status_code Meta: type: object description: Present only on breakdown responses. properties: total: type: integer description: Total distinct values for the window, across all pages. limit: type: integer description: Effective limit after clamping. offset: type: integer has_more: type: boolean description: True when offset + returned rows < total. Page again with offset += limit. QueryResponse: type: object required: - results - query properties: results: type: array items: $ref: '#/components/schemas/ResultRow' meta: $ref: '#/components/schemas/Meta' query: $ref: '#/components/schemas/QueryEcho' has_import: type: boolean description: True when the site has imported (e.g. GA4) data overlapping the queried window. headers: X-RateLimit-Remaining: description: Requests left in the current per-key window. schema: type: integer X-RateLimit-Reset: description: Seconds until the per-key window resets (delta-seconds, not an epoch). schema: type: integer X-RateLimit-Limit: description: The per-key hourly rate limit for your plan. schema: type: integer X-Request-ID: description: 'Correlation id for support, present on every response including successful ones. The leading segment names the node that served the request, so this one value is enough to locate the log entry. Error bodies repeat it as `request_id`. A client-supplied X-Request-ID is recorded server-side but never echoed back in place of ours. ' schema: type: string example: ch-node01-01997f2a8b3c7d5e8f01abcdef123456 responses: Internal: description: '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. ' headers: X-Request-ID: $ref: '#/components/headers/X-Request-ID' content: application/json: schema: $ref: '#/components/schemas/Error' NotFound: description: '`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. ' content: application/json: schema: $ref: '#/components/schemas/Error' Unauthorized: description: Missing/malformed/invalid/expired/revoked bearer token (`unauthorized`). content: application/json: schema: $ref: '#/components/schemas/Error' Forbidden: description: '`insufficient_scope` (the key lacks the `read` scope every endpoint here requires), `key_not_scoped` (single-site key asked for a different site) or `tracking_inactive` (the site''s tracking has stopped). ' content: application/json: schema: $ref: '#/components/schemas/Error' BadRequest: description: '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. ' content: application/json: schema: $ref: '#/components/schemas/Error' RateLimited: description: '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). ' headers: Retry-After: description: Seconds until you may retry. schema: type: integer X-RateLimit-Limit: $ref: '#/components/headers/X-RateLimit-Limit' X-RateLimit-Remaining: $ref: '#/components/headers/X-RateLimit-Remaining' X-RateLimit-Reset: $ref: '#/components/headers/X-RateLimit-Reset' content: application/json: schema: $ref: '#/components/schemas/Error' securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: stbl_ description: 'A key minted in Settings → API. All tokens start with `stbl_`. Missing, malformed, invalid, expired, or revoked → 401. Every operation in this spec requires the key''s `read` scope; without it → 403 `insufficient_scope`. '