Query data

The whole query body of the Developer API, from dimensions, measures and filters to paging, counts, totals and time zones, and how to read the answer.

This page covers everything you can send to the query endpoint and everything it sends back. The key needs the data.query scope, and its person's role needs data.export.

POST /v1/developer/datasets/{id}/query HTTP/1.1
Host: api.usehalo.net
Authorization: Bearer hak_…
Content-Type: application/json

The body

FieldTypeMeansDefault
dimensionsarrayGroupings, outermost firstNone
measuresarrayNumbers to aggregate per groupNone
filtersarrayConditions every row must meetNone
sortsarrayRow order, in priority orderSee Sorts
limitintegerMost rows to return, 1 to 1,000100
offsetintegerRows to skip0
keysetbooleanPage by rows rather than by skipping themfalse
cursorstringA previous window's stats.nextCursorNone
timeZonestringThe IANA zone dates group and relative ranges resolve inThe organization's
countRowsbooleanAlso count every row in stats.totalfalse
totalsbooleanAlso aggregate the measures over every row in totalsfalse

A query needs at least one dimension or measure. The path names the dataset, so the body has no specVersion or datasetId, and it always reads the dataset's current version. pivot, groupRows, goals and parameters aren't available here. Any field not in this table gets 422 validation_failed.

Every result row is one group of the dimensions. Measures alone give one row, and dimensions alone list their distinct values. To read something close to raw rows, group by fields that are unique together.

Dimensions

{ "field": "order_date", "grain": "month", "alias": "month" }
KeyMeans
fieldThe field's name, from GET /v1/developer/datasets/{id}
grainFor date and timestamp fields: group by this period. The column key gets __grain, such as order_date__month
binWidthFor number fields: group into ranges this wide. Each value is its range's start. The column key gets __bin
aliasThe result column's key, in place of the generated one

A dimension takes a grain or a width, not both. A calculation over groups can't be a dimension.

GrainGroups by
minute, hourThat period. Timestamp fields only
day, month, quarter, yearThat period
weekWeeks from the organization's week start
iso_weekWeeks from Monday
fiscal_quarter, fiscal_yearPeriods from the organization's fiscal year start

Each value is its period's start: a date such as 2026-07-01, or a timestamp for minute and hour.

Measures

{ "field": "revenue", "aggregate": "avg", "alias": "average_order" }
KeyMeans
fieldThe field's name
aggregateHow to combine values. Left out, the field's default applies. One that differs from the default adds __aggregate to the column key, such as revenue__avg
aliasThe result column's key, in place of the generated one
AggregateCombines values byField type
sum, avgAdding them, averaging themNumber
median, p25, p75PercentilesNumber
min, maxLowest, highestAny
countCounting rowsAny
count_distinctCounting unique valuesAny

Any field can be a measure if you name an aggregate, such as { "field": "region", "aggregate": "count_distinct" }. A field that's already a calculation can't take one.

Filters

A filter is a field, an op, and values or range. Every filter in the list must hold.

opTakesExample
eq, neq, gt, gte, lt, lteOne item in values{ "field": "revenue", "op": "gte", "values": [1000] }
in, not_in1 to 1,000 items in values{ "field": "region", "op": "in", "values": ["North", "South"] }
betweenTwo items in values, inclusive{ "field": "orders", "op": "between", "values": [10, 50] }
contains, starts_withOne item in values, ignoring case{ "field": "channel", "op": "contains", "values": ["online"] }
is_null, not_nullNothing{ "field": "channel", "op": "not_null" }
date_rangerangeSee below
  • Values are converted to the field's type. Write dates as 2026-07-04, and timestamps as 2026-07-04T09:30.

  • Missing values. eq and the other comparisons refuse null, so use is_null. in with a null in its list also matches missing values. not_in keeps rows with a missing value unless its list has a null.

  • Filters on measures apply to each group's aggregate rather than to rows, using the field's default aggregate.

Date ranges

date_range works on date and timestamp fields. An absolute range includes both its days.

{ "field": "order_date", "op": "date_range", "range": { "kind": "absolute", "start": "2026-01-01", "end": "2026-06-30" } }
{ "field": "order_date", "op": "date_range", "range": { "kind": "relative", "preset": "last_n_days", "n": 30 } }
PresetCovers
today, yesterdayThat day
last_n_daysThe n days ending yesterday
last_n_weeks, last_n_monthsThe n whole weeks or months before this one
this_weekThis week's start through today
month_to_date, quarter_to_date, year_to_dateThe period's start through today
last_week, last_month, last_quarter, last_yearThe whole previous period
rolling_12_monthsThe 12 whole months before this one
fiscal_quarter_to_date, fiscal_year_to_dateThe fiscal period's start through today
last_fiscal_quarter, last_fiscal_yearThe whole previous fiscal period

n is 1 to 3,660. Weeks follow the organization's week start, and fiscal periods its fiscal year start. On a timestamp field, a range covers whole days in the query's time zone.

Sorts

{ "field": "revenue", "direction": "desc" }

direction is asc or desc. field is a result column's key, or a field name, which sorts by the first column over that field. It has to be in the query.

With no sorts, the first date or grain column runs forward. If there's none, the first measure runs highest first. If there's no measure, the first column runs lowest first.

Paging

