Multihub Reporting API

Pull your properties' marketing performance data into your own warehouse or BI tool.

The complete query grammar: arguments, operators, grains, aggregations, paging and errors.

On this page

The two operations

reporting_datasets — the catalog

Metadata only, no arguments. Returns every dataset with its dimensions and metrics. This is the authoritative list: names you can pass to reporting_data come from here, and the description fields carry each dataset's caveats.

{
  reporting_datasets {
    name
    description
    required_integrations
    last_refresh_at
    dimensions { id name type grains }
    metrics { name description unit aggregations default_aggregation supported_dimensions }
  }
}
FieldMeaning
namePass this as dataset.
descriptionWhat it measures, and the caveats that apply to it. Read this.
required_integrationsWhich upstream connection feeds it. If a property is not connected to that source, it has no rows here.
last_refresh_atISO timestamp of the last successful build. Useful as a freshness gate before a sync.
dimensions[].typetenancy, time or category — the type determines which filter operators are legal.
dimensions[].grainsNon-null on time dimensions only: the buckets the dimension defines. A pre-bucketed dataset still lists them today even though it rejects them — its description says so, and that is the authority until the catalog is corrected.
metrics[].aggregationsEvery aggregation this metric allows. Anything else is rejected.

reporting_data — the rows

Runs one query and returns aggregated rows. Pass the spec as GraphQL variables — filters, group_by, order_by and window are JSON values, so variables are cleaner than inlining them in the document.

query Pull($group: [JSON], $filters: [JSON], $order: [JSON]) {
  reporting_data(
    dataset:   "ga4_session_source_daily"
    metrics:   ["Sessions", "New Users"]
    group_by:  $group
    filters:   $filters
    order_by:  $order
    page_size: 5000
  ) {
    dataset
    rowCount
    totalRows
    bytesProcessed
    cacheHit
    cursor
    rows
  }
}
{
  "group":   ["property", "channel", {"dim": "date", "grain": "month"}],
  "filters": [
    {"dim": "date",    "op": ">=", "value": "2026-01-01"},
    {"dim": "channel", "op": "!=",   "value": "Unassigned"}
  ],
  "order":   [{"by": "Sessions", "dir": "desc"}]
}

One query per request. Each reporting_data field runs a warehouse job, so only one is allowed per GraphQL request. Two in one document returns an error rather than running both.

Arguments

ArgumentTypeDescription
datasetString A name from reporting_datasets. Required, except when continuing with a cursor.
metrics[String!] One or more metric names from that dataset. Required (at least one), except when continuing with a cursor. An entry may also be {"name": "Sessions", "agg": "avg"} to pick a non-default aggregation.
group_by[JSON] Dimension ids to group by. An entry is an id ("date") or a bucketed time dimension ({"dim":"date","grain":"month"}). Max 6, no duplicates. Omit entirely for a single total row.
filters[JSON] {"dim":…,"op":…,"value":…} objects, AND-ed together. Values are bound as query parameters, never interpolated.
order_by[JSON] {"by":…,"dir":"asc"|"desc"} over a grouped dimension or a selected metric.
windowJSON Per-partition top-N: {"partition_by":[…],"order_by":[…],"top_n":N}.
limitInt Top-N mode: one response of at most this many rows. Mutually exclusive with page_size.
page_sizeInt Paginated mode: rows per page, max 10,000. The default when you set neither this nor limit.
cursorString A cursor from a previous page. It identifies the query on its own — send it with page_size and nothing else.

Metrics and aggregations

A metric is requested by its display name. Each has a default_aggregation that applies when you do not ask for one, and an aggregations list of everything it allows.

AggregationApplies toColumn name when not the default
sumAdditive measures (spend, clicks, sessions). Also the default on the daily GA4 user counts, which are not additive — see belowTotal …
avgMeasures and ratesAverage …
min / maxMeasuresMinimum … / Maximum …
countRow countsCount of …
ratioRatio metrics only (CTR, CPC, engagement rate)— only aggregation allowed

