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¶
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.*, nometrics.*, nosegments.datefilter): 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.dategives daily rows;segments.date+segments.geo_target_countrygives daily rows per country. Selecting a date segment requires asegments.datefilter (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" }
Recommended daily report¶
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.