OverviewExplore queries

Explore queries

Build trends and funnels over any event and property in one call with a typed query spec — no SQL, tenant-scoped by construction, sub-second.

curl -X POST "https://mythic-analytics.gulp.workers.dev/client/v1/data/query?location_id=LOC" \
  -H "Authorization: Bearer ak_..." \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "trend",
    "date_range": { "preset": "30d" },
    "interval": "day",
    "series": [{ "event": "$pageview", "math": "dau" }],
    "breakdown": { "property": "utm_source", "limit": 5 },
    "compare": "previous_period",
    "filters": [{ "property": "$device_type", "op": "eq", "value": "mobile" }]
  }'
{
  "success": true,
  "data": {
    "kind": "trend",
    "buckets": ["2026-08-02", "2026-08-03", "…", "2026-09-01"],
    "previous_buckets": ["2026-07-02", "…", "2026-08-01"],
    "breakdown_values": ["google", "", "adwords", "homepage", "incubator", "$$other"],
    "series": [
      {
        "label": "$pageview · dau",
        "math": "dau",
        "event": "$pageview",
        "breakdown_value": "google",
        "data": [18, 22, 0, 31],
        "total": 71,
        "previous": [15, 19, 12, 27]
      }
    ],
    "meta": {
      "from": "2026-08-02",
      "to": "2026-09-01",
      "to_exclusive": "2026-09-01 17:55:00",
      "interval": "day",
      "previous": { "from": "2026-07-02", "to": "2026-08-01", "to_exclusive": "2026-08-01 17:55:00" },
      "sql_ms": 212,
      "rows_read": 778534,
      "bytes_read": 36060000,
      "cached": false
    }
  }
}
{
  "kind": "funnel",
  "date_range": { "preset": "14d" },
  "steps": [
    { "event": "$pageview", "filters": [{ "property": "$pathname", "op": "contains", "value": "/pricing" }] },
    { "event": "signup" },
    { "event": "order_completed" }
  ],
  "window": { "value": 3, "unit": "day" },
  "breakdown": { "property": "$experiment_arm" }
}
{
  "success": true,
  "data": {
    "kind": "funnel",
    "steps": [
      { "step": 1, "label": "$pageview", "event": "$pageview", "users": 1180, "conversion_from_first": 1, "conversion_from_previous": 1 },
      { "step": 2, "label": "signup", "event": "signup", "users": 143, "conversion_from_first": 0.1212, "conversion_from_previous": 0.1212 },
      { "step": 3, "label": "order_completed", "event": "order_completed", "users": 31, "conversion_from_first": 0.0263, "conversion_from_previous": 0.2168 }
    ],
    "breakdown": [
      { "value": "B", "steps": [ { "step": 1, "users": 602, "…": "…" } ] },
      { "value": "A", "steps": [ { "step": 1, "users": 578, "…": "…" } ] }
    ],
    "meta": { "from": "2026-08-18", "to": "2026-09-01", "window_seconds": 259200, "grain": "device", "sql_ms": 340, "rows_read": 51200, "bytes_read": 2100000, "cached": false }
  }
}

What it is

POST /client/v1/data/query is the self-serve half of the Data API. The fixed reports (/events/summary, /events/breakdown, /sessions/breakdown) answer the questions we anticipated. This route answers the ones you compose: any event, any property, filters, several series, a breakdown, a previous-period compare, or an ordered funnel with a conversion window.

You send a spec, not SQL. The spec is validated (unknown keys are rejected), compiled on the server to ClickHouse SQL over a wide, typed events table, and scoped to your location by the compiler itself. There is no field in the spec that can name a tenant, a table, or raw SQL.

Measured on the largest tenant (3.1M events over 30 days): a daily trend broken down by utm_source reads about 36 MB and returns in well under a second. The same question over raw JSON properties did not finish inside the 20 s cap. That gap is why this route exists.

A first trend

One series[] entry per spec series × breakdown value. data[] is aligned to buckets[] and zero-filled; previous[] is aligned by index to previous_buckets[]. Omit interval and each entry carries a single value (and previous_value) instead.

The spec

body
kind

What to compute.

body
date_range

{ "preset": "24h" | "7d" | "14d" | "30d" | "90d" | "365d" } or { "from": "YYYY-MM-DD", "to": "YYYY-MM-DD" } (in timezone, to inclusive). Presets resolve to [today-N, today], so 7d returns eight daily buckets: seven whole days plus today so far. A window that includes today ends at the moment of the request, not at midnight — meta.to_exclusive reports where it actually stopped, and an event dated in the future is never counted. Max 366 days.

body
timezone

IANA zone the calendar is cut in — the day/week/month buckets, what a preset counts as "today", and where a window that includes today stops. Defaults to UTC. Pass the end user's zone ("America/Denver") when the numbers are about their business days: in UTC their day starts mid-evening, so the newest bucket is a fragment and "yesterday" is not the day they mean. Echoed back as meta.timezone, and bucket labels come back as wall-clock times in that zone. An unknown or malformed name is rejected with invalid_spec.

Buckets are cut from hourly data, so whole-hour and half-hour zones (Asia/Kolkata) are exact; a 45-minute zone (Asia/Kathmandu) is rounded to the hour.

body
interval