The output column is renamed when you pick a non-default aggregation. Requesting {"name":"Sessions","agg":"avg"} returns a column called Average Sessions, not Sessions. Only the default aggregation keeps the plain name. Map columns by the name you expect, and a schema change will fail loudly instead of silently producing nulls.

Not every metric allows every aggregation — aggregations in the catalog is the list, and anything outside it is rejected. Several metrics forbid sum outright and default to avg or max (the property-reference datasets, reputation scores, Woorank, and Search Console Position). count_distinct is not currently permitted by any metric in the catalog.

The default is not always the safe choice. Total Users and Active Users on the daily GA4 datasets default to sum, and summing distinct people is exactly the over-count described in the overview. Omitting agg is what produces it. Use the ga4_users_* datasets when you want a user count.

Ratio metrics (Paid CTR, Cost per Click, Engagement Rate…) accept only ratio, and are always computed as a ratio of sums — total clicks ÷ total impressions over the grouped rows — never an average of per-row ratios. That is the arithmetically correct rollup provided both the numerator and the denominator are additive at the row grain, which is the usual case.

Two places where that proviso fails. On paid_ad_copy_daily, rows fan out across keyword × headline × description, so each ad's cost and clicks repeat a different number of times and every rolled-up ratio is re-weighted by that fan-out — the ratios are trustworthy only for a single ad on a single day. And Events Per User (GA4) divides by a distinct-user count, which is not additive, so the denominator inflates as you roll up and the ratio comes out low. Take cost-per-acquisition and conversion-rate figures from paid_campaigns_daily or paid_ads_grouped_daily.

Dimensions and grains

Three dimension types, with different rules:

TypeDimensionsBehaviour
tenancy agency, company, property Present on every dataset. Values are ids, returned as strings. A fact can map to more than one entity, so grouping by a tenancy dimension expands such a row into one row per entity — which means a property-grouped total is slightly higher than the same query ungrouped, and rows whose scope is empty drop out entirely. Take portfolio totals from the ungrouped query.
time date Accepts a grain. Values bind as dates when written YYYY-MM-DD.
category Everything else — channel, campaign, keyword, city, device String-valued. The only type the text operators accept.

Grains

A time dimension can be bucketed coarser than it is stored:

"group_by": [{"dim": "date", "grain": "month"}]
GrainBucket
dayThe stored day (the default when no grain is given).
weekISO week, Monday-first. Returned as the week's Monday.
monthCalendar month, returned as the 1st.
quarterCalendar quarter, returned as its first day.
yearCalendar year, returned as Jan 1.

A grain on a non-time dimension is an error. So is a grain on the pre-bucketed ga4_users_* datasets — their rows already are the bucket.

Pre-bucketed datasets

ga4_users_daily, ga4_users_weekly and ga4_users_monthly require group_by on date. Their measure is a distinct-user count GA4 computed for the whole bucket, and adding two buckets counts a returning visitor twice — no aggregate can undo that. A query that would collapse the buckets is rejected rather than silently answered wrong.

Filters and operators

Each filter is {"dim": …, "op": …, "value": …}. Filters are AND-ed. Omit op and it defaults to =.

OperatorValueNotes
= !=scalarExact match.
> >= < <=scalarRanges. Mostly used on date.
INnon-empty arrayAny of. An empty array is an error, not "match everything".
STARTS_WITHstring or arrayCase-sensitive prefix. Category dimensions only.
CONTAINSstring or arrayCase-sensitive substring. Category dimensions only.
NOT_CONTAINSstring or arrayContains none of the values. Category dimensions only.
CONTAINS_ICstring or arrayCase-insensitive substring — for human search boxes.

Text operator rules

Filtering on tenancy dimensions

Because a fact row can map to several entities, a tenancy filter matches a row if any of its entities match. The ids below are placeholders you supply — the companies query returns the ones your token can see:

[{"dim": "property", "op": "IN",
  "value": ["YOUR_PROPERTY_ID", "ANOTHER_PROPERTY_ID"]},
 {"dim": "company", "op": "=", "value": "YOUR_COMPANY_ID"}]

Ids are 16-character values and should be sent, stored and compared as strings — they are larger than a double represents exactly, so treating one as a number loses precision. Get them from the companies query. A tenancy filter narrows within your grants; it can never widen them.

