Skip to content

Reporting

Reports are GAQL queries sent to one of two endpoints that differ only in how the result set is delivered:

Verify against Ads Manager

The same report is available on screen at ads-manager.huydata.com with the platform's own column names; its Totals row is the reference for your pipeline. See Ads Manager.

googleAds:searchStream googleAds:search
Path POST /v18/customers/{customerId}/googleAds:searchStream POST /v18/customers/{customerId}/googleAds:search
Request body {"query": "…"} {"query": "…", "pageSize": n, "pageToken": "…", "returnTotalResultsCount": true}
Response JSON array of batches, each with up to 1,000 results; the whole result set in one HTTP response JSON object with one page of up to pageSize results and a nextPageToken
Requests needed 1 per query 1 per page
Use for Scheduled pulls, backfills, anything you store Ad-hoc exploration, UIs, very large result sets you want to process incrementally

Both endpoints run the same GAQL (GAQL) with the same fields (Fields reference) and produce rows in the same shape.

searchStream

curl -s -X POST "https://googleads.huydata.com/v18/customers/5423891542/googleAds:searchStream" \
  -H "Authorization: Bearer $GOOGLE_ACCESS_TOKEN" \
  -H "developer-token: $GOOGLE_DEVELOPER_TOKEN" \
  -H "login-customer-id: 9990001111" \
  -H "Content-Type: application/json" \
  -d '{"query": "SELECT segments.date, campaign.id, campaign.name, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value, metrics.video_views FROM campaign WHERE segments.date BETWEEN '\''2026-10-20'\'' AND '\''2026-10-22'\'' ORDER BY segments.date, campaign.id"}'
[
  {
    "results": [
      {
        "segments": { "date": "2026-10-20" },
        "campaign": { "id": "22245080429", "name": "DMS_GG_AND_SEA_VALUE_202512" },
        "metrics": {
          "impressions": "200898",
          "clicks": "1729",
          "costMicros": "345860000",
          "conversions": 286.0,
          "conversionsValue": 127.06,
          "videoViews": "65774"
        }
      },
      {
        "segments": { "date": "2026-10-20" },
        "campaign": { "id": "26359584284", "name": "DMS_GG_AND_EA_INSTALL_202610" },
        "metrics": {
          "impressions": "92314",
          "clicks": "1024",
          "costMicros": "831060000",
          "conversions": 117.0,
          "conversionsValue": 255.63,
          "videoViews": "11726"
        }
      },
      "..."
    ],
    "fieldMask": "segments.date,campaign.id,campaign.name,metrics.impressions,metrics.clicks,metrics.costMicros,metrics.conversions,metrics.conversionsValue,metrics.videoViews",
    "requestId": "58e2b280f2044787b801"
  }
]
Response element Type Description
(top level) array One element per batch. Batches hold up to 1,000 results each, in query order; a result set of 1,735 rows arrives as [{1000 results}, {735 results}]. An empty result set is [{"results": [], …}] — one batch with no results, never an empty array.
[i].results[] array of objects The rows — see Result shape
[i].fieldMask string Comma-separated camelCase paths of the selected fields, in SELECT order (identical in every batch)
[i].requestId string Request id, identical in every batch and equal to the x-request-id response header

Iterate over every batch and concatenate results; do not read only [0].

search returns one page at a time. The body accepts three optional fields in addition to query:

Body field Type Default Description
query string required The GAQL query
pageSize integer 1000 Rows per page, 1 … 10000. Outside this range: requestError: INVALID_PAGE_SIZE; not an integer: requestError: INVALID_INPUT
pageToken string — The nextPageToken from the previous page. Omit for the first page. Malformed: requestError: INVALID_PAGE_TOKEN
returnTotalResultsCount boolean false When true, adds totalResultsCount to the response
curl -s -X POST "https://googleads.huydata.com/v18/customers/5423891542/googleAds:search" \
  -H "Authorization: Bearer $GOOGLE_ACCESS_TOKEN" \
  -H "developer-token: $GOOGLE_DEVELOPER_TOKEN" \
  -H "login-customer-id: 9990001111" \
  -H "Content-Type: application/json" \
  -d '{"query": "SELECT segments.date, ad_group_ad.ad.id, metrics.impressions FROM ad_group_ad WHERE segments.date DURING LAST_7_DAYS", "pageSize": 3, "returnTotalResultsCount": true}'
{
  "results": [
    { "segments": { "date": "2026-10-23" }, "adGroupAd": { "ad": { "id": "20282091516" } }, "metrics": { "impressions": "9378" } },
    { "segments": { "date": "2026-10-23" }, "adGroupAd": { "ad": { "id": "23034801339" } }, "metrics": { "impressions": "16867" } },
    { "segments": { "date": "2026-10-23" }, "adGroupAd": { "ad": { "id": "23094874993" } }, "metrics": { "impressions": "7947" } }
  ],
  "fieldMask": "segments.date,adGroupAd.ad.id,metrics.impressions",
  "requestId": "83c99070204b4bb6af1e",
  "nextPageToken": "bz0z",
  "totalResultsCount": "153"
}
Response field Type Description
results[] array Up to pageSize rows
fieldMask string As in searchStream
requestId string As in searchStream
nextPageToken string Present only if more rows remain. Send it back as pageToken with the same query and pageSize to get the next page. Treat it as opaque.
totalResultsCount string (int64) Total rows matched by the query (all pages). Only when returnTotalResultsCount is true

