Multihub Reporting API

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

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.

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:

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.

DatasetGrainCovers
paid_campaigns_dailyCampaign × dayEvery campaign type. Use it for totals.
paid_ads_grouped_dailyAd group × dayEvery type that has ad groups — search, display, discovery.
paid_keywords_dailyKeyword × dayEvery type that has keywords — search.
paid_ad_copy_dailyCreative × dayAds 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

LimitValueNotes
Rows per response10,000Both limit and page_size share this ceiling.
Default page size10,000Applied when you set neither limit nor page_size.
Total rows per queryunlimitedPage through them with cursor.
Dimensions per query6group_by entries; duplicates rejected.
Queries per request1One reporting_data field per GraphQL request. Split into separate requests.
Cursor lifetime23 hoursAfter that, re-run the query.
Request timeout4 minutesPer 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.