Filtering on dates

Write dates as YYYY-MM-DD and they bind as real dates. A half-open range is the safest form — >= the first day, < the day after the last:

[{"dim":"date","op":">=","value":"2026-01-01"},
 {"dim":"date","op":"<", "value":"2026-07-01"}]

Ordering

order_by takes {"by": …, "dir": "asc"|"desc"}. dir defaults to desc. by must name a dimension you grouped by, or a metric you selected — using the metric's output name, so Average Sessions if you asked for avg. Anything else is an error.

"order_by": [{"by": "Paid Spend", "dir": "desc"}, {"by": "property", "dir": "asc"}]

Per-partition top-N

window ranks rows within each bucket — the top 3 cities for every property, the top 10 keywords per campaign — in one query.

"group_by": ["property", "city"],
"window": {
  "partition_by": ["property"],
  "order_by":     [{"by": "Sessions", "dir": "desc"}],
  "top_n":        3
}
partition_byThe buckets to rank within. Each must be a grouped dimension, so window requires group_by. No duplicates.
order_byWhat to rank by — same shape as the top-level order_by, over a grouped dimension or selected metric.
top_nPositive integer cutoff.

Ties share a rank, so a partition can return more than top_n rows when values tie at the cutoff.

Top-N mode vs paginated mode

Top-N mode (limit)Paginated mode (page_size)
Use forDashboard tiles, "top 20 campaigns"Any complete pull into a warehouse
ResponsesOneOne per page, followed by cursor
totalRowsNot reported — you cannot tell whether more matchedExact match count
Pair withorder_by, always — otherwise "which 20" is arbitrary

The two are mutually exclusive; sending both is an error. Sending neither gives you paginated mode with a page size of 10,000, which is what a bulk pull wants.

limit truncates silently. If 40,000 rows match and you asked for 1,000, you get 1,000 rows and no indication that the rest exist. Use it only where truncation is the point.

Paginated mode

The first call runs the query once and returns page 1 plus a cursor. Each follow-up call reads the next page of that same finished result — the query does not re-run, so later pages come back quickly and cannot shift under you when the nightly build replaces a table mid-pull.

A continuation request carries only cursor and optionally page_size. The cursor already identifies the dataset, metrics and filters; sending a spec alongside it is an error rather than a silently ignored change.

query Page($cursor: String) {
  reporting_data(cursor: $cursor, page_size: 5000) {
    rowCount totalRows cursor rows
  }
}

Loop while cursor is non-null. It is null on the last page.

Response fields

FieldTypeMeaning
datasetStringEcho of the dataset queried.
rows[JSON]The rows. Keys are exactly the dimension ids you grouped by and the output names of the metrics you selected.
rowCountIntRows in this response.
totalRowsFloatTotal rows the query matched. Paginated mode only.
bytesProcessedFloatHow much data the query read. A rough proxy for how heavy it was.
cacheHitBooleanWhether the warehouse served the query from cache.
cursorStringNext page, or null on the last page.

bytesProcessed is per job, not per page. Every page of a paginated pull echoes the same total, so summing it across pages multiplies it by the page count. Page 1's value describes the whole pull.

Errors

Errors come back in GraphQL's errors array with a code in extensions.code. The message is specific — it names the dataset, dimension or metric at fault — so log it.

CodeMeansDo
BAD_USER_INPUT Unknown dataset, dimension or metric; an unsupported operator or aggregation; a malformed spec; an expired cursor; a request body over the size limit. Fix the request. Do not retry unchanged.
UNAUTHENTICATED Missing, malformed or revoked token. Check the Authorization header; mint a new token if it was revoked.
FORBIDDEN The token is valid but the account has no property grants, or a cursor belongs to a different user. Contact your Repli account contact — this is an access-grant issue.
INTERNAL_SERVER_ERROR Something failed on our side. Retry with backoff. If it persists, get in touch with the time and the query.

An expired cursor also carries extensions.reportingStatus: 410, which lets a sync job distinguish "start this pull again" from "this request was malformed" without parsing the message.