POST /analytics/explore
Updated Oct 1, 20263 minute read
Explore any dataset
One query over any dataset Median collects, the way the analytics page's explorer runs it: a measure, filters, a split into series and an axis. Datasets: conversations, messages, ratings, signals, usageEvents, logs, knowledgeDocs, knowledgeSuggestions, toolRequests, toolRuns, customers, tasks. Each has its own field ids; the MCP reference (median_docs with topic analytics) lists them. A query that names a field or value the dataset lacks is refused with invalid_query and a sentence saying what was wrong.
Part of Analytics.
Request body
application/json, required.
| Field | Type | Required | Description |
|---|---|---|---|
query | object | Yes | |
query.dataset | "conversations" | "ratings" | "signals" | "logs" | "knowledgeDocs" | "knowledgeSuggestions" | "toolRequests" | "toolRuns" | "customers" | "tasks" | "usageEvents" | "messages" | Yes | |
query.measure | object | Yes | |
query.measure.op | "count" | "distinct" | "sum" | "avg" | "median" | "p75" | "p90" | "p95" | "p99" | "min" | "max" | "percent" | Yes | count takes no field. distinct reads an id, string or enum field. percent takes no field but a where filter, and gives the share of matched rows passing it, 0 to 100. The rest read a number or duration field. |
query.measure.field | string | null | No | The field id the measure reads. |
query.measure.where | object | No | percent only: the condition to count the share of. Same shape as a filter. |
query.filters | object[] | No | Rows have to pass every filter, or any one with match: "any". Enum values are their ids, booleans are true or false, durations are milliseconds. isSet and isNotSet take no value; in and notIn take a list of up to 50. |
query.filters[].field | string | Yes | |
query.filters[].op | "is" | "isNot" | "gt" | "gte" | "lt" | "lte" | "contains" | "notContains" | "in" | "notIn" | "isSet" | "isNotSet" | Yes | |
query.filters[].value | string | number | boolean | string | number | boolean[] | No | |
query.match | "all" | "any" | No | all (the default) lets a row through when it passes every filter, any when it passes one. |
query.groupBy | string | null | No | A field id to split the rows into series by. Rows with nothing in the field land in __none. |
query.top | integer | No | How many groups keep their name. The rest fold into __other, which takes one of the places. |
query.order | "natural" | "largest" | "smallest" | No | natural keeps the field's own order (most rows first for free text). largest or smallest ranks the groups by the measure, and the ranking picks which keep their name. |
query.other | boolean | No | false leaves the groups past top off instead of folding them into __other. The total then covers only the groups returned. |
query.bucket | "day" | "week" | "month" | "hour" | "weekday" | No | The x axis. day, week and month cut the window up and need the range scope; hour and weekday fold every row onto one clock. |
query.when | object | No | |
query.when.field | string | No | Which of the dataset's time fields places a row. The dataset's first by default. |
query.when.scope | "range" | "all" | No | range for rows inside the window, all for every row on file. Default range. |
query.compare | boolean | No | Also measure the same number of days just before the window, returned as previous. Ignored with the all scope. |
days | integer | No | How far back the window reaches, counted from the UTC midnight before now. Default 30. |
from | integer | No | The window's start, unix ms, in place of counting back. |
Responses
| Status | Description |
|---|---|
200 | One row of values per group, one column per bucket. |
400 | The request is malformed, and the message names the field. |
401 | The bearer token is missing, revoked, or expired. |
429 | Too many requests. Wait the seconds in Retry-After. Limits depend on the plan. See rate limits. |
200 body
| Field | Type | Required | Description |
|---|---|---|---|
buckets | integer[] | No | When each column begins, unix ms, for a time axis. Null for a clock axis (24 hours, or Monday to Sunday) and when there is no axis. |
groups | string[] | No | Group keys in the values' order: the field's values, __none, __other, or __all for an unsplit query. |
values | number | null[][] | No | One list per group, one entry per column. Null where nothing measured. |
total | number | null | No | The measure over every matched row at once. |
matched | integer | No | |
scanned | integer | No | |
capped | boolean | No | True when the scan stopped at the table's cap and older rows were not read. |
previous | object | No | Only when the query set compare: the same measure over the days just before the window. |
previous.from | integer | No | Where the earlier stretch starts, unix ms. It ends where the window begins. |
previous.values | number | null[] | No | One value per group, in groups order, over the whole earlier stretch. |
previous.total | number | null | No | |
previous.matched | integer | No | |
previous.capped | boolean | No |
Example request
curl -X POST https://api.median.sh/v1/analytics/explore \
-H "Authorization: Bearer $BEARER_AUTH" \
-H "content-type: application/json" \
-d '{"query":{"dataset":"conversations","measure":{"op":"count"},"filters":[{"field":"handedOff","op":"is","value":true}],"groupBy":"channel","bucket":"week"},"days":30}'