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
What to compute.
{ "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.
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.
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.
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.
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.
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.
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.
Funnel only. Conversion window, unit one of minute, hour, day.
Default 7 days, capped at the length of the date range.
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.
Applied to every series or step. At most 20 filters in total across the spec.
Filters
{ "property": "...", "op": "...", "value": ... }
| op | value | compiles to |
|---|---|---|
eq, neq | string | exact match |
in, not_in | string[] (≤100) | set membership |
contains, not_contains | string | case-insensitive substring |
regex | string (≤200 chars) | RE2 match |
is_set, is_not_set | none | non-empty / empty |
gt, gte, lt, lte | number | numeric compare (non-numeric values are excluded) |
Properties
Typed names resolve to indexed columns. Anything else is looked up as a custom event property.
| Name | Meaning |
|---|---|
$current_url, $pathname | page of the event |
$referring_domain | session-entry referrer domain, event-level fallback |
utm_source, utm_medium, utm_campaign | session-entry UTMs, event-level fallback |
$device_type, $browser, $os | device |
$geo_country, $geo_city | geo |
$experiment_arm | first A/B arm on the event |
event, distinct_id, session_id, user_id | core columns |
| anything else | custom 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.
dauandunique_sessionsuse ClickHouseuniq(), 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. $$otheris a real value inbreakdown_valuesandseries[], 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.cachedtells you which. - Rate limit. 30 requests per 10 seconds per location, separate from the rest of the Data API.
Errors
| status | code | meaning |
|---|---|---|
| 400 | invalid_spec | error.details[] lists every problem found |
| 400 | invalid_json | body is not a JSON object |
| 413 | payload_too_large | body over 64 KB |
| 429 | rate_limited | see Retry-After |
| 502 | upstream_error | the 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.