The second page is requested with the token and returns the next three rows and a new token ("nextPageToken": "bz02"); the last page has no nextPageToken.

Paging loop

import requests

BASE = "https://googleads.huydata.com/v18"

def search_all(customer_id, query, headers, page_size=1000):
    url = f"{BASE}/customers/{customer_id}/googleAds:search"
    body = {"query": query, "pageSize": page_size}
    while True:
        resp = requests.post(url, headers=headers, json=body, timeout=60)
        resp.raise_for_status()
        page = resp.json()
        yield from page.get("results", [])
        token = page.get("nextPageToken")
        if not token:
            break
        body["pageToken"] = token

Each page is one request against the 60/minute quota. For a daily pull prefer searchStream, which returns everything in a single request.

Result shape

Each result is a JSON object that mirrors the selected fields as nested, camelCase keys. The GAQL field campaign.app_campaign_setting.app_id becomes result["campaign"]["appCampaignSetting"]["appId"]; metrics.cost_micros becomes result["metrics"]["costMicros"]. The fieldMask string lists exactly these paths, so you can use it to flatten rows generically:

def flatten(result, field_mask):
    row = {}
    for path in field_mask.split(","):
        node = result
        for key in path.split("."):
            node = node.get(key) if isinstance(node, dict) else None
        row[path] = node          # None when the field is absent
    return row

Value types

GAQL type JSON Fields Note
int64 string customer.id, campaign.id, campaign_budget.id, campaign_budget.amount_micros, ad_group.id, ad_group_ad.ad.id, geo_target_constant.id, metrics.cost_micros, metrics.impressions, metrics.clicks, metrics.video_views, totalResultsCount Quoted to avoid 53-bit precision loss in JavaScript. Convert with int().
double number metrics.conversions, metrics.conversions_value, metrics.all_conversions, metrics.ctr, metrics.average_cpc, metrics.average_cpm, metrics.cost_per_conversion, metrics.conversions_from_interactions_rate, metrics.video_view_rate
bool boolean customer.manager
everything else string names, enums, dates, resource names ad_group_ad.ad.final_urls is an array of strings

Money and conversions

Field Unit Example Meaning
metrics.cost_micros, campaign_budget.amount_micros micros of USD "345860000" $345.86 — divide by 1,000,000
metrics.average_cpc, metrics.average_cpm, metrics.cost_per_conversion micros of USD (double) 2401437.42 $2.40 per install
metrics.conversions count (double) 286.0 App installs attributed to the ad
metrics.conversions_value USD (double, 2 decimals) 127.06 Attributed purchase value from those installs
metrics.all_conversions count (double) 836.0 Installs + purchase events

Absent fields

A key is omitted from a result when its value is unknown for that row; only the fieldMask is guaranteed to be complete. Treat a missing key as null.

Aggregation and segments

  • Attributes only (no segments.*, no metrics.*, no segments.date filter): one row per entity in the resource — every campaign, every ad group, every ad — including paused and ended ones.
  • Metrics without segments: one row per entity, with metrics summed over the whole date filter. With WHERE segments.date BETWEEN '2026-10-01' AND '2026-10-29' you get a 29-day total per entity; with no date filter at all you get a lifetime total since 2026-06-30.
  • Metrics with segments: one row per entity × distinct segment value. segments.date gives daily rows; segments.date + segments.geo_target_country gives daily rows per country. Selecting a date segment requires a segments.date filter (EXPECTED_FILTERS_ON_DATE_RANGE).
  • Derived metrics (ctr, average_cpc, cost_per_conversion, …) are computed from the aggregated sums of each row.
-- 7-day totals per campaign
SELECT campaign.id, campaign.name, metrics.impressions, metrics.clicks, metrics.cost_micros,
       metrics.conversions, metrics.conversions_value
FROM campaign
WHERE segments.date DURING LAST_7_DAYS
{
  "campaign": { "id": "22245080429", "name": "DMS_GG_AND_SEA_VALUE_202512" },
  "metrics": {
    "impressions": "1555799",
    "clicks": "13505",
    "costMicros": "2663840000",
    "conversions": 2168.0,
    "conversionsValue": 839.62
  }
}

