google-ads · git:20260817.6541167 · 2026-08-17 · sha256 5223f60bf8e1e09b

google-ads git:20260817.6541167A

Immutable. This exact content is served forever at /api/v1/blob/5223f60bf8e1e09b.

---
name: google-ads
description: Traps when running Google Ads through treg — the API requirements and cleanup semantics that cost round-trips or money. Use whenever asked to analyse ad performance, audit spend, create or change campaigns, adjust budgets or bids, or do media buying.
---

# Google Ads via treg — the traps

Verified live on 2026-07-22 against a live account, via `treg call google-ads`.
Each trap is tagged with how many independent agents hit it when working **without** this file, so
you know which are real and which are here for insurance.

Account id: `treg connections ls` → the `google-ads` row; `treg connections resources <id>` to list.
Never guess an account id — you may be spending someone else's money.

## 1. Pin the API version — there is no discovery  ⚠️ 2/2 agents hit this

Paths are versioned and a wrong guess returns a **Google HTML 404**, not JSON. Nothing in the API
or in `treg tool ls` reports the current version — one agent burned **five** calls walking v14→v18
before recovering the answer from the `treg calls` audit log.

**Use `v25`** (released 2026-07-22, live-verified through the proxy 2026-08-17; sunsets ~Aug 2027).
Do not guess downward.

Two different failures, two different fixes:

- **HTML 404** — the version never existed (you guessed too high, or typo'd the path).
- **JSON 400 `UNSUPPORTED_VERSION`** — the version existed and has been **sunset**. Google ships a
  major roughly quarterly and each lives ~12 months, so a pin that worked for months dies on a
  date, not on a deploy. v21 died this way on **2026-08-05**. Always jump to the newest GA
  version, not the next one up — the next one up may be months from its own sunset.

Release notes and sunset dates: <https://developers.google.com/google-ads/api/docs/sunset-dates>

## 2. `contains_eu_political_advertising` is required on campaign create  ⚠️ 3/3 hit this

Absent from essentially every code sample. Campaign create fails `REQUIRED` without it:

```json
"containsEuPoliticalAdvertising": "DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING"
```

## 3. Bidding strategy must be the sub-message, not the enum  ⚠️ 1/2 hit this

`"biddingStrategyType":"TARGET_SPEND"` alone fails `REQUIRED` on `campaign_bidding_strategy`. It's
a protobuf oneof — send the field itself: `"targetSpend":{}`, `"manualCpc":{}`,
`"maximizeConversions":{}`. The enum is output-only.

## 4. Removing a parent orphans children — and their status lies  ⚠️ 2/2 confused by this

Remove a campaign and its ad groups/criteria/ads become immutable but **keep reporting their old
status** — a removed campaign's ad group still reads `ENABLED`. Mutating one returns
`OPERATION_NOT_PERMITTED_FOR_REMOVED_RESOURCE`.

**Verify teardown on `campaign.status`, never on a child's.** Checking the child looks like cleanup
silently failed. Remove campaign first, then its budget — the budget is not removed for you, and an
orphaned budget is easy to leave behind.

Nothing is ever hard-deleted: `REMOVED` is terminal. Filter with `WHERE campaign.status != 'REMOVED'`
or your campaign list fills with corpses.

## 5. Money is in micros  ✅ 0/2 got this wrong — kept for asymmetry

`amountMicros: 3000000` = **$3.00/day**. Both agents handled this correctly, so it is not a common
failure — but a 10⁶ slip creates a $3,000/day budget that the API accepts **without any error**, and
it is the only mistake here that spends money silently. Compute `dollars * 1_000_000` explicitly and
re-read it before sending.

Currency is **not** always USD — the account may bill in a non-USD currency (e.g. AUD):

```
SELECT customer.currency_code, customer.time_zone FROM customer
```

## 6. `updateMask` controls what changes

```json
{"operations":[{"updateMask":"amount_micros",
  "update":{"resourceName":"customers/<CID>/campaignBudgets/<BID>","amountMicros":3000000}}]}
```

Only listed fields change; a field you set but don't list is ignored, one you list but don't set is
**cleared**. Both `amount_micros` and `amountMicros` are accepted (verified).

## 7. `login-customer-id` failures look like auth failures

Acting on a client account under a manager needs the header:

```bash
treg call google-ads "<path>" --method POST --header 'login-customer-id: 9876543210' --data '...'
```

A wrong or unauthorised value returns **`401 UNAUTHENTICATED`**, not a targeting error. Check the
header before debugging OAuth.

## `validateOnly` — free insurance on any mutation

```json
{"validateOnly":true,"operations":[...]}   // returns {} on success, changes nothing
```

Verified: a `validateOnly` budget change returned `{}` and left the value untouched. Neither
unskilled agent used it and neither needed it — their mistakes were rejected by the API anyway. Its
real value is the case they never hit: a mutation that is **valid but wrong** (a mistyped budget),
which no error will catch. Use it whenever the operation spends money or you can't cheaply undo it.

## Reading performance

```bash
treg call google-ads "v25/customers/<CID>/googleAds:search" --method POST --data '{
  "query":"SELECT campaign.name, campaign.status, campaign_budget.amount_micros,
           metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions
           FROM campaign WHERE segments.date DURING LAST_30_DAYS
           ORDER BY metrics.cost_micros DESC"}'
```

Metrics need a date condition (`DURING LAST_30_DAYS`, or `BETWEEN '2026-06-01' AND '2026-06-30'`) —
without one you get lifetime totals. Use `googleAds:searchStream` for large unpaginated pulls.
Keyword-level detail lives in `keyword_view`.

**On zero conversions** (✅ 2/2 agents already handle this): an audited account showed ~$300 spent, ~170 clicks, 0 conversions because nothing is instrumented. Both unskilled agents correctly said they
couldn't distinguish "no tracking" from "no results" — keep doing that, and check before treating
zero as a performance verdict.

## Creating a campaign — minimum that works

Budget first, then campaign. Verified end-to-end.

```json
// campaignBudgets:mutate
{"operations":[{"create":{"name":"...","amountMicros":3000000,
  "deliveryMethod":"STANDARD","explicitlyShared":false}}]}

// campaigns:mutate
{"operations":[{"create":{
  "name":"...", "status":"PAUSED",
  "advertisingChannelType":"SEARCH",
  "campaignBudget":"customers/<CID>/campaignBudgets/<BID>",
  "manualCpc":{},
  "containsEuPoliticalAdvertising":"DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING",
  "networkSettings":{"targetGoogleSearch":true,"targetSearchNetwork":false,"targetContentNetwork":false}
}}]}
```

Then `adGroups:mutate` → `adGroupCriteria:mutate` (keywords) → `adGroupAds:mutate` (responsive
search ad). A campaign with no ad group, keywords or ads **cannot serve**, whatever its status.

✅ *Both unskilled agents defaulted to `PAUSED` on a real-money account without being told, and
explained why.* Keep that default; enabling is a one-field update, an unwanted live campaign is a
refund request.

## Test accounts solve less than you'd hope

Created under a **test manager** — a separate Google account; a production manager cannot host them.
They have **no serving data** and **cannot test conversion uploads**, so they only prove mutation
mechanics. Performance and conversion work must happen on a live account.

- Field reference: https://developers.google.com/google-ads/api/fields/v25/overview
- GAQL grammar: https://developers.google.com/google-ads/api/docs/query/grammar
- Test accounts: https://developers.google.com/google-ads/api/docs/best-practices/test-accounts