One endpoint, one token, two calls. Browse a catalog of report datasets, then query any of them for aggregated rows — scoped automatically to the properties your account can see.
On this page
Endpoint and authentication
The API is a single GraphQL endpoint. Every request is a POST with a JSON body and a
bearer token.
| Endpoint | https://api.multihub.io/graphql |
|---|---|
| Method | POST, Content-Type: application/json |
| Auth | Authorization: Bearer <your-token> |
Getting a token
Sign in to Multihub, open Profile → API Tokens, name a token and create it. The full token value is shown once, at creation — store it in your secret manager then, because it cannot be retrieved again.
- Read-only on this API. A token issued this way can run queries against the GraphQL endpoint documented here; every mutation on it is refused.
- No expiry. A token stays valid until you revoke it, so a scheduled nightly job will not break on a rotation you forgot about.
- Revocable at any time. Delete it on the same screen and it stops working immediately. Revocation is the control — if a token may have been exposed, revoke it and create a new one.
Treat the token like a password. It carries the full read access of the account that created it. Do not commit it to source control, embed it in a browser app, or paste it into a shared document.
Quickstart
Two calls, in the order you will use them. First, list the datasets available to you:
export MULTIHUB_API=https://api.multihub.io/graphql
curl -sX POST "$MULTIHUB_API" \
-H "Authorization: Bearer $MULTIHUB_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"{ reporting_datasets { name description last_refresh_at dimensions { id name type grains } metrics { name unit aggregations default_aggregation } } }"}'
Then query one of them. This pulls monthly Google Ads spend and clicks per property for the first half of 2026:
curl -sX POST "$MULTIHUB_API" \
-H "Authorization: Bearer $MULTIHUB_TOKEN" \
-H "Content-Type: application/json" \
--data-binary @- <<'JSON'
{
"query": "query($g:[JSON],$f:[JSON]){ reporting_data(dataset:\"paid_campaigns_daily\", metrics:[\"Paid Spend\",\"Paid Clicks\"], group_by:$g, filters:$f, page_size:5000) { rowCount totalRows bytesProcessed cursor rows } }",
"variables": {
"g": ["property", {"dim": "date", "grain": "month"}],
"f": [
{"dim": "date", "op": ">=", "value": "2026-01-01"},
{"dim": "date", "op": "<", "value": "2026-07-01"}
]
}
}
JSON
The query text stays on one line because it lives inside a JSON string; everything that varies moves
into variables, where it can be laid out normally.
The response:
{
"data": {
"reporting_data": {
"rowCount": 5000,
"totalRows": 18432,
"bytesProcessed": 412598272,
"cursor": "v1.eyJqb2JJZ...",
"rows": [
{ "property": "4521250000000001", "date": {"value": "2026-01-01"}, "Paid Spend": 4820.44, "Paid Clicks": "1893" },
{ "property": "4521250000000001", "date": {"value": "2026-02-01"}, "Paid Spend": 5110.02, "Paid Clicks": "2044" }
]
}
}
}
Two column shapes will surprise a loader. A date arrives as an object —
{"value": "2026-01-01"}, not a bare string — because that is how BigQuery hands us a
DATE. And a metric stored as BigQuery NUMERIC arrives as a quoted number
("1893"), so the same metric name can be a JSON number on one dataset and a string on
another. Normalise both on the way in: read row.date.value, and coerce metric values to
numbers rather than trusting their JSON type.
rows is a plain list of JSON objects keyed by the dimensions and metrics you asked for.
cursor is non-null while more pages remain — send it back to fetch the next page, and
loop until it is null. See Paginated mode.
What your token can see
Every query is filtered, in the database, to the agencies, companies and properties your account is granted. You do not pass a customer id and there is nothing to configure — a query with no filters returns your whole portfolio and nothing outside it.
This applies to the data queries only. The dataset catalog describes every report that exists on the platform, whether or not your properties feed it. A dataset your properties have no connection for is not an error: it simply returns zero rows.
How the data is shaped
Each dataset is a table of daily aggregates. You choose:
- metrics — what is measured (
Paid Spend,Sessions,Search Clicks…), aggregated for you; - group_by — the grain you want back (by property, by month, by campaign, by city…);
- filters — which rows to include.
Rows carry identifiers, not names: property, company and
agency come back as ids. Multihub owns the naming and names change, so joining on an id is
what keeps your warehouse stable. Resolve ids to names with the companies and
properties queries on this same endpoint; the Guides panel in the
playground has a runnable example.
Keep ids as strings throughout. They are 16 characters and larger than a double represents exactly, so parsing one as a number silently corrupts it.
Examples that filter to a single property show a placeholder such as
YOUR_PROPERTY_ID. Those are yours to supply — run the
Company and property names query in the Guides panel first and substitute one of the ids it
returns.
Before you build
Seven things that will otherwise surprise you. Each dataset's own description in the
catalog repeats the ones that apply to it.
1. Data is complete through today − 3
Upstream sources restate for a couple of days after the fact, so we treat the last three days as provisional. A nightly pull is the right cadence.
The API will not apply this cut for you. Rows past it exist and look plausible
— yesterday typically returns most of a settled day, and today a small fraction — so a
query with an open-ended date filter quietly mixes final and provisional data. Bound every window
explicitly at today − 3.
2. History restates — re-pull a trailing 45-day window
Advertising platforms revise past days (conversion attribution lands late, invalid clicks are
credited back). Our own nightly build re-reads the trailing 45 days for that reason. Do the same: each
night, re-pull the window from today − 48 to today − 3 and
replace those dates — delete the range and insert, or upsert on your natural key.
Appending only new dates leaves you with the first version of every restated day.
Normalise date before you key anything on it. It arrives as
{"value": "2026-01-01"}, so a delete-range or upsert written against a bare date string
matches nothing — and a replace that matches nothing becomes an append. The window then
duplicates on every run, growing without bound and without an error. Read
row.date.value into a real date column as the first thing your loader does.
3. Dates are source-local
Each row's date is the day as the upstream source recorded it: the GA4 property's timezone for GA4, the ad account's timezone for Google and Facebook Ads, roughly Pacific for Search Console. There is no timezone column, and dates are not converted to a common zone. For day-grain comparisons across sources, expect boundary noise.
4. Some measures are not additive
Total Users and Active Users are distinct-person counts. Summing them across
days or across channels counts the same person once per row they appear in, and the overshoot grows
sharply as a property gets smaller.
For a correct distinct-user figure use ga4_users_daily, ga4_users_weekly or
ga4_users_monthly, which carry the count GA4 computed for the whole bucket. Those datasets
refuse a query that would aggregate their buckets together. New Users is the exception and
is additive.
Budget Rollover on paid_budgets_cycle is the other one, and nothing stops
you summing it: rollover is a balance carried from the previous cycle, so adding it across cycles
re-counts dollars that stayed unspent for more than one month. A multi-cycle budget is
SUM(Budget Amount) plus the first cycle's rollover, not the sum of both columns.
Impression share is a ratio, and paid_search_share_daily therefore carries it as
counts: the Search impressions each campaign won, the eligible impressions it could have
won, and how many it lost to budget and to Ad Rank. Sum those at whatever grain you need and divide
— or use the dataset's own Search Impression Share, Share Lost to Budget
and Share Lost to Rank metrics, which do exactly that. Averaging a per-row share is wrong.
Google reports a share below 10% as "<10%" and a lost share above 90% as ">90%", so the eligible
and lost counts are lower bounds where a campaign is most constrained.
The property-reference datasets are levels rather than totals — they reject sum
outright, and Total Units defaults to max while keeping its plain name. Divide
by it at property grain and aggregate in your own model; a company-grain
spend ÷ Total Units divides by one property's unit count, not the company's.
5. GA4 totals include "Unassigned"
GA4 files referral-less traffic (including AI assistants) under an Unassigned channel.
Our totals are GA4-native and include it. To reconcile against a GA4 channel report, filter
channel != 'Unassigned' explicitly.
6. Campaigns join by name, not by id
Advertising datasets expose campaign, ad group and keyword as names and text. Platform ids (Google Ads campaign / ad group / ad ids) are not queryable.
If you join our rows to your own Google Ads pull, the join key is
(property, date, campaign name). Renaming a campaign in the Google Ads UI will silently
break that join for rows on either side of the rename — it shows up as a drop in matched rows,
not as an error. Plan for it: snapshot campaign names, or reconcile match rates on every load.
7. Total Google Ads at campaign grain, not at a drill-down grain
Four Google Ads datasets sit at four grains. Each one carries every campaign type that Google publishes at that grain — and campaign types differ in how deep that goes. Performance Max is built from asset groups, not ad groups, and has no keywords at all, so Google reports it at campaign level and no deeper. Nothing is filtered out on our side; the deeper datasets simply cannot have rows that do not exist.
| Dataset | Grain | Covers |
|---|---|---|
paid_campaigns_daily | Campaign × day | Every campaign type. Use it for totals. |
paid_ads_grouped_daily | Ad group × day | Every type that has ad groups — search, display, discovery. |
paid_keywords_daily | Keyword × day | Every type that has keywords — search. |
paid_ad_copy_daily | Creative × day | Ads with text creative. Rows fan out; compare, do not total. |
The practical consequence is the part worth wiring into your model:
Totalling a drill-down dataset under-reports, and it does so silently. Performance
Max often carries a large share of both spend and conversions, so summing
paid_ads_grouped_daily can miss a substantial part of each. How much depends entirely on
your own campaign mix — measure it rather than assume:
dataset: "paid_campaigns_daily"
metrics: ["Paid Spend", "Paid Conversions"]
group_by: ["network"]
The network dimension is the campaign's advertising channel type, and
performance_max is one of its values.
Every campaign type that does reach a deeper grain is fully represented there — the ad-group dataset accounts for essentially all search, display and discovery spend. Use the drill-downs for structure, and the campaign dataset for the number that has to reconcile with the Google Ads UI.
Limits
| Limit | Value | Notes |
|---|---|---|
| Rows per response | 10,000 | Both limit and page_size share this ceiling. |
| Default page size | 10,000 | Applied when you set neither limit nor page_size. |
| Total rows per query | unlimited | Page through them with cursor. |
| Dimensions per query | 6 | group_by entries; duplicates rejected. |
| Queries per request | 1 | One reporting_data field per GraphQL request. Split into separate requests. |
| Cursor lifetime | 23 hours | After that, re-run the query. |
| Request timeout | 4 minutes | Per request. |
There is no published request-rate limit today. Pull on a schedule, keep concurrency modest (one or two queries at a time), and page a large pull rather than fanning out many parallel queries — each query is a warehouse job.