Rows with zero activity are omitted

As soon as a query touches performance data (any metrics.* or segments.* in SELECT or WHERE, or a segments.date filter), only entity × segment combinations with at least one impression on at least one day of the range are returned. There are no rows with all-zero metrics. Consequences for a daily pull:

  • A campaign that was paused or had ended on a date has no row for that date.
  • A country in which an ad group did not serve has no row.
  • A query for a date in the future, or for DURING TODAY, returns zero results ([{"results": [], …}]) rather than an error.
  • If you need a complete entity list, run a separate attributes-only query (SELECT campaign.id, campaign.name, campaign.status FROM campaign).

Data freshness and the attribution window

Report dates are UTC days, available the next day

All accounts use Etc/UTC. The data for report date D becomes available at 00:10 UTC on D+1 — in other words the latest available date is always yesterday (UTC). Any date range you request is clipped to 2026-06-30 … yesterday; DURING TODAY is always empty and THIS_MONTH effectively ends yesterday. Schedule daily pulls after 00:10 UTC and request DURING YESTERDAY (or the explicit date).

Impressions, clicks and cost are final immediately

Delivery metrics (impressions, clicks, video_views, cost_micros) for a report date never change after they appear.

Conversions keep increasing for 3 days

Installs and purchases are attributed back to the day the ad was clicked, and attribution keeps arriving for up to 3 days after the report date. Consequently metrics.conversions (installs), metrics.all_conversions (installs + purchases), metrics.conversions_value and every derived metric (cost_per_conversion, conversions_from_interactions_rate) for a recent date increase each time you re-query until the date is 3 days old.

The same query (SELECT segments.date, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value, metrics.all_conversions FROM customer WHERE segments.date = '2026-10-25'), run on four consecutive days:

Queried on (UTC) Age of 2026-10-25 impressions clicks costMicros conversions conversionsValue allConversions
2026-10-26 1 day "506278" "5062" "1937960000" 729.0 517.54 754.0
2026-10-27 2 days "506278" "5062" "1937960000" 783.0 648.64 810.0
2026-10-28 3 days "506278" "5062" "1937960000" 807.0 736.0 836.0
2026-10-29 4 days "506278" "5062" "1937960000" 807.0 736.0 836.0

On the first day only about 90 % of the installs and 80 % of the purchase value are visible; the figures are final from the third day on. Because conversion counts are whole numbers settled per attribution read, a row's count can move up or down by one between reads before it settles; the final value is the one you see once the date is 3 days old.

What this means for a daily pipeline: every day, pull not just yesterday but the last 3 report dates (yesterday, the day before and the day before that) and overwrite what you stored for them — the newer value is always the more complete one. Rows keyed by entity id + report date (+ country) can be replaced in full.

-- run daily: refreshes the still-maturing window
SELECT segments.date, segments.geo_target_country,
       campaign.id, ad_group.id, ad_group_ad.ad.id,
       metrics.impressions, metrics.clicks, metrics.video_views, metrics.cost_micros,
       metrics.conversions, metrics.conversions_value
FROM ad_group_ad
WHERE segments.date DURING LAST_7_DAYS

LAST_7_DAYS is a comfortable superset of the 3-day window and still costs a single searchStream request per customer.

Service status (sandbox only)

GET https://googleads.huydata.com/health needs no authentication and reports the latest available report date. It is not part of the Google Ads API and is provided for convenience:

{ "status": "ok", "platform": "google", "latest_report_date": "2026-10-29" }

For one customer, one searchStream request with this query returns the finest grain the API offers — ad × country × day — for the maturing window, typically well under 2,000 rows:

SELECT
  segments.date,
  segments.geo_target_country,
  customer.id,
  campaign.id, campaign.name, campaign.status,
  campaign.app_campaign_setting.app_id,
  ad_group.id, ad_group.name,
  ad_group_ad.ad.id, ad_group_ad.ad.name, ad_group_ad.ad.type,
  metrics.impressions, metrics.clicks, metrics.video_views, metrics.cost_micros,
  metrics.conversions, metrics.conversions_value
FROM ad_group_ad
WHERE segments.date BETWEEN '2026-10-27' AND '2026-10-29'
ORDER BY segments.date, campaign.id, ad_group.id, ad_group_ad.ad.id

Repeat for each customer id from customers:listAccessibleCustomers (10 requests in total, well under the 60/minute quota), resolve segments.geoTargetCountry with the geo target mapping, convert costMicros to dollars, and parse the campaign name with the naming convention to recover game, OS, geo group, objective and launch month.