Trend only. Omit for one number per series. hour needs a range of 31 days or less. Weeks start on Sunday. Buckets follow timezone (UTC by default). A week or month bucket is labelled by its calendar start, so the first one can begin before date_range.from and hold only the days inside the range.

body
series

Trend only, 1 to 5. Each: event (a name, or null for any event), math (total, dau, unique_sessions, sum, avg, min, max, median, p90, p95, p99), math_property (required for the numeric maths), optional filters[] and label.

body
breakdown

Split every series by one property. Top limit values (default 10, max 50) ranked by the first series; everything else folds into "$$other". For a funnel the value is taken from the first step's event.

body
compare

Trend only. Also returns the immediately preceding window of equal length, in each series' previous[]. When the current window includes today it is truncated at the time of the request, and the preceding one is truncated at the same time of day on its last day (see meta.previous.to_exclusive) — otherwise today-so-far would be compared against a whole day and every metric would look like it had fallen.

body
steps

Funnel only, 2 to 10, in order. Each: event, optional filters[] and label. Sequential semantics: a user must reach the steps in this order within the window, and other events may occur between them.

body
window

Funnel only. Conversion window, unit one of minute, hour, day. Default 7 days, capped at the length of the date range.

body
grain

Funnel only. Who a step counts. device counts the raw distinct_id; person resolves each id to its canonical person first, so a journey that crosses devices — or a step that is a server/CRM event keyed by a contact id rather than a browser id — links into one funnel instead of breaking. It joins the whole identity map for the window, so it is slower: measured on a 30-day two-step funnel, 0.04s to 0.19s on a web+CRM tenant and 0.27s to 1.2s on the largest. Trends have no grain — person resolution moves a unique-user count by under 1% on most tenants, so it is not worth the scan there.

body
filters

Applied to every series or step. At most 20 filters in total across the spec.

Filters

{ "property": "...", "op": "...", "value": ... }

opvaluecompiles to
eq, neqstringexact match
in, not_instring[] (≤100)set membership
contains, not_containsstringcase-insensitive substring
regexstring (≤200 chars)RE2 match
is_set, is_not_setnonenon-empty / empty
gt, gte, lt, ltenumbernumeric compare (non-numeric values are excluded)

Properties

Typed names resolve to indexed columns. Anything else is looked up as a custom event property.

NameMeaning
$current_url, $pathnamepage of the event
$referring_domainsession-entry referrer domain, event-level fallback
utm_source, utm_medium, utm_campaignsession-entry UTMs, event-level fallback
$device_type, $browser, $osdevice
$geo_country, $geo_citygeo
$experiment_armfirst A/B arm on the event
event, distinct_id, session_id, user_idcore columns
anything elsecustom event property (scalar, ≤200 chars)

Discover keys with GET /properties and values with GET /query/values.

Not queryable here: nested objects, arrays, values longer than 200 characters, and meta_* blobs from CRM events. Other $-prefixed SDK properties beyond the table above are not exposed either — use the typed name. The events $replay_summary, $replay_started, $heatmap, $web_vital_*, $set, $set_once and $identify are excluded from this table; each has its own surface (replays, heatmaps, web vitals, people).

A funnel

Step k counts users who reached steps 1 through k in order within the window. By default users are distinct_ids, so an anonymous visitor who identifies between two steps is two ids and the funnel breaks there. Add "grain": "person" to resolve ids to people first and keep the journey whole — meta.grain echoes what was counted.

That matters most when a step is not a browser event. A CRM or server-side conversion arrives under a contact id, never the visitor's distinct_id, so at device grain the funnel structurally cannot link it: on a live web+CRM tenant a $pageview → opportunity_created funnel found 637 conversions by device and 914 by person, a rate of 3.4% against 5.2%. On a web-only funnel the two agree to within 0.1%, and person grain only costs you time.

Semantics worth knowing

  • Unique counts are approximate. dau and unique_sessions use ClickHouse uniq(), accurate to about 1–2%.
  • Buckets are UTC and every bucket in the range is present, zero where nothing happened.
  • Compare aligns by index. previous[i] is the bucket i of the previous window, so week-over-week lines overlay directly.
  • $$other is a real value in breakdown_values and series[], never a separate field.
  • Caching. A range that ends before today is served from cache for six hours; one touching today for two minutes. meta.cached tells you which.
  • Rate limit. 30 requests per 10 seconds per location, separate from the rest of the Data API.

Errors

statuscodemeaning
400invalid_specerror.details[] lists every problem found
400invalid_jsonbody is not a JSON object
413payload_too_largebody over 64 KB
429rate_limitedsee Retry-After
502upstream_errorthe query engine rejected or timed out the statement

Property values

GET /query/values?property=utm_source&days=30&limit=20 returns { success, data: { property, days, values: [{ value, count }], cached } } — the picker behind filter and breakdown inputs. Accepts the same property names as /query, an optional event to narrow, and a case-insensitive search.

Saving one as an insight

An insight is exactly this spec stored as config.query through the Builder API (POST /builder/insights) plus an insight_type that picks the visualization; run it with POST /builder/insights/:id/query. It is validated at write time, executes on this engine, and returns this response shape. See Insight config.

From MCP

The query_events and list_property_values tools are thin proxies over these two routes with the same spec as their input schema.