OverviewExplore queries

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

body
kind

What to compute. attribution has its own section: Ad attribution and ROAS.

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. Steps may repeat an event ($pageview then $pageview counts people with two or more pageviews). Each event fills at most one step.

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 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.

body
filters

Applied to every series or step. At most 20 filters in total across the spec. Not accepted on paths; put conditions in goal.filters.

body
goal

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.

body
touchpoint

Paths only. session makes each session one step, labeled by dimension. event makes each event one step, with pageviews labeled by their pathname.

body
dimension

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).

body
lookback_days

Paths only, 1 to 180. How far before the goal a journey reaches. Default 90 for session touchpoints, 7 for event touchpoints.

body
depth

Paths only, 1 to 10. Touchpoints kept per journey: the last N before the goal.

body
limit

Paths only, 1 to 50. Journeys returned, and rows per first/last-touch list.

body
exclude

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.

body
default_excludes

Paths with event touchpoints only. false keeps the default-excluded events, so only exclude is dropped.

body
value_property

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": ... }

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?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 per distinct_id instead.
  • 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.
  • share is over goal_persons, including people whose journey is outside limit.
  • no_touchpoint counts goal persons with nothing tracked in the lookback, typically a CRM conversion whose contact was never linked to a browser visit.
  • truncated marks journeys that had more than depth touchpoints. Only the last depth are shown.
  • Days to convert. median_days and avg_days run 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 exceed lookback_days, so a long cycle reads short; at_lookback counts 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). credit is the people credited (it sums to goal_persons - no_touchpoint), persons the people who had that touchpoint at all, share is credit / goal_persons, and value the 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_touchpoints is 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 sampled is true. 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:

GroupRule
Paid Searchan 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 Sociala 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)
Displaymedium display, banner, expandable, interstitial, cpm
Paid Otherany other paid medium
Emailmedium contains email, or newsletter, or source is an email client (Gmail app, mail.google.com, Outlook)
SMSsource or medium sms
Affiliatesmedium affiliate
AImedium ai (referred by ChatGPT, Claude, Perplexity, Gemini, Copilot, …) or one of those as the source
Organic Searchmedium organic, or a search engine source
Organic Sociala 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
Referralmedium referral, app, link
Directno source and no medium
Unassignedanything 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.

fieldvaluesdefault
modellast (last non-Direct touch), first, linear (equal split)last
levelcampaign, adset, adcampaign
platformmeta_ads, google_ads (rows only; totals cover everything)all
account_idan ad account id, act_ prefix acceptedall
lookback_days1 to 18030
limit1 to 500 rows100

How a session is matched to a campaign:

  • Platform. Meta when the session has an fbclid or is Paid Social from a Meta source. Google when it has a gclid, gbraid, wbraid or gad_source, or is Paid Search from Google.
  • Ids from the landing URL. Meta: campaign_id, adset_id, ad_id, then the utm_id / utm_campaign, utm_term and utm_content id 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_campaign equals 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. 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, including an event or property explore does not store (... is not queryable: ...)
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, attribution included.