One request returns at most 1,000 rows. Asking for more gets 422 validation_failed. stats.hasMore says more follow, and there are two ways to ask for them.

offsetkeyset
Good forJumping to a page, short resultsReading everything, deep results
How it readsThe source produces and throws away every row before the window, so deep windows get slowerEach window continues after the last row of the one before
OrderRows that tie on your sort may move between windowsEvery row has one place, since the remaining dimensions are added to the sort as tiebreakers

Each row you get back counts toward the daily row budget, whichever you use.

Keyset paging

Send keyset: true on every window. Each window with more to come has a stats.nextCursor. Pass it back as cursor, along with offset set to where that window ended.

import os

import requests

url = f"https://api.usehalo.net/v1/developer/datasets/{os.environ['DATASET_ID']}/query"
headers = {"Authorization": f"Bearer {os.environ['HALO_API_KEY']}"}
query = {
    "dimensions": [{"field": "order_date"}, {"field": "region"}, {"field": "channel"}],
    "measures": [{"field": "revenue"}, {"field": "orders"}],
    "limit": 1000,
    "keyset": True,
}

offset, after = 0, None
while True:
    body = {**query, "offset": offset, **({"cursor": after} if after else {})}
    response = requests.post(url, headers=headers, json=body, timeout=330)
    response.raise_for_status()
    page = response.json()
    for row in page["rows"]:
        print(row)
    if not page["stats"]["hasMore"]:
        break
    offset += page["stats"]["rowCount"]
    after = page["stats"]["nextCursor"]

This stops on any error. To retry the ones worth retrying, see Snippets.

A cursor belongs to one query, sort and offset. Change any of them and Halo ignores it and uses offset, so the window is still right, only slower. The last window has no cursor. Treat a cursor as opaque, and send it back as it came.

Count and totals

Each of these runs a second query.

countRows: true puts the number of rows the query returns, ignoring limit and offset, in stats.total. Results too big to count cheaply can get the source's estimate instead of an exact count: on sources that can estimate, such as PostgreSQL, from about a million rows. stats.totalEstimated is true when it is one.

totals: true adds a top-level totals object: the measures over every row, by column key. Averages and distinct counts are exact, rather than added up from the rows you see.

{ "totals": { "revenue": 431970.75, "orders": 1824 } }

If either fails, the rows still come back and warnings says why. With a filter on a measure, totals that don't add up from the groups (such as averages) are null, with a warning. totals does nothing in a query with no measures.

Time zones

timeZone takes an IANA name such as America/Denver. It decides where date grains fall, what relative ranges mean, and the zone timestamps come back in. Left out, the organization's applies. stats.timeZone says which one the answer used. An unknown zone gets 422 invalid_query.

Set it when your job shouldn't move if someone changes the organization's zone.

Read the answer

{
  "columns": [
    { "key": "order_date__month", "field": "order_date", "label": "Order date", "role": "dimension", "type": "date", "grain": "month" },
    { "key": "revenue", "field": "revenue", "label": "Revenue", "role": "measure", "type": "number", "aggregate": "sum", "format": "currency:USD" }
  ],
  "rows": [
    ["2026-09-01", 412880.1],
    ["2026-10-01", 431970.75]
  ],
  "stats": {
    "durationMs": 212,
    "rowCount": 2,
    "hasMore": false,
    "cached": true,
    "computedAt": "2026-11-03T14:58:02Z",
    "timeZone": "America/Denver"
  },
  "warnings": []
}

rows and columns

rows holds an array per row, in the order of columns. Turn them into objects by key:

const objects = rows.map((row) =>
  Object.fromEntries(columns.map((column, i) => [column.key, row[i]])),
);
Column fieldMeans
keyThe column's name in this result: the alias, or the field with any __grain, __bin or __aggregate suffix
fieldThe dataset field it reads
labelThe field's label, for people
roledimension or measure
typestring, number, integer, boolean, date or timestamp
grain, binWidth, aggregateHow it was grouped or combined, when it was
formatHow Halo displays values, such as percent or currency:USD, when the field sets one
semanticTypeWhat the values are, when the field says
typeValues look like
stringText
number, integerJSON numbers
booleantrue or false
date2026-07-04
timestamp2026-07-04T09:30:00, local to stats.timeZone, with no offset

Any value can be null.

stats and warnings

StatMeans
durationMsHow long the query took
rowCountRows in this answer
hasMoreMore rows follow
nextCursorMarks the last row, for cursor. On keyset requests with more rows
totalEvery row the query returns. When you asked for countRows
totalEstimatedtrue when total is the source's estimate rather than a count
cachedThe result came from Halo's cache rather than the source
computedAtWhen the source answered
timeZoneThe zone the query used

A dataset's cache lifetime decides how long cached answers last. The API has no way to skip the cache.

warnings is a list of sentences for people, and is empty when there's nothing to say. Log them.

Long queries

Each plan bounds how long a query may run: see Rate limits. Past that you get 504 query_timeout. Closing the connection cancels the query at the source, so set your client's timeout a little above your plan's, and give up by closing rather than leaving requests running.

A chart's rows

GET /v1/developer/charts/{id}/data runs a saved chart's own query against its dataset, as the person, and answers in the same shape. It takes no body, returns at most 1,000 rows, and doesn't page.