Explore queries
Build trends, funnels, paths to a goal and ad attribution (ROAS by campaign) 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 }
}
}
{
"kind": "paths",
"date_range": { "preset": "30d" },
"goal": { "event": "order_completed" }
}
{
"success": true,
"data": {
"kind": "paths",
"goal_persons": 667,
"no_touchpoint": 38,
"sampled": false,
"paths": [
{ "touchpoints": ["Paid Social"], "truncated": false, "persons": 551, "avg_touchpoints": 1.42, "share": 0.826 },
{ "touchpoints": ["Direct"], "truncated": false, "persons": 15, "avg_touchpoints": 1.67, "share": 0.022 },
{ "touchpoints": ["Paid Social", "Direct"], "truncated": false, "persons": 8, "avg_touchpoints": 2.25, "share": 0.012 }
],
"first_touch": [
{ "touchpoint": "Paid Social", "persons": 575, "share": 0.862 },
{ "touchpoint": "Direct", "persons": 29, "share": 0.043 }
],
"last_touch": [
{ "touchpoint": "Paid Social", "persons": 557, "share": 0.835 },
{ "touchpoint": "Direct", "persons": 34, "share": 0.051 }
],
"meta": {
"from": "2026-08-25", "to": "2026-09-24", "grain": "person",
"goal": { "label": "order_completed", "event": "order_completed" },
"touchpoint": "session", "dimension": "channel_group",
"lookback_days": 90, "depth": 5, "limit": 20, "goal_id_cap": 20000,
"sql_ms": 1070, "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, an ordered funnel with a conversion window, or the journeys people
took before a conversion, with first-touch and last-touch counts.
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. attribution has its own section: Ad attribution and ROAS.
{ "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. Steps may repeat
an event ($pageview then $pageview counts people with two or more
pageviews). Each event fills at most one step.
Funnel only. Conversion window, unit one of minute, hour, day.
Default 7 days, capped at the length of the date range.
Funnel and paths. Paths default to person; see Paths to a goal.
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.
Not accepted on paths; put conditions in goal.filters.
Paths only, required. The conversion: event, optional filters[] and
label. The goal may be an event the rest of explore does not store, such as
$identify ("paths to sign-in"), as long as it has no filters: it is read
from the raw event stream with the same window and person grain.
Paths only. session makes each session one step, labeled by dimension.
event makes each event one step, with pageviews labeled by their pathname.
Paths with session touchpoints only. How a session is labeled. campaign
groups a campaign whatever its case or URL encoding and shows its most
common spelling. A numeric campaign value is a Meta campaign id when the
client syncs Meta Ads: it is replaced by the campaign's name and merged with
it, and meta.campaign_names reports { ids, named, status } (ok,
no_meta_sync or unavailable).
Paths only, 1 to 180. How far before the goal a journey reaches. Default 90 for session touchpoints, 7 for event touchpoints.
Paths only, 1 to 10. Touchpoints kept per journey: the last N before the goal.
Paths only, 1 to 50. Journeys returned, and rows per first/last-touch list.
Paths with event touchpoints only. Events left out of journeys, in addition
to the defaults: $pageleave, $engagement, $exception, $autocapture,
$experiment_viewed, tag_added, tag_removed. meta.exclude echoes the
full list applied.
Paths with event touchpoints only. false keeps the default-excluded events,
so only exclude is dropped.
Paths only. A numeric property of the goal event, such as value or GHL's
activity_value (the order amount on order_completed). Each person's value
is the sum over their goal events in the range, and first_value is their
first goal event alone. Adds goal_value and goal_first_value, and a
value / first_value on every journey, first-touch and last-touch row and
a value on every linear_touch row. Non-numeric values count as 0. Not
available on goals explore stores no properties for, such as $identify.
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?queryable=true, which lists only the event
properties this endpoint can read, 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 spec that names one of
these events (except as an unfiltered paths goal), a $-prefixed property outside the table, a meta_* property
or a dot-path is rejected with invalid_spec instead of counting 0. Objects
and arrays under a custom key cannot be told apart from the spec alone; check
queryable on the key in GET /properties.
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.
Paths to a goal
A paths query answers "what did people do before they converted?" It finds
everyone who did goal in date_range. For each person it takes the
touchpoints in the lookback_days before their first goal in the range, merges
consecutive repeats, and counts identical journeys.
- Persons, not devices. By default every id behind one person (browsers,
devices, CRM contact ids) is joined before the journey is built, because
earlier visits are usually on another device or arrive before the person was
identified.
"grain": "device"builds journeys perdistinct_idinstead. - Session touchpoints start at or before the goal, so the goal's own session is the last touch. Event touchpoints are strictly before the goal.
- First touch is the first touchpoint in the lookback. Last touch is the
one closest to the goal. Both count every goal person once, so each list sums
to
goal_persons - no_touchpoint. shareis overgoal_persons, including people whose journey is outsidelimit.no_touchpointcounts goal persons with nothing tracked in the lookback, typically a CRM conversion whose contact was never linked to a browser visit.truncatedmarks journeys that had more thandepthtouchpoints. Only the lastdepthare shown.- Days to convert.
median_daysandavg_daysrun from a person's first touchpoint in the lookback to their goal, on the response and on every journey, first-touch and last-touch row (null for people with no touchpoint). They cannot exceedlookback_days, so a long cycle reads short;at_lookbackcounts people whose first touch sits at the edge of the lookback, and a high count means a longer lookback would change the answer. linear_touch[]splits each person evenly across their touchpoints (a 3-session journey gives each session 1/3).creditis the people credited (it sums togoal_persons - no_touchpoint),personsthe people who had that touchpoint at all,shareiscredit / goal_persons, andvaluethe goal value credited the same way. Repeats count: two Paid Social sessions in a 3-session journey give Paid Social 2/3.- Revenue by channel. With
value_property, first-touch rows answer "which channel starts the journeys that bring the money", which often differs from who brings the buyers: on one tenant Paid Social started 86% of buyers' journeys but 43% of revenue, and Direct 5% of buyers but 36%. avg_touchpointsis the mean number of touchpoints behind that journey before repeats were merged. For session touchpoints, that is sessions.- Sampling. Above 20,000 goal ids, a fixed hash-based sample of 20,000 is
used and
sampledistrue. Shares stay representative. Absolute counts cover only the sample.
Channel groups
channel_group classifies each session from its source and medium. When the
session had no UTMs, those come from the referrer (google.com / organic,
x.com / referral, none for direct). An ad click id overrides a
referrer-derived value, never an explicit UTM: gclid/gbraid/wbraid →
google / cpc, fbclid → facebook / paid, msclkid → microsoft / cpc,
twclid, ttclid, li_fat_id → twitter, tiktok, linkedin / paid. The first
matching rule wins:
| Group | Rule |
|---|---|
| Paid Search | an ad-product source (adwords, google_ads, google_ad, bing_ads, microsoft_ads, …); a search engine with a paid medium (cpc, ppc, retargeting, paid*, anything containing cp); or a search engine with any other hand-set medium, such as a campaign name (google / spring_sale). A search engine with no medium, organic, seo, a display medium or an email medium is not Paid Search |
| Paid Social | a social source (facebook, fb, instagram, ig, tiktok, x, linkedin, youtube, reddit, pinterest, …) with a paid medium; medium paid_social; an ad-product source (facebook_ad, youtube_ad, instagram_ad, meta_ads, tiktok-ads, …); or a social source with any other hand-set medium, such as a campaign name (fb / spring_sale) |
| Display | medium display, banner, expandable, interstitial, cpm |
| Paid Other | any other paid medium |
medium contains email, or newsletter, or source is an email client (Gmail app, mail.google.com, Outlook) | |
| SMS | source or medium sms |
| Affiliates | medium affiliate |
| AI | medium ai (referred by ChatGPT, Claude, Perplexity, Gemini, Copilot, …) or one of those as the source |
| Organic Search | medium organic, or a search engine source |
| Organic Social | a social source with no medium, a referrer-derived medium, or an organic-link medium (social*, organic*, share, menu, bio, link, manychat, dm, post, story, profile, community, group), or medium sm |
| Referral | medium referral, app, link |
| Direct | no source and no medium |
| Unassigned | anything else, e.g. a custom utm_medium=menu |
Matching ignores case. Use "dimension": "source_medium" to see the raw pairs
behind a group.
Self-referrals are not touchpoints. A session whose referrer is the landing
page's own site (same registrable domain, so go2.example.com referring to
example.com counts) means the visitor was already there: a new session after
the timeout, or a hop between subdomains. Such sessions are dropped from the
journey. If every session in a journey is one, the journey reads as Direct
(direct/none, direct, (none) for the other dimensions). A referral from a
different domain you also own still counts as Referral.
Journeys by event
{
"kind": "paths",
"date_range": { "preset": "30d" },
"goal": { "event": "form_submitted" },
"touchpoint": "event",
"exclude": ["input_captured"]
}
Each step is an event name, or the pathname for a $pageview, e.g.
["/pricing", "/demo", "input_captured"].
Paths queries cannot be saved as insights yet.
Ad attribution and ROAS
An attribution query answers "which campaigns, ad sets or ads produced this
revenue, and what did it cost?" Every conversion in the range is credited to
the person's sessions in the lookback_days before it (default 30, across
devices and CRM contact ids, as paths). A session from Meta or Google lands on
its campaign; any other session lands on its channel group. Spend for the same
dates is joined in, so a campaign that spent and converted nothing still has a
row.
{
"kind": "attribution",
"date_range": { "preset": "30d" },
"deals": {},
"model": "last",
"level": "campaign"
}
Conversions are either a goal event, valued by value_property
({ "goal": { "event": "order_completed" }, "value_property": "activity_value" }
for GHL orders), or deals: won GHL opportunities. A deal converts once, at
the time it was first marked won, if its latest status is still won, and is
valued at its latest monetary value. deals.pipeline narrows to one pipeline
by exact name.
| field | values | default |
|---|---|---|
model | last (last non-Direct touch), first, linear (equal split) | last |
level | campaign, adset, ad | campaign |
platform | meta_ads, google_ads (rows only; totals cover everything) | all |
account_id | an ad account id, act_ prefix accepted | all |
lookback_days | 1 to 180 | 30 |
limit | 1 to 500 rows | 100 |
How a session is matched to a campaign:
- Platform. Meta when the session has an
fbclidor is Paid Social from a Meta source. Google when it has agclid,gbraid,wbraidorgad_source, or is Paid Search from Google. - Ids from the landing URL. Meta:
campaign_id,adset_id,ad_id, then theutm_id/utm_campaign,utm_termandutm_contentid template. Google:gad_campaignid(Google adds it),campaignid,adgroupid,creative. Only numeric values count, so a name or an unexpanded{{campaign.id}}never matches. - By name. A Meta or Google session with no campaign id whose
utm_campaignequals a campaign name in the synced spend (case-insensitive, Mythic's ad-tagging bid suffix removed, unique names only) gets that campaign. - Anything else is the platform's
(unmatched)row. A conversion with no session in the lookback is the(no touchpoint)channel row, so credit always adds up to the conversion count.
The response has totals over every row (conversions, revenue, spend, blended
roas and cpa, the ad-credited share, unmatched_ad_conversions,
no_touchpoint, over_cap, mixed_currency), filtered with the same figures after
platform and account_id, and rows[] sorted by revenue then spend.
Revenue is assumed to be in the ad account's currency. When spend spans more
than one currency, that level's spend, roas and cpa are null rather
than a sum of different currencies. Above 20,000 converting ids, attribution
runs on a deterministic sample (sampled: true): the remaining conversions are
counted on the (over attribution cap) row so totals stay complete, and row
cpa / roas are null. A row
is type: "ad" (platform, account, campaign, and ad set / ad at those levels,
with names from the ad sync) or type: "channel". cpa is spend per
conversion and roas revenue per unit of spend, null when the denominator is
0. Conversions under linear are fractional.
Spend comes from the client's Meta and Google Ads syncs, copied daily. A client without a sync still gets conversions and revenue per campaign, with spend 0. Ad set and ad spend is Meta only.
Attribution queries cannot be saved as insights yet.
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, including an event or property explore does not store (... is not queryable: ...) |
| 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, attribution included.