# INSTRUCTIONS-GOOGLE.md

# adsmcp — Google Search Ads API Server

## Overview
adsmcp wraps the Google Ads API for **Google Search campaigns**. Each request runs against the connected user's own live Google Ads credentials. The server normalizes ergonomic JSON into the exact mutate / GAQL shapes Google expects, follows pagination automatically, and surfaces Google's original `errorCode` + `requestId` when something goes wrong. Every endpoint listed below is testable from the API Docs page in the browser.

This document covers **only** the Google Search endpoints (`/api/google/*`). Meta endpoints (`/api/meta/*`) are documented separately in `instructions.md` — neither file references the other's endpoints.

## Authentication
Every endpoint listed in this document requires an **API key** sent as the `X-API-Key` request header. Each user account has a single long-lived key that is shown on the API Docs page; from there it can be copied or regenerated. Regenerating immediately invalidates the previous key.

```
X-API-Key: amk_<64 hex chars>
```

The in-browser tester sends this header for you automatically. External callers (Postman, curl, AI agents) must add the header explicitly. Requests without a valid `X-API-Key` return `401 { error: "Missing API key (X-API-Key) or bearer token" }` or `401 { error: "Invalid API key" }`.

You must connect a Google Ads account once via the **Google Ads connection** card on the dashboard before any `/api/google/*` request will succeed. Tokens auto-refresh in the background; reconnect through the dashboard if the connection is ever revoked.

## Workflow Guidelines

### Discovering Available Resources
1. Call `GET /api/google/accounts` (or `/api/google/customers` — same payload) to list the Google Ads ad accounts the user can manage. Each entry has a 10-digit `id`, a `formatted_id` ("XXX-XXX-XXXX"), `descriptive_name`, `currency_code`, `time_zone`, plus flags `manager` (an MCC manager account, can't be the *target* of a launch) and `test_account`
2. Call `GET /api/google/conversion-actions?customerId=…` (richer) or `GET /api/google/conversion-goals?customerId=…` (launcher-friendly grouped view) to list the conversion actions / goals on the customer. Each action has a `category` (`PURCHASE`, `LEAD`, …) and an `origin` (`WEBSITE`, `APP`, …) — these `(category, origin)` pairs are what you flip biddable per-campaign later. **Mandatory before passing `conversionGoals` on launch:** if you want anything other than the ad account default, you MUST look up real ids / pairs from one of these endpoints first. Never guess, invent, or copy ids from another customer — Google rejects pairs that don't exist on this customer, and the conversion-goals step soft-fails into the launch's `warnings[]` (the campaign still launches, just without the goal you wanted)
3. Call `GET /api/google/budgets?customerId=…` to see existing campaign budgets you might want to reuse instead of creating a new one
4. Call `GET /api/google/geo-search?q=…` to convert place names into the geo target constant ids you need for location targeting
5. Call `GET /api/google/languages?customerId=…` to look up language ids (`1000` = English, `1003` = Spanish, `1014` = French, `1001` = German)

### Launching a Search Campaign — three ways

> **A launch is hypothesis #1 of a long sequence — not a finished product.** The whole point of a Responsive Search Ad is to test 5–15 headlines and 2–4 descriptions in rotation, watch Google grade them `BEST` / `GOOD` / `LOW` over the next 7–14 days, then iterate at TWO levels: (a) swap individual `LOW` headlines/descriptions for new angles within the same ad, and (b) when an entire ad is losing relative to its siblings, **drop the whole ad and create a new one**, carrying forward what worked. Both levers compound. See **"Iterating on RSA creative — the test → review → improve loop"** below for the full discipline. If you're not running this loop you're leaving 30–50% of potential CTR on the table.

**1. One-shot (recommended).** `POST /api/google/campaign/launch` accepts the entire campaign tree in a single body — budget + campaign + ad group + keywords + negative keywords + Responsive Search Ads + sitelinks + callouts + structured snippets + call extension + locations + languages + audiences + ad schedule. Anything you omit is simply skipped. **Default `status: 'ENABLED'` — Google launches go live immediately.** Pass `status: 'PAUSED'` if you want to stage the campaign for review before serving.

**2. Per-step.** When you want explicit control. The order matters — configure everything around the campaign **before** creating the ads, so the moment ads start serving they're already restricted to the right audience, geography, schedule, and negative keywords:
1. `POST /api/google/budgets` → capture `budget.resource_name`
2. `POST /api/google/campaigns` (pass `budgetResourceName` or `budgetId`) → capture `campaign.resource_name`
3. `POST /api/google/ad-groups` (pass `campaignResourceName` or `campaignId`) → capture `adGroup.resource_name`
4. `PATCH /api/google/campaign-conversion-goals` — flip biddable on the conversion goals you want optimization to bid for
5. `POST /api/google/campaign-criteria/locations` / `/languages` / `/audience` / `/ad-schedule` — apply campaign-level targeting and scheduling
6. `POST /api/google/negative-keywords` — exclude terms this campaign should never serve for
7. `POST /api/google/sitelinks` / `/callouts` / `/structured-snippets` / `/call-extensions` — each is created and (when you pass `campaignResourceName` or `campaignId`) linked to the campaign in the same call
8. `POST /api/google/keywords` (pass `adGroupResourceName` or `adGroupId`) — the positive keywords the ad group matches
9. `POST /api/google/ads` (pass `adGroupResourceName` or `adGroupId`) — Responsive Search Ads. **Always last.** Same handler is reachable via `POST /api/google/responsive-search-ads` if you prefer the explicit name

**3. Mixed.** Run `/campaign/launch` for the spine, then layer extra criteria (more negative keywords, more sitelinks, a second audience segment, additional ad variants) via the per-step endpoints.

**Default status of every campaign created via this server is `ENABLED`** — Google launches go live as soon as the create returns. Children (ad group, keywords, RSAs) are also created `ENABLED` by default inside the bundled launcher so the live campaign actually serves. Pass `status: 'PAUSED'` on the campaign body to stage for review before serving, then flip live with `PATCH /api/google/campaigns/:id/status` once you're ready. Pause individual ad groups or ads at any time with `PATCH /api/google/ad-groups/:id/status` and `PATCH /api/google/ads/:id/status`.

### Quality Score — keyword↔ad alignment is the #1 lever
Google's Quality Score (QS) is the single biggest determinant of your CPC and ad position. The two factors **you actually control** are (a) how well your ads match the keywords in their ad group and (b) how well your landing page matches both. Get this right and your CPC drops 30–60%; get it wrong and you outbid for the same impression.

**The hard rules — burn these in:**

1. **Headlines must be ≤30 characters. Descriptions must be ≤90 characters.** The server truncates anything longer at submission, but truncation cuts mid-word and produces ugly ads. Pre-trim every headline / description on your side, count characters including spaces, and treat the limits as hard limits. `path1` and `path2` are ≤15 characters.

2. **Every ad's headlines and descriptions must echo the keywords in its ad group.** If the ad group's keywords are `"buy widgets"`, `"red widgets"`, `"widgets online"`, then the ad needs the word `"widgets"` (and ideally `"buy"`, `"red"`, `"online"`) appearing across its headlines and descriptions. Missing the keyword from the ad copy is the most common cause of a low Quality Score. Aim for the keyword (or a close variant — singular/plural, "buy"/"shop") to appear in **at least 3 headlines** and **at least 1 description**.

3. **Tightly themed ad groups beat loose ad groups, every time.** One ad group should hold ~5–20 keywords that share a single intent (e.g. "buy red widgets", "purchase red widgets", "red widget store"). Mixing intents ("buy red widgets" + "blue widget reviews") in the same ad group means the same ad has to satisfy both — neither does, QS drops. If you have multiple intents, launch **multiple ad groups inside one campaign** (one per intent), each with its own RSA tuned to that intent's keywords.

4. **Diversity within each ad — no duplicate / near-duplicate headlines.** Google's Ad Strength score rewards variety. Each headline should approach the value proposition from a different angle: one with the keyword + offer ("Buy Red Widgets — 20% Off"), one with social proof ("Trusted by 10,000+ Teams"), one with urgency ("Sale Ends Friday"), one with a benefit ("Free 2-Day Shipping"), one with a CTA ("Shop the Collection"). The server caps at 15 headlines and 4 descriptions per ad — fill the slots with **distinct** content, not 15 paraphrases of the same line.

5. **Provide 8–15 headlines and 3–4 descriptions per ad.** RSAs give Google the latitude to mix and match — more variety = more combinations Google can A/B test = better Ad Strength = better QS. Three headlines (the minimum) is technically valid but produces "Poor" Ad Strength every time.

6. **Don't over-pin.** Pinning a headline to `HEADLINE_1` forces Google to always show that line in slot 1, which kills the variety Google needs to optimize. Pin only when legally or brand-required (compliance disclaimer in slot 1, brand name in slot 2). Each pin you add reduces Ad Strength one notch.

7. **The landing page should mention the keyword in its `<h1>` and visible body copy.** Google scrapes the landing page on every ad review. A mismatch between ad copy and landing-page copy tanks the "Landing page experience" component of QS — and that's roughly half the QS calculation.

When you're building a launch payload, the mental model is: **"What 5–20 keywords share one intent? Write the ad to that intent. Repeat per intent."** Not "here's a giant keyword list, generate one big ad."

**Reading Quality Score from the API to drive ad rewrites:**

`GET /api/google/keywords?adGroupIds=<ad group>&includeStats=true` returns `ad_group_criterion.quality_info.quality_score` for every keyword — a number from `1` (worst) to `10` (best). Google considers `7+` good, `4–6` mediocre, `1–3` actively bad (you're paying a CPC premium because Google doesn't trust your relevance). Pull this regularly — it's the most actionable creative signal Google gives you.

**The QS-driven ad rewrite loop:**

1. **Pull keyword stats with QS.** `GET /api/google/keywords?adGroupIds=<X>&includeStats=true&datePreset=LAST_30_DAYS`. Sort by `metrics.cost_micros` descending — the keywords spending the most money are the ones whose QS matters most
2. **Identify the high-spend low-QS keywords.** A keyword spending $200 with `quality_score=4` is your single biggest leak. Its ad copy doesn't match the keyword well enough, and Google is making you pay through the nose for every click
3. **Identify the high-spend HIGH-performance keywords.** Same query — sort by `metrics.conversions` descending. The keywords actually driving conversions tell you what intent the ad group is really winning on. Often this is a SUBSET of the keywords you launched with
4. **Rewrite the ad copy to lean into the high-spend high-converting keywords.** Headlines should echo the literal text of the top-converting keyword(s) — Google parses headlines for keyword overlap and bumps QS for matches. Drop or pause keywords with high spend + low QS + low conversions (`PATCH /api/google/keywords/:id/status` with `status: 'PAUSED'`) — they're dragging the whole ad group down
5. **Repeat after 7–14 days** of fresh data. QS recalculates as new impressions accumulate; the same loop catches drift

**Demographics inform the COPY, not just the targeting.**

`GET /api/google/insights?level=age&adGroupIds=<X>` and `level=gender` show which demographics actually convert. Use this when generating new headlines and descriptions:

- If 65% of conversions are female 25–44, your next headline batch should reflect what THAT audience responds to (different value props, different CTAs, different social proof anchors than a male-skewed audience would)
- If conversions are concentrated in one parental-status bucket (parents-with-kids vs without), same idea — write to that life-stage
- This isn't about excluding the other demographics in targeting; it's about what messaging wins. The targeting can stay open while the creative leans into who's actually buying

**The full creative-iteration loop now has three signals feeding it:**
1. **Asset performance** (`/ad-asset-details?adId=…`) — which headlines / descriptions are BEST vs LOW within the current ad
2. **Keyword Quality Score + spend + conversions** (`/keywords?adGroupIds=…&includeStats=true`) — which keywords are winning, which are dragging the ad group down, which language to lean into
3. **Demographic mix** (`/insights?level=age|gender|parental_status&adGroupIds=…`) — who is actually converting, so the next batch of headlines is written for that audience

Every cycle (7–14 days), pull all three. Use them together when writing the next batch of headlines or deciding which keywords to pause.

### Network settings — only target Google Search (best quality)
Google Search campaigns can technically opt into three additional networks beyond Google Search itself: **search partners** (other search engines and properties Google syndicates ads to), the **content network** (Google Display Network — banner ads on websites), and **partner search network** (a separate Google partner pool). All three reduce traffic quality on a Search campaign — partner traffic converts at a fraction of the rate of `google.com` searches, and routing Search ads onto Display is almost never what you want.

**Always launch Search campaigns with Google Search only.** This server's defaults already do this:

```json
"networkSettings": {
  "targetGoogleSearch":         true,    // ✅ keep on — this is the entire point
  "targetSearchNetwork":        false,   // ❌ search partners — OFF for quality
  "targetContentNetwork":       false,   // ❌ Google Display Network — never on for Search
  "targetPartnerSearchNetwork": false    // ❌ partner search — OFF
}
```

If a caller asks you to "include search partners" or "expand reach to Display", push back unless they have a specific, measured reason — for the vast majority of Search use cases turning these on lowers conversion rate and inflates cost per lead. The right way to reach Display traffic is a separate Display or Performance Max campaign with its own budget, creative, and bidding strategy — not a flag on a Search campaign.

If you must override (e.g. running a brand campaign where partner reach genuinely helps), pass `networkSettings: { targetSearchNetwork: true }` (or any combination) explicitly on `POST /api/google/campaigns` or `POST /api/google/campaign/launch`. Omit the field entirely to get the safe Google-Search-only defaults.

### Launch playbook for AI agents
This section captures the entire mental model an agent needs to build a launch payload that's **as good as a human-driven launcher** — not just one that compiles. Every rule here exists for a reason; ignoring any of them produces a campaign that technically goes live but underperforms.

**Pre-launch checklist (do every time, in this order):**

1. `GET /api/google/accounts` — verify the user's Google Ads access; pick the right `customerId` (skip `manager: true` rows; prefer non-`test_account` for production launches)
2. `GET /api/google/conversion-actions?customerId=…` — confirm the customer has at least one ENABLED conversion action with the category you'll bid for (`PURCHASE`, `LEAD`, etc.). If none exist, fall back to `biddingStrategy: 'manual_cpc'` or `'maximize_clicks'` — you can't `maximize_conversions` against nothing
3. `GET /api/google/geo-search?q=<place>` for every targeted location — never invent geo target constant ids
4. `GET /api/google/languages?customerId=…` if not English (id `1000`)
5. Render any `{state}` / `{city}` / `{zip}` tokens in your headlines, descriptions, keywords, and final URLs **client-side** — the server doesn't auto-process them

**Bidding strategy decision tree:**

- Brand-new account, **no conversions tracked yet**: start with `manual_cpc` (low max bid like `$1.50`–`$3.00`) or `maximize_clicks` (with a `maxCpcBidCeiling` so it can't run away). Smart bidding needs ~30 conversions in the last 30 days to optimize; without that data it's worse than manual
- Has conversions but **<50/month**: `maximize_conversions` (no target — let Google find the rate)
- Has conversions, **want a CPA cap**: `target_cpa` with `targetCpa` set to your acceptable cost-per-conversion in dollars
- E-commerce with conversion values: `target_roas` with `targetRoas` as a percent (e.g. `400` = 4× return)
- Brand campaigns where you want top-of-page presence: `manual_cpc` with high bids, `targetSearchPartners: false`

When a bidding-strategy sub-field is required (`targetCpa`, `targetRoas`, `maxCpcBidCeiling`), pass it. The server has fallback defaults ($50, 400%, no ceiling) but those are emergency values, not recommendations.

**The exact keyword cleanup the server applies — anticipate it:**

```
input  → "[buy widgets!] +online"
applied: text.substring(0, 80).trim().replace(/[^\w\s-]/g, '')
output → "buy widgets online"
matchType → still BROAD/PHRASE/EXACT from the field, NOT inferred from brackets
```

Brackets, quotes, plus-signs, ampersands, slashes, accented characters — all stripped. Hyphens and underscores survive. Empty results after cleanup are silently dropped (returned in `skipped[]`). To send `[red widgets]` as exact-match, send `{ text: "red widgets", matchType: "EXACT" }`.

**Final URL construction — the standard pattern:**

```
<base-url>?<your-utm-params>&source=GoogleSearch&cid={campaignid}&gid={adgroupid}&aid={creative}
```

The cleanest implementation is to set `finalUrlSuffix` once on the RSA and let Google cascade it to every linked sitelink/extension automatically:

```json
"finalUrls": ["https://example.com/landing"],
"finalUrlSuffix": "source=GoogleSearch&cid={campaignid}&gid={adgroupid}&aid={creative}"
```

The `{campaignid}`, `{adgroupid}`, `{creative}` placeholders are Google ValueTrack — send them as **literal strings**, Google substitutes the real ids at click time. Other useful ValueTrack params: `{matchtype}` (e=exact, p=phrase, b=broad), `{network}` (g=Google search, s=search partners), `{device}` (m/c/t), `{keyword}` (the matched keyword), `{lpurl}` (landing page URL placeholder for `trackingUrlTemplate`).

If your destination URL is ever empty or non-`http(s)`, the server silently falls back to `https://example.com` — passing a placeholder URL will quietly publish ads that point at example.com. Always pass a real `finalUrls[]`.

**RSA distribution — when you have a large headline/description pool:**

If the agent has, say, 25 headline variations and the user wants 3 ads, the right thing is to **build 3 ads with different headline subsets** rather than stuffing all 25 into one ad. Each ad: pick 8–15 headlines that share a sub-theme. Two ads with overlapping but distinct headline pools = more A/B-able combinations = stronger learning signal for Google. The same applies to descriptions, but with 4 max per ad.

When you only have the bare-minimum pool (3 headlines, 2 descriptions), submit one ad — you can't meaningfully diversify N ads off a 3-headline source.

**Conversion goal selection — default = ad account default, override = pick one or many:**

The default behavior is "use the ad account's default conversion goals." If you do nothing — omit `conversionGoals` from the launch body, or pass `[]` — the new campaign inherits whatever the customer-level `CustomerConversionGoal` settings have biddable, which is the same setting you'd see on a fresh campaign created from the Google Ads UI without touching the goals section.

To override (e.g. optimize for a deeper-funnel action like `QUALIFIED_LEAD` instead of the default `LEAD`), pass the action ids or `(category, origin)` pairs you want biddable. Once you flip any goal to biddable on this campaign, the customer-level fallback is replaced by your explicit selection — so list **every** goal you want biddable, not just the new one.

Both launch endpoints (`POST /api/google/campaigns` and `POST /api/google/campaign/launch`) accept the same `conversionGoals` shape. Pick goals via `GET /api/google/conversion-goals` (launcher-friendly grouped view) or the richer `GET /api/google/conversion-actions`.

```json
"conversionGoals": [
  "12345678",                                              // bare conversion-action id; server resolves category+origin via GAQL
  "87654321",
  { "category": "PURCHASE", "origin": "WEBSITE" },         // explicit pair
  { "category": "LEAD",     "origin": "WEBSITE", "biddable": true }
]
```

**You MUST look up real ids / pairs first via `GET /api/google/conversion-goals?customerId=…` (preferred — launcher-friendly grouped view) or `GET /api/google/conversion-actions?customerId=…` (richer per-action data) before passing anything here.** Never guess, invent, or hardcode ids from another customer — every customer has a different conversion-action set. Pairs that don't exist on this customer are silently dropped (or Google rejects the mutate), and the campaign launches without the optimization you intended.

The bare-id form is easier — just paste the ids from the lookup. The server runs one GAQL query to resolve them to `(category, origin)`.

**Soft-vs-hard failure semantics — what aborts vs what warns:**

- **Hard-fail (whole launch dies, returns 4xx):** budget create, campaign create, ad-group create. The response includes `partial: { budget?, campaign?, adGroup? }` so you can inspect what was created before the failure
- **Soft-fail (warning in `warnings[]`, launch continues):** conversion-goals, locations, languages, audiences, ad schedule, negatives, sitelinks, callouts, structured snippets, call extension, positive keywords, ads
- **Per-row partial failure inside a soft step:** Google returns 200 with `partialFailureError` listing which rows failed by `operationIndex`. The server reports successes in `created[]`; failed rows live in `warnings[].raw.partialFailureError`

A campaign with a failed targeting step still launches but **without geo restrictions** — it'll serve everywhere. Always inspect `warnings[]` before flipping the campaign to `ENABLED`.

**The retry-after-removal pattern (location targeting):**

Google's `mutate` is atomic per call — one bad geo target constant id fails the batch of 1000. The server batches 1000 ops per request with `partialFailure: true`, so per-row failures show up as a `partialFailureError` rather than aborting. If you're constructing the locations list yourself and aren't sure every id resolves, set `partialFailure: true` (the default) — bad ids land in the failure list, the rest still apply. Then you can re-run `/api/google/campaign-criteria/locations` with just the corrected ids.

**Silent defaults you should override consciously:**

| Default | When it kicks in | Override with |
|---|---|---|
| `https://example.com` | empty / non-`http` finalUrls | always pass a real URL |
| `'+1-800-555-0100'` | call ad with no `phoneNumber` | always pass a real number |
| `BROAD` match | string-shorthand keyword entry | pass `{ text, matchType: 'EXACT' }` if you want exact |
| `geoTargetConstants/2840` (USA) | no `locations[]` provided | pass at least one location |
| `PRESENCE_OR_INTEREST` geo type | not set | pass `geoTargetTypeSetting.positiveGeoTargetType: 'PRESENCE'` for strict physical-presence |
| `targetSearchNetwork: false` | not set | leave alone — it's the quality default |
| `target_cpa = $50` | strategy `target_cpa` with no `targetCpa` value | always pass `targetCpa` explicitly |
| `target_roas = 400%` | strategy `target_roas` with no `targetRoas` value | always pass `targetRoas` explicitly |
| Campaign `status = ENABLED` | not set | default — campaign goes live on create. Pass `status: 'PAUSED'` to stage for review, then flip live via `PATCH /api/google/campaigns/:id/status` |

**Efficiency: minimize round-trips.**

- Use `/campaign/launch` (one HTTP request) instead of 14 per-step calls for first launches
- Use `includeStats=true` on `/campaigns` / `/keywords` etc. when you need both the listing and the metrics — joins them into one GAQL query instead of two round-trips
- Pass an array of `keywords[]` to a single `POST /api/google/keywords` call rather than one keyword per call — the server batches up to 1000 ops per mutate
- Same for `locations[]` and `negativeKeywords[]` — always send the full set in one call
- Sitelinks / callouts / structured-snippets are created and linked in a single endpoint call each (`assets:mutate` then `campaignAssets:mutate` — that's two Google round-trips per asset type, but both are kicked off from one HTTP request to this server)

**Post-launch: verify before going live.**

After `/campaign/launch` returns, **before** flipping `status: 'ENABLED'`:

1. Check `warnings[]` is empty (or only contains things you can live with)
2. `GET /api/google/campaign-criteria?customerId=…&campaignIds=<new>&type=LOCATION` — confirm geo targeting actually applied
3. `GET /api/google/keywords?customerId=…&adGroupIds=<new>` — confirm keyword count matches what you sent
4. `GET /api/google/ads?customerId=…&adGroupIds=<new>` — confirm ad approval status (`policy_summary.approval_status`); newly-created ads start as `UNDER_REVIEW`

This 4-step verification is what makes the difference between a campaign that goes live correctly and one that goes live with half its targeting silently missing. If you launched with `status: 'PAUSED'` to stage for review, finish with `PATCH /api/google/campaigns/:id/status { status: 'ENABLED' }` once the checks pass.

### The `/campaign/launch` execution sequence
The first three steps are **fatal** (failure aborts the launch and returns `4xx`/`5xx` with `partial: { budget?, campaign?, adGroup?, warnings }` so you can clean up). Every step after them is **non-fatal** (failures land in `warnings[]`, the rest of the sequence keeps running). The order is intentional: by the time ads exist and could potentially serve, the campaign's targeting, schedule, negatives, and extensions are already in place. Ads come up to a fully-configured campaign — never the other way around.

1. Create campaign budget — fatal
2. Create campaign (with `advertisingChannelType: SEARCH`, bidding strategy, and **best-quality network settings: Google Search only**) — fatal
3. Create ad group (`SEARCH_STANDARD`) — fatal
4. Flip biddable on the requested conversion goals (resolves bare conversion-action ids → `(category, origin)` via GAQL)
5. Apply location targeting (batched 1000)
6. Apply language targeting
7. Apply audience targeting
8. Apply ad-schedule windows (day + hour)
9. Add negative keywords (campaign or ad-group scope)
10. Create + link sitelink assets (max 20 per campaign)
11. Create + link callout assets
12. Create + link structured-snippet assets
13. Create + link CALL extension asset
14. Add positive keywords (cleaned + 80-char capped, batched 1000 per mutate)
15. Create Responsive Search Ads — **last**

If you skip the bundled launcher and build the same campaign with per-step calls, follow the same order. Creating ads before targeting / negatives / sitelinks is a bug — even when staging with `status: 'PAUSED'` you'll waste a round of policy review when you flip the campaign to `ENABLED`.

Multi-row mutates (keywords, locations, RSAs, ad-schedule slots) are sent with `partialFailure: true` by default — Google returns HTTP 200 even when some rows fail, the response includes `results` (succeeded) and a `partialFailureError` (failed). Pass `partialFailure: false` in the body for every-or-nothing semantics.

### Pulling Performance Data
**Pick the right endpoint for what you actually want.**

| You want… | Use this endpoint |
| --- | --- |
| **Basic stats — campaigns / ad-groups / ads / keywords with their cost, clicks, conversions, conversion rate** (this is the common case) | `GET /api/google/campaigns` / `/ad-groups` / `/ads` / `/keywords` with **`includeStats=true`** |
| **Per-asset performance inside one RSA** — headlines / descriptions with BEST / GOOD / LOW labels, plus the parent campaign's sitelinks / callouts and the account's business logo, all in one call | `GET /api/google/ad-asset-details?customerId=…&adId=…` (just adId — server auto-derives parents) |
| **Deeper stats: demographics — age / gender / parental_status performance** | `GET /api/google/insights?level=age&adGroupIds=…` (or `gender` / `parental_status`) — **always scope by `adGroupIds`** |
| **Deeper stats: conversion-action breakdown — which conversion action drove which conversions/value** | `GET /api/google/insights?breakdowns=conversion_action_name&adGroupIds=…` |
| **Deeper stats: time-series — daily / weekly / monthly trend lines** | `GET /api/google/insights?breakdowns=date&adGroupIds=…` (or `week`, `month`) |
| **Deeper stats: device / network / click-type slicing** | `GET /api/google/insights?breakdowns=device&adGroupIds=…` (or `network`, `click_type`) |
| **Details only** — name, status, channel type, bidding strategy, budget, network settings, schedule | `GET /api/google/campaigns` / `/ad-groups` / `/ads` / `/keywords` (no `includeStats`) |
| **Sitelinks / callouts / structured snippets attached to a campaign** | `GET /api/google/campaign-assets?campaignIds=…&fieldType=SITELINK` |
| **Asset library across campaigns** | `GET /api/google/assets?type=SITELINK` |
| **Anything not covered above** | `POST /api/google/gaql` with a raw GAQL query |

**The two-step mental model:**

1. **For *basic* stats — "how are my campaigns / ad-groups / ads / keywords doing?" — use the listing endpoints with `includeStats=true`.** This is the canonical, ergonomic path:
   - `GET /api/google/campaigns?customerId=…&includeStats=true` → campaigns with cost / clicks / conversions
   - `GET /api/google/ad-groups?customerId=…&includeStats=true` → ad-groups with stats
   - `GET /api/google/ads?customerId=…&adGroupIds=…&includeStats=true` → ads with stats (and `quality_info` etc.)
   - `GET /api/google/keywords?adGroupIds=…&includeStats=true` → keywords with stats (text, match_type, **quality_score**, cost, conversions). **`adGroupIds` is REQUIRED** — keyword data is ad-group-scoped only
   - These always include the human-readable text fields, automatically pick the right backing resource (e.g. `keyword_view` for keyword metrics), and are what every dashboard actually wants
2. **For *deeper* stats — demographics, conversion-action breakdown, time-series — use `/insights`, scoped at the AD-GROUP LEVEL via `adGroupIds`.** Always pass `adGroupIds=…` (you almost never want a customer-wide demographic roll-up — it's noisy and ungrouped). The level you request determines the FROM resource (e.g. `level=gender` → `gender_view`); `adGroupIds` is just the scope filter:
   - `GET /api/google/insights?level=keyword&adGroupIds=…` — deep drilldown on keyword stats with breakdowns (use `/keywords?includeStats=true` first; only fall back to insights if you need segments / drilldowns the listing endpoint can't express)
   - `GET /api/google/insights?level=age&adGroupIds=…` — age breakdown
   - `GET /api/google/insights?level=gender&adGroupIds=…` — gender breakdown
   - `GET /api/google/insights?level=parental_status&adGroupIds=…` — parental status
   - `GET /api/google/insights?level=campaign&breakdowns=conversion_action_name&campaignIds=…` — which conversion actions are driving each campaign

`includeStats=true` joins the `metrics.*` fields into the same GAQL query — one round-trip. The server picks the right backing resource automatically (e.g. `/keywords` switches `FROM ad_group_criterion` to `FROM keyword_view` when stats are requested).

`metrics.cost_micros` is in **micros** (1 USD = 1,000,000). The `/insights` endpoint also returns a `summary` object with the converted dollar value as `summary.spend`.

**Debugging 0-row results:** every stats endpoint echoes `_executed_gaql` in the response (the exact GAQL Google ran). On `/insights`, when you get 0 rows the server runs `existence_probes` against any ids you passed — telling you whether each id exists as the resource type the level expects, with cross-checks (e.g. "you passed `X` to `adGroupIds` but it's actually a campaign id"). Read the `interpretation` field per probe.

### Iterating on RSA creative — the test → review → improve loop
**This is the single most important habit when running Search ads.** A Responsive Search Ad isn't a fire-and-forget asset — it's a portfolio of 5–15 headlines and 2–4 descriptions that Google rotates and combines. Every headline gets graded `BEST` / `GOOD` / `LOW` by Google after enough impressions, and **the right move is to systematically swap out the `LOW` ones with new variations and let Google re-grade**. Do this every 7–14 days for any campaign you care about. Compounding effect over a few cycles is enormous — the same campaign with 3 cycles of `LOW`-replacement typically lifts CTR 20–40% and drops CPC accordingly.

**The loop, every cycle:**

1. **Test** — launch the RSA (via `POST /api/google/campaign/launch` or per-step) with a deliberate hypothesis baked in. Each headline should be testing something explicit: a benefit (`"Free 2-Day Shipping"`), an objection-handler (`"Money-Back Guarantee"`), a social-proof claim (`"10,000+ 5-Star Reviews"`), an urgency hook (`"Sale Ends Friday"`), a brand mention, etc. Don't ship 5 paraphrases of the same idea — you'll learn nothing about which angle works. Same for descriptions: each one should pull a distinct lever
2. **Wait** — Google needs roughly 1,000–3,000 impressions per headline before it will assign a performance label. For a campaign at moderate spend (~$25–50/day) that's typically 7–14 days. Below that you'll see `LEARNING` / `PENDING` and grading is unreliable
3. **Review** — call `GET /api/google/ad-asset-details?level=all&adId=<the RSA>&campaignIds=<the campaign>`. Sort the rows by `field_type` then by `performance_label`. The picture you want:
   - `BEST` headlines (`field_type=HEADLINE`) — the angles that resonate. **Keep these and write 2–3 new variations of the SAME angle** (different wording, different number, different CTA verb). The angle is winning; mine it harder
   - `GOOD` headlines — leave alone for now; they're contributing
   - `LOW` headlines — **replace.** Don't iterate on the wording — the angle is losing. Swap in a different angle entirely (a benefit instead of an urgency hook, a price proof instead of a feature claim)
   - `LEARNING` / `PENDING` — leave alone; not enough data yet
   - Same logic for descriptions
   - Cross-reference with the metrics columns: a headline labeled `GOOD` but with very low impressions vs siblings might be pinned, or might be triggering only on rare query shapes. Check `pinned_field` to confirm
4. **Note what didn't work** — keep a simple ledger of (campaign, cycle, headline-text, label, conversion rate). After 3+ cycles you'll start to see patterns: which angles consistently win for THIS audience, which keywords reward urgency vs trust signals, which descriptions actually convert vs just look good. **Feed that pattern into every new campaign / ad group you launch**, and into the next cycle's replacements
5. **Improve** — write the next batch of headlines and descriptions. The replacement set should be: keep `BEST`, keep `GOOD`, swap each `LOW` for something on a NEW angle. Then either (a) PATCH the live RSA's text via the Google Ads UI / a future RSA-edit endpoint, or (b) create a new RSA in the same ad group via `POST /api/google/ads` and pause the old one via `PATCH /api/google/ads/:id/status` once the new one has accumulated enough data
6. **Repeat** — go back to step 2. The loop never stops. Even mature campaigns benefit from a creative refresh every 30–60 days as audience attention drifts and competitor copy shifts

**Don't shortcut this.** Common mistakes:
- Reviewing too soon (before `LEARNING` clears to `BEST` / `GOOD` / `LOW` — you'll act on noise)
- Reviewing too late (the rotation has been stale for weeks, you've left money on the table)
- Replacing `BEST` headlines because a new "great idea" came up — leave winners alone unless you have a hypothesis for why a *replacement* would beat it
- Treating one cycle as conclusive — patterns only emerge over 3+ cycles
- Forgetting to log what was tried — without a ledger you'll re-run failed angles 6 months later

**The agent's mental model**: every campaign launch is hypothesis #1 of a long sequence. The job isn't "ship this campaign" — it's "ship → measure with `/ad-asset-details` → drop the losers → write better losers → repeat." Anyone running RSAs without this loop is leaving 30–50% of their potential CTR on the table.

**Two scopes of optimization — know which lever to pull:**

The 6-step loop above is **headline-level** optimization: keep the RSA, swap individual `LOW` headlines / descriptions for new variants. That's the right move when most assets are `GOOD`/`BEST` and only a few are dragging.

The bigger lever — and a really good move when an entire ad is underperforming — is **ad-level** optimization: **drop the worst ad in the ad group entirely and write a new one from scratch.** Best ad groups have 2–3 RSAs in rotation; Google distributes impressions across them and you can directly compare ad-vs-ad performance.

**When to swap whole ads instead of just headlines:**
- The ad has more `LOW` than `GOOD`/`BEST` assets — the underlying creative concept isn't working, no amount of headline tweaking will fix it
- Ad-level CTR or conversion rate is meaningfully lower than its siblings in the same ad group (compare via `GET /api/google/insights?level=ad&adGroupIds=…&objectId=…`)
- You've already done 2+ headline-swap cycles on this ad and it's still labeled `LOW` overall — the ad has hit a ceiling

**The whole-ad replacement workflow:**

1. **Identify the worst performer.** Call `GET /api/google/insights?level=ad&adGroupIds=<your ad group>&datePreset=LAST_30_DAYS` to compare every RSA in the ad group side-by-side on cost, impressions, clicks, CTR, conversions, conversion rate. The worst is whichever has the lowest `conversions / clicks` (or `clicks / impressions` if conversions are still ramping)
2. **Mine its asset detail before you kill it.** Call `GET /api/google/ad-asset-details?level=all&adId=<the loser>&campaignIds=<the campaign>` and READ THE BEST/GOOD ASSETS. Even a losing ad usually has 1–2 headlines or descriptions that worked. Write those down — they're winning angles you want to carry forward into the new ad. Also note which `LOW` angles to AVOID in the replacement
3. **Look at the WINNER for the same ad group too.** Call `/ad-asset-details` on the best-performing sibling RSA. Its `BEST` assets tell you what THIS audience responds to. The new ad should lean into those proven angles, not invent fresh ones from nothing
4. **Write the new ad.** Combine: (a) the carried-forward `BEST`/`GOOD` headlines from the loser, (b) winning angles from the sibling RSA, (c) 3–5 genuinely new variations on those proven angles, (d) deliberately fresh hypotheses — different value props, different CTAs, different objection-handlers — to keep testing new ground. Aim for 10–15 headlines, 3–4 descriptions
5. **Create the new ad** via `POST /api/google/ads` (pass `adGroupId` + `headlines` + `descriptions` + `finalUrls`). Default `status: 'PAUSED'` so it doesn't start serving until you flip it
6. **Pause the loser.** Once the new ad is created and ready, pause the old one via `PATCH /api/google/ads/:id/status { status: 'PAUSED' }` and enable the new one via the same endpoint with `status: 'ENABLED'`. Don't `REMOVED` the old ad — pausing keeps the historical data queryable for future analysis. Removed ads disappear from `/ad-asset-details` and you lose the ledger
7. **Wait 7–14 days, then run the loop again.** The new ad enters its own headline-level cycle (test → review → improve). Eventually IT will become the loser too — that's the point. The whole ad group keeps getting better

**The compounding picture:**
- Headline-level loop (steps 1–6 above this sub-section) → +20–40% CTR over 3 cycles
- Ad-level replacement layered on top → another +20–40% as you cull whole losing concepts
- Together, over 6 months of disciplined iteration, a single ad group routinely doubles its conversion rate at flat or lower CPC

**Don't replace ads too aggressively.** Each new ad needs its 1–3k impressions per headline to grade reliably (~7–14 days at moderate spend). Replacing an ad before it has labels is acting on noise. Wait for `BEST`/`GOOD`/`LOW` to settle, THEN compare.

### Per-launch token replacement (one campaign per state / city / zip)
The server does NOT auto-process tokens like `{state}` / `{city}` / `{zip}` in your headlines, descriptions, or keywords. Render them client-side before posting:

```js
const states = ['Texas', 'Florida', 'Alaska'];
for (const state of states) {
  await fetch('/api/google/campaign/launch', { method: 'POST', body: JSON.stringify({
    customerId, dailyBudget: 25,
    campaignName: `Spring 2026 — ${state}`,
    keywords: rawKeywords.map(k => k.replace(/\{state\}/gi, state)),
    ads: [{
      headlines: rawHeadlines.map(h => h.replace(/\{state\}/gi, state)),
      descriptions: rawDescriptions.map(d => d.replace(/\{state\}/gi, state)),
      finalUrls: [`https://example.com/${state.toLowerCase()}`]
    }],
    locations: [{ geoTargetConstantId: lookupRegionId(state) }]
  })});
}
```

For a single shared campaign covering many states, omit the loop and pass every `geoTargetConstantId` in `locations[]` — Google serves the same creative to all of them.

### Standard tracking params (always append these on launch)
Every URL launched through this server should carry these four query params so downstream attribution can identify the traffic source and the exact campaign / ad group / ad that drove each click:

```
source=GoogleSearch&cid={campaignid}&gid={adgroupid}&aid={creative}
```

- `source=GoogleSearch` — a fixed string flagging this traffic as coming from Google Search Ads. Use this exact value so your analytics / landing-page logic can switch on it
- `cid={campaignid}` — Google ValueTrack: the campaign id at click time (Google substitutes the value automatically)
- `gid={adgroupid}` — Google ValueTrack: the ad group id at click time
- `aid={creative}` — Google ValueTrack: the ad (creative) id at click time

The `{...}` placeholders are Google's ValueTrack syntax — Google substitutes the real ids at click time, you do NOT pre-fill them. The literal string `source=GoogleSearch&cid={campaignid}&gid={adgroupid}&aid={creative}` is what you send.

**Two ways to apply** — pick one per launch:

**1. Pass `finalUrlSuffix` on the campaign** (recommended). Google appends the suffix to every clicked URL automatically — main RSA, every sitelink, every extension. One source of truth, no per-URL surgery:

```json
"ads": [{
  "headlines": […],
  "descriptions": […],
  "finalUrls": ["https://example.com/landing"],
  "finalUrlSuffix": "source=GoogleSearch&cid={campaignid}&gid={adgroupid}&aid={creative}"
}]
```

`finalUrlSuffix` accepts ValueTrack params, takes precedence over any matching keys already in the destination URL, and is concatenated with `?` or `&` automatically depending on whether the URL already has a query string.

**2. Bake the params into each `finalUrls[]` entry yourself.** Slightly more work but lets you mix per-sitelink params in (e.g. a unique `slid=42` for sitelink-level attribution):

```json
"ads": [{
  "finalUrls": ["https://example.com/landing?source=GoogleSearch&cid={campaignid}&gid={adgroupid}&aid={creative}"]
}],
"sitelinks": [
  { "linkText": "Pricing", "finalUrls": ["https://example.com/pricing?source=GoogleSearch&slid=1&cid={campaignid}&gid={adgroupid}&aid={creative}"] },
  { "linkText": "Reviews", "finalUrls": ["https://example.com/reviews?source=GoogleSearch&slid=2&cid={campaignid}&gid={adgroupid}&aid={creative}"] }
]
```

If the destination URL already has a query string, append with `&`; if not, start with `?`. The launcher does NOT auto-append these — you must include them in the values you submit (or rely on `finalUrlSuffix` per option 1).

For broader campaign-wide UTMs you can layer on top of the four standards above:
```
source=GoogleSearch&medium=cpc&campaign={campaignname}&cid={campaignid}&gid={adgroupid}&aid={creative}&matchtype={matchtype}&network={network}&device={device}&keyword={keyword}
```

### Searching
- Use `keyword=` on `/campaigns`, `/ad-groups` for case-insensitive substring match on `name`
- Use `campaignIds=` / `adGroupIds=` / `adIds=` (comma-separated) to fetch specific known objects directly
- Resolution order across overlapping id lists: `adIds` > `adGroupIds` > `campaignIds` > `customerId`
- Use `negative=true` / `negative=false` on `/keywords` to filter to negative-only or positive-only criteria

## Tool Usage Notes

### GET /api/google/accounts
- Alias of `GET /api/google/customers` — same response. "Account" is the term Meta and most marketers use; "customer" is Google's native term. Either path works
- No required query params. Optional: `cached=true` to read the connect-time snapshot instead of doing a fresh round-trip
- Returns `{ data: [{ id, resource_name, formatted_id, descriptive_name, currency_code, time_zone, manager, test_account, auto_tagging_enabled }], count, cached }`
- `manager: true` rows are MCC manager accounts — they can't be the target of a launch, but can act as the routing account for sub-accounts under them
- `test_account: true` rows only serve test traffic — useful for end-to-end validation without spending real money
- Inaccessible accounts (where Google's permission system blocks the descriptive-name fetch — common for sub-accounts under an MCC the user doesn't directly manage) are silently dropped from the response. You only see accounts you can actually launch into

### GET /api/google/customers
- Same as `/accounts`. Use whichever name fits your mental model

### GET /api/google/customers/refresh
- Forces a fresh `customers:listAccessibleCustomers` round-trip. Same response as `/accounts`. Useful after a new account has been added to your Google login

### GET /api/google/campaigns
- Query: `customerId`, `keyword`, `campaignIds`, `channelType`, `status`, `includeStats`, `datePreset`, `since`, `until`
- `customerId` is required (any 10-digit id from `/accounts`; bare, dashed, or `customers/...` form all accepted)
- `channelType` defaults to `SEARCH`. Pass `ALL` to also see Display, Video, Performance Max
- `keyword` filter is applied server-side after Google returns the list
- When `includeStats=true`, each row's `metrics.*` is filled (cost_micros, clicks, ctr, average_cpc, conversions, search_impression_share, …)
- Per-campaign fields fetched: `campaign.id, campaign.name, campaign.status, campaign.serving_status, campaign.advertising_channel_type, campaign.advertising_channel_sub_type, campaign.bidding_strategy_type, campaign.campaign_budget, campaign.network_settings.*, campaign.optimization_score, campaign_budget.amount_micros, campaign_budget.delivery_method`. Field names follow Google's exact dotted-path schema — never reshape. (Need start/end dates? Use `POST /api/google/gaql` directly — those fields exist in older API versions but are not in the default field set for v24.)

### GET /api/google/ad-groups
- Query: `customerId`, `campaignIds`, `adGroupIds`, `keyword`, `includeStats`, `datePreset`, `since`, `until`
- One of `customerId`, `campaignIds`, or `adGroupIds` is required
- Per-row: `ad_group.{id, name, status, type, cpc_bid_micros, cpm_bid_micros, target_cpa_micros, target_roas, campaign}, campaign.{id, name}`

### GET /api/google/ads
- Query: `customerId`, `campaignIds`, `adGroupIds`, `adIds`, `type`, `includeStats`, `datePreset`, `since`, `until`
- `type` defaults to `RESPONSIVE_SEARCH_AD`. Pass `ALL` to see other ad types
- Per-row: `ad_group_ad.ad.{id, name, type, final_urls, tracking_url_template, final_url_suffix, responsive_search_ad.{headlines, descriptions, path1, path2}}, ad_group_ad.{status, policy_summary.approval_status, policy_summary.review_status}, ad_group.{id, name}, campaign.{id, name}`

### GET /api/google/keywords
- **This is the canonical path for keyword stats.** Pass `includeStats=true` and you get the keyword text, match type, ad-group context, and the full metrics block (cost, impressions, clicks, CTR, conversions, etc.) in one call. Only reach for `/insights?level=keyword` when you need breakdowns (by device, conversion action, time) the listing endpoint can't express
- **`adGroupIds` is REQUIRED** (comma-separated). Keyword data is scoped at the ad-group level only — keywords belong to ad groups, their bids / quality score / performance are all ad-group-scoped, and the same keyword text in two ad groups is two distinct criteria with different stats. Customer-wide and campaign-wide keyword dumps are not supported because they're noisy and unactionable. If you need keywords across a whole campaign, look up the campaign's ad-groups first via `GET /api/google/ad-groups?campaignIds=…` and pass their ids
- Query: `customerId` (required), `adGroupIds` (required), `matchType`, `negative`, `includeStats`, `datePreset`, `since`, `until`
- `matchType` filter: `EXACT`, `PHRASE`, `BROAD`
- `negative=true` returns only negative keywords; `negative=false` returns only positives; omit to return both
- Per-row: `ad_group_criterion.{criterion_id, keyword.{text, match_type}, status, negative, cpc_bid_micros, quality_info.quality_score}, ad_group.{id, name}, campaign.{id, name}`. Plus the metrics block when `includeStats=true`
- **Resource auto-selection**: Google v24 splits keyword data across two views. `ad_group_criterion` is the source of truth for keyword config (text, match type, negative flag, status, bid) but does NOT support metrics — selecting them returns `PROHIBITED_METRIC_IN_SELECT_OR_WHERE_CLAUSE`. `keyword_view` is the metrics-supporting view but only surfaces positive serving keywords. The server picks automatically: `includeStats=true` → `keyword_view`; otherwise → `ad_group_criterion`. Response includes a `from` field telling you which view ran
- **Invalid combo**: `includeStats=true` + `negative=true` returns `400` — negative keywords don't accrue metrics (they don't deliver impressions). Pick one
- Passing `campaignIds` returns `400` — use `adGroupIds` only
- Response includes `_executed_gaql` so you can see exactly what GAQL ran

### GET /api/google/conversion-actions
- Query: `customerId`, `status`, `category`
- Returns every conversion action defined on the customer with `{ id, name, status, type, category, origin, primary_for_goal, click_through_lookback_window_days, view_through_lookback_window_days, value_settings.default_value, value_settings.default_currency_code, counting_type }`
- Use this to discover which `(category, origin)` pairs you'd flip biddable on a campaign via `PATCH /api/google/campaign-conversion-goals`

### GET /api/google/conversion-goals
- Query: `customerId`. Slim, launcher-friendly view of conversion actions, **grouped by `(category, origin)`** — one row per goal group with the constituent action ids and names folded in
- Returned shape: `data: [{ category, origin, actions: [{ id, name, primary_for_goal }] }, ...]`
- This is the shape callers actually pass to the launch endpoints (`POST /api/google/campaigns` and `POST /api/google/campaign/launch`) in the `conversionGoals` array (as bare action ids OR `{ category, origin }` pairs)
- **Omit `conversionGoals` on launch (or pass `[]`) to use the ad account default** — that's the customer-level `CustomerConversionGoal` settings, which is the same setting a fresh campaign in the Google Ads UI inherits when you don't touch the goals section
- Pass goals to override (e.g. optimize for a deeper-funnel `QUALIFIED_LEAD` instead of the account-default `LEAD`). Once any goal is biddable on a campaign, it replaces the customer-level fallback — list every goal you want biddable, not just the addition

### GET /api/google/budgets
- Query: `customerId`. Returns existing campaign budgets so you can reuse instead of recreating
- Per-row: `campaign_budget.{id, name, amount_micros, delivery_method, explicitly_shared, status, type, reference_count}`

### GET /api/google/assets
- Query: `customerId`, `type`. `type` filter: `SITELINK`, `CALLOUT`, `STRUCTURED_SNIPPET`, `CALL`
- Returns the customer-level asset library (assets are per-customer; campaigns link to them via `campaignAssets`)

### GET /api/google/campaign-assets
- Query: `customerId`, `campaignIds`, `fieldType`
- Returns the campaign↔asset link rows — i.e. which sitelinks / callouts / snippets are attached to which campaign
- Per-row: `campaign_asset.{resource_name, campaign, asset, field_type, status}, asset.{id, name, type, final_urls, …per-type-specific fields…}, campaign.{id, name}`

### GET /api/google/geo-search
- Converts a human-readable place name into the Google geo target constant id you need for location targeting. **Always call this first** before adding location targeting — `campaign_criterion.location.geo_target_constant` takes opaque `geoTargetConstants/<id>` resource names, never names
- Query: `q` (required), `countryCode` (default `US`), `locale` (default `en`), `targetType` (e.g. `Country`, `Region`, `City`, `Postal Code`), `limit` (default 20, max 100)
- Examples:
  - `GET /api/google/geo-search?q=Texas&countryCode=US&targetType=Region`
    → `{ data: [{ id: "21176", resource_name: "geoTargetConstants/21176", name: "Texas", canonical_name: "Texas, United States", country_code: "US", target_type: "Region", reach: 25000000 }] }`
  - `GET /api/google/geo-search?q=Miami&countryCode=US&targetType=City`
    → `{ data: [{ id: "1015116", name: "Miami, Florida, United States", target_type: "City" }] }`
  - `GET /api/google/geo-search?q=33139&countryCode=US&targetType=Postal+Code`
    → `{ data: [{ id: "9032069", name: "33139", canonical_name: "33139,Florida,United States", target_type: "Postal Code" }] }`

### Geo targeting cookbook
This is the canonical pattern for state / city / ZIP targeting. Use it whenever the human asks to target specific places.

**1. Look up keys for every place** (one geo-search per place):
```
GET /api/google/geo-search?q=Texas&countryCode=US&targetType=Region     → id 21176
GET /api/google/geo-search?q=Florida&countryCode=US&targetType=Region   → id 21142
GET /api/google/geo-search?q=Alaska&countryCode=US&targetType=Region    → id 21132
```

**2. Apply them via `POST /api/google/campaign-criteria/locations`** (or pass `locations[]` to `/campaign/launch`):

Target three US states in one campaign:
```json
{
  "customerId": "1234567890",
  "campaignId": "9876543210",
  "locations": [
    { "geoTargetConstantId": "21176" },
    { "geoTargetConstantId": "21142" },
    { "geoTargetConstantId": "21132" }
  ]
}
```

Target ZIP codes:
```json
"locations": [
  { "geoTargetConstantId": "9032069" },
  { "geoTargetConstantId": "9031124" }
]
```

Mix targeting + exclusions and per-location bid modifiers:
```json
"locations": [
  { "geoTargetConstantId": "2840" },
  { "geoTargetConstantId": "21176", "bidModifier": 1.2 },
  { "geoTargetConstantId": "21132", "negative": true }
]
```

**Reminders**
- Geo target constant ids are opaque Google IDs — never make them up. Always go through `/api/google/geo-search` first
- One campaign can hold many locations — you do NOT need separate campaigns per state. Only split campaigns per state when you specifically want per-state budgets, creatives, or stats. To launch one campaign per location, call `POST /api/google/campaign/launch` in a loop client-side
- `geoTargetTypeSetting.positiveGeoTargetType` (set on the campaign) controls how Google interprets the location list. `PRESENCE_OR_INTEREST` (default) targets users physically present OR showing interest in the location; `PRESENCE` is physical-only; `SEARCH_INTEREST` is interest-only
- `negative: true` on a location entry excludes that place; `bidModifier: 1.2` multiplies bids by 20% in that place

### GET /api/google/languages
- Query: `customerId`, `keyword`. Returns targetable language constants `{ id, code, name }`. The id is what goes into `languageConstants[]` on `/campaign-criteria/languages`. Common ids: `1000` = English, `1003` = Spanish, `1014` = French, `1001` = German

### GET /api/google/campaign-criteria
- Query: `customerId`, `campaignIds`, `type`. `type` filter: `LOCATION`, `LANGUAGE`, `AD_SCHEDULE`, `AUDIENCE`, `KEYWORD`
- Returns the existing criteria attached to a campaign so you can audit before adding more

### GET /api/google/campaign-conversion-goals
- Query: `customerId`, `campaignIds`. Returns the auto-created campaign↔conversion-goal mapping rows. Each is keyed `{campaignId}~{category}~{origin}` and exposes a `biddable` flag — flip it via `PATCH /api/google/campaign-conversion-goals` to control which goals Google bids for

### GET /api/google/insights
- **Reach for this when you need breakdowns / drilldowns**, not as your default starting point for "how are my X performing". For straight "listing + stats" questions, use the listing endpoints with `includeStats=true` (`/keywords?includeStats=true` for keyword stats, `/ads?includeStats=true` for ad stats, etc.) — they're more ergonomic and always include the human-readable text fields. `/insights` is the right tool for: time-series (`breakdowns=date`), device/network slicing (`breakdowns=device`), demographics (`level=age`), conversion-action breakdown (`breakdowns=conversion_action_name`), or one-object roll-up (`level=ad&objectId=…`)
- Query: `customerId` (required), `objectId`, `campaignIds`, `adGroupIds`, `level`, `datePreset`, `since`, `until`, `breakdowns`
- **Scoping** — `objectId`, `campaignIds`, and `adGroupIds` all stack with AND. The natural pattern is to scope by the level you care about: `level=keyword&adGroupIds=…` for "keyword stats in this ad group", `level=ad&campaignIds=…` for "ad stats in this campaign", etc.
- **`objectId` semantics per level** (this is what the field maps to in the WHERE clause):
  - `level=campaign` → filters by `campaign.id`
  - `level=ad_group` → filters by `ad_group.id`
  - `level=ad` → filters by `ad_group_ad.ad.id` (composite `{adGroupId}~{adId}` form is auto-split — pass either)
  - `level=keyword` → filters by `ad_group_criterion.criterion_id` (the keyword's criterion id, NOT the keyword text)
  - `level=age` / `gender` / `parental_status` → filters by **`ad_group.id`** (not criterion_id — the natural mental model is "show me gender breakdown for THIS ad group". The criterion id at these levels identifies the demographic *bucket* like AGE_18_24, which callers rarely have in hand. Use `adGroupIds=…` for multi-ad-group scoping)
- `level` accepts:
  - `campaign` (default), `ad_group`, `ad`, `keyword` — the standard Search-shaped scopes. `keyword` pulls from `keyword_view` and is the right level for "show me keyword performance"
  - `age`, `gender`, `parental_status` — **demographic breakdowns**. Each row is one `(ad_group, demographic-bucket)` combo (so the same ad group with `AGE_RANGE_18_24` and `AGE_RANGE_25_34` will be two rows). Pulled from `age_range_view` / `gender_view` / `parental_status_view`. Use these when you want to see "who is converting" without writing GAQL
- `breakdowns` is comma-separated, e.g. `device,date`. Allowed:
  - **Time:** `date`, `week`, `month`, `hour`, `day_of_week`
  - **Channel / device:** `device`, `network`, `click_type`
  - **Conversion:** `conversion_action`, `conversion_action_name`, `conversion_action_category` — splits each row by which conversion action the conversions / value were attributed to. **Caveat:** Google rejects mixed queries with these segments — when any conversion-action segment is present, the server auto-trims the metric set to conversion-only (`conversions`, `conversions_value`, `cost_per_conversion`, `value_per_conversion`, `all_conversions`, `all_conversions_value`, `cost_per_all_conversions`, `view_through_conversions`). Don't try to fetch impressions or clicks segmented by conversion action — Google won't return them
- Default metrics (universal — valid at every level): `cost_micros, impressions, clicks, ctr, average_cpc, average_cpm, conversions, conversions_value, cost_per_conversion, value_per_conversion, conversions_from_interactions_rate, all_conversions, all_conversions_value, cost_per_all_conversions, view_through_conversions, interactions, interaction_rate`
- Campaign-level only (added when `level=campaign`): the impression-share family — `search_impression_share, search_top_impression_share, search_absolute_top_impression_share, search_rank_lost_impression_share, search_budget_lost_impression_share, absolute_top_impression_percentage, top_impression_percentage`. These are NOT readable at `ad_group` / `ad` / `keyword` level — Google rejects them as `UNRECOGNIZED_FIELD`. The server gates them automatically based on `level`
- Returns `{ customerId, level, from, count, _executed_gaql, note?, existence_probe?, rows, summary }`. The `summary` rolls up `cost_micros`, `clicks`, `impressions`, `conversions`, `conversions_value`, plus derived `spend`, `ctr`, `average_cpc`, `cost_per_conversion`. `cost_micros` are micros (1 USD = 1,000,000); `summary.spend` is the converted dollar value
- **`objectId` accepts either format**: bare numeric id OR the composite resource-name id `{adGroupId}~{adId}` (the same string `idFromResourceName` returns from a launch response). Server splits on `~` and uses the trailing portion. Without this, a composite passed as `objectId` silently produces 0 rows because Google parses `id = X~Y` as garbage
- **Debugging 0-row responses**: every response includes `_executed_gaql` (the exact GAQL Google ran) and a `note` field when 0 rows came back. **When 0 rows + `objectId` is passed, the server runs an `existence_probe` automatically** — a second query without metrics or date filter to check whether the id you passed even exists as the resource type you asked for. The probe's `interpretation` tells you: `"id EXISTS — 0-row result is a DATA problem, try ALL_TIME"` vs `"id does NOT exist as a {level} on this customer — you probably passed the wrong id type, look the right one up via /api/google/{listing}"`. This is the fastest way to spot wrong-id-type bugs (most commonly: passing an ad-group id when `level=ad` expects the bare ad id, or passing the composite `{adGroupId}~{adId}` form). Paste `_executed_gaql` into `POST /api/google/gaql` for further manual debugging
- For deeper / weirder metrics not exposed here (Quality Score history, top_of_page rates, video metrics, asset breakdown, etc.), drop down to `POST /api/google/gaql` and write the query directly

### GET /api/google/ad-asset-details
- **Required: `customerId` and `adId` only.** That's it — the server auto-derives the parent ad-group and campaign from the ad id. The agent ALWAYS calls this endpoint with an ad id in hand (because that's the entity you're inspecting), so the API is built around exactly that input
- Optional query: `datePreset`, `since`, `until`, `includeDisabled` (default `false`), `fieldType`
- Mirrors the **"View asset details"** panel in the Google Ads UI. That UI shows assets attached at THREE levels — Ad (RSA headlines / descriptions), Campaign (sitelinks / callouts / structured snippets / call extensions), Account (business logo, business name) — all in one panel. This endpoint returns all three concatenated
- Each row carries an `_assetLevel` marker (`"ad"` / `"campaign"` / `"customer"`) so you can tell where each row came from
- The `performance_label` field ("BEST" / "GOOD" / "LOW" / "LEARNING" / "PENDING") that drives the Good / Best / Low badges is only on the ad-level rows (`ad_group_ad_asset_view`) — Google doesn't expose performance labels for campaign- or account-level assets
- **`adId` accepts either format**: the bare numeric ad id OR the composite resource-name id `{adGroupId}~{adId}` (the same string `idFromResourceName` returns from a launch response). The server splits on `~` and uses the trailing portion. If the ad id doesn't exist on the customer, the server returns `404` with a pointer to `GET /api/google/ads`
- **Date default = all-time** to match the Google Ads UI's "View asset details" panel (which defaults to since-launch / all-time, not 30 days). Pass `datePreset` or `since`/`until` only when you want a narrower window (e.g. `LAST_7_DAYS` for a fresh-rotation review)
- `includeDisabled=true` includes assets that have been removed/disabled. Default omits them so you only see what's currently serving
- `fieldType` filter: `HEADLINE`, `DESCRIPTION`, `SITELINK`, `CALLOUT`, `STRUCTURED_SNIPPET`, `CALL`, `MOBILE_APP`, `BUSINESS_LOGO`, `BUSINESS_NAME`, etc. — narrow to one component type
- Ad-level row: `ad_group_ad_asset_view.{ad_group_ad, asset, field_type, enabled, pinned_field, performance_label}`, `asset.{id, source, text_asset.text}`, `ad_group_ad.ad.{id, type}`, `ad_group.{id, name}`, `campaign.{id, name}`
- Campaign-level row: `campaign_asset.{campaign, asset, field_type, status}`, `asset.{id, source, name, text_asset.text, sitelink_asset.{link_text, description1, description2}, callout_asset.callout_text, structured_snippet_asset.{header, values}, call_asset.{country_code, phone_number}}`, `campaign.{id, name}`
- Customer-level row: `customer_asset.{asset, field_type, status}`, `asset.{id, source, name, text_asset.text, image_asset.full_size.{url, width_pixels, height_pixels}, business_name_asset.business_name}`
- All three views include the metrics block (`impressions`, `clicks`, `cost_micros`, `ctr`, `average_cpc`, `conversions`, `conversions_value`, `conversions_from_interactions_rate`, `cost_per_conversion`, `value_per_conversion`, `all_conversions`, `all_conversions_value`, `view_through_conversions`)
- **No approval-status field on the ad-level view**: `ad_group_ad_asset_view.policy_summary.approval_status` was dropped from Google's v24 schema. If you need ad-policy / approval status, query the parent ad via `GET /api/google/ads` — that exposes `ad_group_ad.policy_summary.*`
- Response shape: `{ customerId, adId, derived: { campaignId, adGroupId, adType }, count, data: [...rows], errors?, _executed_gaql, date_filter_applied, performance_label_legend }`. The `derived` block surfaces the parent ids the server auto-resolved so you don't need a follow-up call to learn them
- Sorted by `metrics.impressions DESC` within each level so the top-served assets land first
- **Use this** to answer "which headlines are working?" — sort `_assetLevel=='ad'` rows where `field_type=HEADLINE` by `performance_label` (BEST first), then by conversions. Underperforming headlines (`LOW`) are good candidates to swap out. For "which sitelinks drive the most clicks?" — filter `_assetLevel=='campaign'` rows where `field_type=SITELINK`

### POST /api/google/gaql
- Escape hatch for advanced callers. JSON body: `{ customerId, query, pageSize? }`. `query` is any read-only GAQL string. The server follows `nextPageToken` until exhausted

### Allowed values for time ranges (everywhere insights are fetched)
- `datePreset`: `TODAY`, `YESTERDAY`, `LAST_7_DAYS`, `LAST_14_DAYS`, `LAST_30_DAYS` (default), `THIS_MONTH`, `LAST_MONTH`, `THIS_QUARTER`, `LAST_QUARTER`, `THIS_YEAR`, `LAST_BUSINESS_WEEK`, `LAST_WEEK_MON_SUN`, `LAST_WEEK_SUN_SAT`, `THIS_WEEK_MON_TODAY`, `THIS_WEEK_SUN_TODAY`, `ALL_TIME`
- Or pair `since=YYYY-MM-DD` with `until=YYYY-MM-DD`. Never send both `datePreset` and a since/until pair

### POST /api/google/budgets
- JSON body. Required: `customerId`, `name`, `dailyBudget`
- `dailyBudget` is in **dollars** — server converts to micros
- Returns `{ success: true, budget: { resource_name, id }, raw }`
- **customerId** (string, required)
- **name** (string, required)
- **dailyBudget** (number, USD/day, required)
- **deliveryMethod** (enum): `STANDARD` only. `ACCELERATED` was removed by Google for Search; the server silently coerces it to `STANDARD`
- **explicitlyShared** (boolean) — default `false` (per-campaign budget). Set `true` to share across multiple campaigns

### PATCH /api/google/budgets/:id
- Update an existing campaign budget — most importantly its **daily amount**. Google fully supports changing the amount of a budget that is already attached to a live, serving campaign: the new amount takes effect immediately and you do **not** need to create a new budget or reassign the campaign. (The only limit is Google's account-side throttle on how many times per day a budget can be lowered — exceeding it surfaces as a normal Google error.)
- Path param `:id` is the bare numeric budget id (the `id` returned by `POST /api/google/budgets` or `GET /api/google/budgets`).
- JSON body. Required: `customerId`, plus **at least one** of `dailyBudget`, `name`, `deliveryMethod` (the call 400s if you change nothing).
- Returns `{ success: true, budget: { resource_name, id }, raw }`
- **customerId** (string, required)
- **dailyBudget** (number, USD/day) — new daily amount in **dollars**; server converts to micros
- **name** (string) — rename the budget
- **deliveryMethod** (enum): `STANDARD` only (same `ACCELERATED` → `STANDARD` coercion as create)
- Don't have the budget id? Use **`PATCH /api/google/campaigns/:id/budget`** below — it takes the campaign id and resolves the budget for you.

### PATCH /api/google/campaigns/:id/budget
- **Change a campaign's budget by campaign id — no budget-id lookup needed.** This is the endpoint to reach for when you have a campaign id in hand and want to raise/lower its daily spend. The server resolves the campaign's `campaign_budget` link and updates that budget in place.
- Path param `:id` is the bare numeric campaign id (from `GET /api/google/campaigns`).
- JSON body. Required: `customerId`, plus **at least one** of `dailyBudget`, `name`, `deliveryMethod`.
- Returns `{ success: true, campaign: { id, name }, budget: { resource_name, id, previous_amount_micros, new_amount_micros, explicitly_shared, reference_count }, raw }`
- **customerId** (string, required)
- **dailyBudget** (number, USD/day) — new daily amount in **dollars**; server converts to micros
- **name** (string) — rename the budget
- **deliveryMethod** (enum): `STANDARD` only
- ⚠ **Shared budgets:** if the resolved budget is `explicitly_shared` (attached to more than one campaign, i.e. `reference_count > 1`), changing the amount changes spend for **every** campaign on that budget — not just this one. The response flags `budget.explicitly_shared` and `reference_count` so you can tell. To give a campaign its own independent budget, create a new one (`POST /api/google/budgets`) and re-point the campaign at it.

> **Where do budget ids come from?** Three places: the `budget.id` returned by `POST /api/google/budgets`, the rows from `GET /api/google/budgets?customerId=…`, or the `campaign.campaign_budget` field on `GET /api/google/campaigns`. But for the common "change campaign X's budget" case you don't need any of them — just call `PATCH /api/google/campaigns/:id/budget`.

### POST /api/google/campaigns
- JSON body. Required: `customerId`, `name`, and one of `budgetResourceName` or `budgetId`
- Returns `{ success: true, campaign: { resource_name, id }, requestPayload, raw }`
- **customerId** (string, required)
- **name** (string, required)
- **budgetResourceName** (string) — full resource name returned by `POST /api/google/budgets`
- **budgetId** (string) — bare numeric id, alternative to resource name
- **status** (enum): `ENABLED` (default), `PAUSED`. Default is `ENABLED` — Google launches go live immediately. Pass `PAUSED` to stage for review before serving
- **advertisingChannelType** (enum): `SEARCH` (default), `DISPLAY`, `VIDEO`, `PERFORMANCE_MAX`, `SHOPPING`. Other channel types are accepted but the rest of this server is built around Search shapes
- **biddingStrategy** (enum): `maximize_conversions` (default), `maximize_conversion_value`, `manual_cpc`, `manual_cpm`, `maximize_clicks`, `target_cpa`, `target_roas`
- **targetCpa** (number, USD) — required when `biddingStrategy` is `target_cpa`
- **targetRoas** (number, percent) — required when `biddingStrategy` is `target_roas`. Pass percent (`400` → 4.0); values ≤5 are treated as ratios, >5 as percents
- **maxCpcBidCeiling** (number, USD) — optional ceiling for `maximize_clicks`
- ~~**enhancedCpc**~~ — Enhanced CPC was removed by Google for Search campaigns in v23. The field is silently ignored if passed (kept in the request schema only for backward compatibility)
- **networkSettings** (object): `targetGoogleSearch` (default `true`), `targetSearchNetwork` (**default `false`** — search partners are off for quality; flip to `true` deliberately if you want them), `targetContentNetwork` (default `false` — Display network, never on for Search), `targetPartnerSearchNetwork` (default `false`). See "Network settings — only target Google Search" above for the why
- **geoTargetTypeSetting** (object): `positiveGeoTargetType` (`PRESENCE_OR_INTEREST` default, `PRESENCE`, `SEARCH_INTEREST`), `negativeGeoTargetType` (`PRESENCE` default)
- **containsEuPoliticalAdvertising** (boolean, default `false`) — declares whether the campaign contains EU political advertising. **Not required from caller** — defaults to `false` (sent to Google as `DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING`). The server **always** forwards this on the wire as the explicit enum value, because Google rejects campaign creates that omit it. Only pass `true` when the user has explicitly confirmed the ads contain EU political content
- **conversionGoals** (array, optional) — pick which goals this campaign optimizes for. **Default = ad account default** (omit the field, or pass `[]`, and the new campaign inherits the customer-level `CustomerConversionGoal` settings). To override (e.g. for a deeper-funnel `QUALIFIED_LEAD` instead of the account-default `LEAD`), pass one or more entries — bare conversion-action ids (server resolves `(category, origin)` via GAQL) OR explicit `{ category, origin, biddable? }` pairs. Once any goal is biddable on the campaign, the customer-level fallback is replaced by your list — pass every goal you want biddable, not just the addition. **You MUST look up real ids / pairs first via `GET /api/google/conversion-goals?customerId=…` (or `/conversion-actions`) before passing anything here.** Never guess or hardcode ids — pairs that don't exist on this customer are silently dropped and the campaign launches without the optimization you intended. Returns a `conversionGoals` summary in the response (or a `conversionGoalsWarning` if Google rejected the goal flip — non-fatal, the campaign still gets created)
- **startDate** / **endDate** (string, `YYYYMMDD`)

### POST /api/google/ad-groups
- JSON body. Required: `customerId`, `name`, and one of `campaignResourceName` or `campaignId`
- Returns `{ success: true, adGroup: { resource_name, id }, requestPayload, raw }`
- **customerId** (string, required)
- **name** (string, required)
- **campaignResourceName** (string) — or **campaignId** (string)
- **status** (enum): `PAUSED` (default), `ENABLED`
- **type** (enum): `SEARCH_STANDARD` (default — only type that holds Search keywords + RSAs), `SEARCH_DYNAMIC_ADS`
- **cpcBidMicrosUsd** (number, USD) — manual-CPC bid for this ad group. Only meaningful when the parent campaign uses `biddingStrategy: 'manual_cpc'`

### POST /api/google/keywords
- JSON body. Required: `customerId`, one of `adGroupResourceName` / `adGroupId`, and a non-empty `keywords` array
- Server-side text cleanup: text is truncated to 80 chars, non-word/space/hyphen characters are stripped, empty rows are silently skipped (returned in `skipped[]`). To send an exact-match or phrase-match keyword, use the `matchType` field — Google encodes match type via the enum, **not** via brackets/quotes in the text
- Batched at 1000 ops per mutate request
- Returns `{ success: true, created: [{ resource_name, id }], skipped, counts }`
- **customerId** (string, required)
- **adGroupResourceName** (string) — or **adGroupId** (string)
- **keywords** (array, required) — each entry is a string (broad-match by default) or an object `{ text, matchType?, cpcBidMicrosUsd? }`
- **defaultMatchType** (enum): `BROAD` (default), `PHRASE`, `EXACT` — applied to string-shorthand entries
- **status** (enum): `ENABLED` (default), `PAUSED`
- **cpcBidMicrosUsd** (number, USD) — optional default CPC bid; per-keyword overrides via the entry object
- **partialFailure** (boolean) — default `true`. Bad keywords don't fail the batch

### POST /api/google/negative-keywords
- JSON body. Same shape as `/keywords`. Created as criteria with `negative: true`
- Pass `campaignResourceName` or `campaignId` for **campaign-level** negatives (recommended — broadest exclusion) **OR** `adGroupResourceName` / `adGroupId` for **ad-group-level** negatives. Exactly one — Google scopes negatives at one level
- **customerId** (string, required)
- **campaignResourceName** / **campaignId** (string) — for campaign-scope negatives
- **adGroupResourceName** / **adGroupId** (string) — for ad-group-scope negatives
- **keywords** (array, required) — same shape as `/keywords`
- **defaultMatchType** (enum): `BROAD` (default), `PHRASE`, `EXACT`
- **partialFailure** (boolean) — default `true`

### POST /api/google/ads
Canonical endpoint for creating ads. Same handler is also reachable at `POST /api/google/responsive-search-ads` for callers who want the explicit type name. Search campaigns serve **Responsive Search Ads** (RSAs) — that's the only ad type this endpoint creates.

- JSON body. Required: `customerId`, one of `adGroupResourceName` / `adGroupId`, and a non-empty `ads[]` array
- Each ad **requires at least 3 headlines, at least 2 descriptions, and at least 1 finalUrls entry** — the server pre-validates and returns `400` if you don't meet these
- Headlines truncated to 30 chars; descriptions to 90 chars; `path1` / `path2` to 15 chars
- Up to **15 headlines** and **4 descriptions** per ad. Extras are silently dropped
- Each headline / description can be a string or `{ text, pinnedField }`. `pinnedField` accepts Google's pinning enums (`HEADLINE_1`, `HEADLINE_2`, `HEADLINE_3`, `DESCRIPTION_1`, `DESCRIPTION_2`). Use sparingly — pinning hurts Ad Strength
- **customerId** (string, required)
- **adGroupResourceName** (string) — or **adGroupId** (string)
- **ads** (array of object, required) — each ad: `{ name?, headlines, descriptions, finalUrls, path1?, path2?, trackingUrlTemplate?, finalUrlSuffix?, status? }`
  - **headlines** (array): ≥3 required, ≤15 kept; each entry is a string or `{ text, pinnedField? }`
  - **descriptions** (array): ≥2 required, ≤4 kept; each entry is a string or `{ text, pinnedField? }`
  - **finalUrls** (array): ≥1 required, up to 5 URLs
  - **path1** / **path2** (string, ≤15 chars)
  - **trackingUrlTemplate** / **finalUrlSuffix** (string) — pass-through for ValueTrack params (`{lpurl}`, `{campaignid}`, `{adgroupid}`, `{creative}`, `{matchtype}`, `{network}`, `{device}`). **Recommended: set `finalUrlSuffix` to `"source=GoogleSearch&cid={campaignid}&gid={adgroupid}&aid={creative}"`** — see the "Standard tracking params" section above. Google appends this suffix to every clicked URL on the ad and on every linked sitelink/extension
  - **status** (enum): `PAUSED` (default), `ENABLED`
- **partialFailure** (boolean) — default `true`

### POST /api/google/sitelinks
- JSON body. Required: `customerId`, `sitelinks` array
- Each sitelink requires `linkText` and `finalUrls`; `description1` / `description2` are optional
- Limits enforced: `linkText ≤25` chars, `description1` / `description2` `≤35` chars, `finalUrls` 1..5 entries
- If you pass `campaignResourceName` or `campaignId`, the new sitelinks are also linked to that campaign in a second mutate (`campaignAssets:mutate` with `fieldType: 'SITELINK'`). Pass `linkToCampaign: false` to create the assets without linking
- Per Google: **max 20 sitelinks per campaign**. The `/campaign/launch` helper caps to 20; this endpoint does not, so passing more than 20 may cause some links to fail
- For per-sitelink attribution, append a unique tracking param to each `finalUrls[]` entry (e.g. `https://example.com/pricing?slid=42`) and read it from the click URL on your landing page. Sitelink assets don't carry hidden ids
- **`finalUrlSuffix` / `trackingUrlTemplate` on the sitelink itself.** Both live at the Asset top level (NOT inside `sitelinkAsset`) and the server forwards them when set. Accept on each sitelink as `finalUrlSuffix` / `trackingUrlTemplate` (snake_case aliases also accepted), OR pass at the top level of the body to default every sitelink that doesn't override. The campaign-level `finalUrlSuffix` (set on the RSA) also cascades to sitelinks automatically, so set this here only when you need a sitelink-specific tracking string layered on top
- **customerId** (string, required)
- **sitelinks** (array, required) — each: `{ linkText, description1?, description2?, finalUrls, finalUrlSuffix?, trackingUrlTemplate?, name? }`
- **finalUrlSuffix** (string) — optional top-level default applied to every sitelink that doesn't set its own
- **trackingUrlTemplate** (string) — optional top-level default redirector template
- **campaignResourceName** / **campaignId** (string) — optional; presence implies link
- **linkToCampaign** (boolean) — default `true` if `campaign*` is supplied

### POST /api/google/callouts
- JSON body. Required: `customerId`, `callouts` array (each is a string or `{ text, name? }`)
- Limit: `text ≤25` chars
- Same `campaignResourceName` / `campaignId` / `linkToCampaign` linking pattern as sitelinks. `fieldType` on link is `'CALLOUT'`

### POST /api/google/structured-snippets
- JSON body. Required: `customerId`, `snippets` array — each `{ header, values: [string, …] }`
- `header` must be one of Google's enum values: `Amenities`, `Brands`, `Courses`, `Degree programs`, `Destinations`, `Featured hotels`, `Insurance coverage`, `Models`, `Neighborhoods`, `Service catalog`, `Shows`, `Styles`, `Types`
- `values` requires at least 3 entries; each value is truncated to 25 chars
- Same linking pattern as sitelinks; `fieldType` on link is `'STRUCTURED_SNIPPET'`

### POST /api/google/call-extensions
- JSON body. Required: `customerId`, `countryCode`, `phoneNumber`
- **customerId** (string, required)
- **countryCode** (string, required) — ISO country code, e.g. `"US"`
- **phoneNumber** (string, required) — E.164 (`+15551234567`) or local format
- **conversionReportingState** (enum): `USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION` (default), `DISABLED`, `USE_ACCOUNT_LEVEL_CALL_CONVERSION_ACTION`
- **campaignResourceName** / **campaignId** (string) — optional; presence implies link with `fieldType: 'CALL'`
- **linkToCampaign** (boolean) — default `true` if `campaign*` is supplied

### POST /api/google/campaign-criteria/locations
- JSON body. Required: `customerId`, one of `campaignResourceName` / `campaignId`, `locations[]`
- Each location entry: `{ geoTargetConstant }` (full resource name `geoTargetConstants/2840`) or `{ geoTargetConstantId }` (bare id). Optional per-entry `negative: true` (exclude this location) and `bidModifier: 1.2` (per-location bid multiplier)
- Always look up ids via `GET /api/google/geo-search` first
- Batched at 1000 ops per mutate request with `partialFailure: true`

### POST /api/google/campaign-criteria/languages
- JSON body. Required: `customerId`, one of `campaignResourceName` / `campaignId`, `languageConstants[]` array
- Languages are looked up via `GET /api/google/languages`. Common ids: `1000` = English, `1003` = Spanish, `1014` = French, `1001` = German

### POST /api/google/campaign-criteria/ad-schedule
- JSON body. Required: `customerId`, one of `campaignResourceName` / `campaignId`, AND one of `schedule[]` or `preset`
- Sets the day-of-week and hour-of-day windows the campaign serves. Hours are interpreted in the **campaign's time zone** (set on the customer at account creation; visible via `GET /api/google/accounts`)
- New slots are *added* to whatever Google has — nothing is replaced or deduped. To wipe and re-apply, query existing slots first via `GET /api/google/campaign-criteria?type=AD_SCHEDULE`, then submit removals before the new creates
- **schedule** (array) — each slot: `{ day, startHour, endHour, startMinute?, endMinute? }`
  - `day`: `MONDAY`..`SUNDAY` (casual variants `Mon`, `Tues`, `Wed`, `T`, `W` accepted)
  - `startHour`: 0..23
  - `endHour`: 0..24 (24 means midnight end-of-day)
  - `startMinute` / `endMinute`: `0`/`15`/`30`/`45` or Google's enums `ZERO`/`FIFTEEN`/`THIRTY`/`FORTY_FIVE`. Default `ZERO`
- **preset** (enum, sugar over `schedule[]`): `BUSINESS_HOURS` / `WEEKDAYS_9_5` (Mon–Fri 9–17), `ALL_DAYS` (every day 0–24), `WEEKENDS_ONLY` (Sat+Sun 0–24)
- **partialFailure** (boolean) — default `true`

### POST /api/google/campaign-criteria/audience
- JSON body. Required: `customerId`, one of `campaignResourceName` / `campaignId`, `audiences[]` array
- Each `audiences[]` entry: a string (resource name `customers/{cid}/audiences/{id}` or a bare audience id) or `{ audienceId | resourceName, negative?, bidModifier? }`

### PATCH /api/google/campaign-conversion-goals
- JSON body. Required: `customerId`, `campaignId`, `goals[]` array
- Each goal: `{ category, origin, biddable: true|false }`
- The resource name is auto-built as `customers/{cid}/campaignConversionGoals/{campaignId}~{category}~{origin}` — you do NOT pass it
- This endpoint only writes the `biddable` flag (sends `update_mask: 'biddable'`). It does NOT touch `primary_for_goal` — that field is owned by the customer-level conversion-goal resource
- Use `GET /api/google/conversion-actions?customerId=…` first to learn the available `(category, origin)` pairs

### PATCH /api/google/campaigns/:id/status
- JSON body: `{ customerId, status }` where `status` is `ENABLED`, `PAUSED`, or `REMOVED`
- `REMOVED` is irreversible — Google soft-deletes the campaign

### PATCH /api/google/ad-groups/:id/status
- Pause / enable / remove a single ad group. Mirrors the campaign status endpoint, scoped to the ad-group level
- JSON body: `{ customerId, status }` where `status` is `ENABLED`, `PAUSED`, or `REMOVED`
- Pausing an ad group stops keyword serving for that group only; siblings under the same campaign keep running
- `REMOVED` is irreversible — Google soft-deletes the ad group (and the keywords + ads it owns)

### PATCH /api/google/ads/:id/status
- Pause / enable / remove a single ad inside an ad group. The Google resource is `adGroupAds` and the wire identifier is composite (`{adGroupId}~{adId}`), so you must pass the parent `adGroupId` along with the ad id
- JSON body: `{ customerId, adGroupId, status }` where `status` is `ENABLED`, `PAUSED`, or `REMOVED`
- The `:id` in the path is the ad id (not the composite). Look it up from `GET /api/google/ads`
- Use this to pause underperforming creative without disturbing the rest of the ad group

### PATCH /api/google/keywords/:id/status
- Pause / enable / remove a single keyword (positive OR negative). The Google resource is `adGroupCriteria` and the wire identifier is composite (`{adGroupId}~{criterionId}`), so you must pass the parent `adGroupId` along with the criterion id
- JSON body: `{ customerId, adGroupId, status }` where `status` is `ENABLED`, `PAUSED`, or `REMOVED`
- The `:id` in the path is the bare criterion id (`ad_group_criterion.criterion_id`). Look it up from `GET /api/google/keywords`
- **Use this to pause underperforming keywords without removing them** — paused keywords stop serving but their historical metrics stay queryable in `/api/google/keywords?includeStats=true` and `/insights`. `REMOVED` is irreversible and the keyword disappears from future reports
- Common workflow: pull `/keywords?adGroupIds=X&includeStats=true&datePreset=LAST_30_DAYS`, sort by cost descending, find keywords that are spending without converting, pause them. Cycle every 7–14 days alongside the RSA iteration loop

### POST /api/google/campaign/launch
The bundled launcher. Creates a budget, a Search campaign, an ad group, and (optionally) keywords + negative keywords + Responsive Search Ads + sitelinks + callouts + structured snippets + call extension + locations + languages + audiences + ad schedule, all in one request. Anything you omit is skipped. **Default `status: 'ENABLED'` — campaigns go live on create.** Pass `status: 'PAUSED'` to stage for review. RSAs created inside the launcher also default to `ENABLED` so a live campaign actually serves; pause individual ads after the fact via `PATCH /api/google/ads/:id/status`.

JSON body example:
```json
{
  "customerId": "1234567890",
  "campaignName": "Spring 2026 — Search",
  "dailyBudget": 25.00,
  "status": "ENABLED",
  "biddingStrategy": "maximize_conversions",
  "networkSettings": { "targetGoogleSearch": true, "targetSearchNetwork": true, "targetContentNetwork": false },
  "containsEuPoliticalAdvertising": false,
  "startDate": "20260415",

  "adGroupName": "Ad Group 1",

  "keywords": [
    { "text": "buy widgets", "matchType": "PHRASE" },
    { "text": "red widgets", "matchType": "EXACT" },
    "blue widgets"
  ],
  "negativeKeywords": [
    { "text": "free", "matchType": "BROAD" }
  ],
  "negativeKeywordsScope": "campaign",

  "ads": [
    {
      "headlines": [
        "Buy Widgets Online", "The Best Widgets, Period", "Save 20% Today",
        "Free 2-Day Shipping", "Try Risk-Free for 30 Days"
      ],
      "descriptions": [
        "High-quality widgets shipped fast. Money-back guarantee.",
        "Trusted by 10,000+ teams. Free shipping on every order."
      ],
      "finalUrls": ["https://example.com/widgets"],
      "finalUrlSuffix": "source=GoogleSearch&cid={campaignid}&gid={adgroupid}&aid={creative}",
      "path1": "buy",
      "path2": "now"
    }
  ],

  "sitelinks": [
    { "linkText": "Pricing", "description1": "Plans for every team", "description2": "Try free for 14 days", "finalUrls": ["https://example.com/pricing"] },
    { "linkText": "Reviews", "description1": "10,000+ five-star",     "description2": "From real customers",   "finalUrls": ["https://example.com/reviews"] }
  ],
  "callouts": ["Free shipping", "24/7 support", "Money-back guarantee"],
  "structuredSnippets": [
    { "header": "Service catalog", "values": ["Setup", "Training", "Migration"] }
  ],
  "callExtension": { "countryCode": "US", "phoneNumber": "+15551234567" },

  "locations":         [{ "geoTargetConstantId": "2840" }],
  "languageConstants": ["1000"],
  "adSchedulePreset":  "BUSINESS_HOURS"
}
```

Required: `customerId`, `campaignName`, `dailyBudget`. Every other field is optional. The response includes resource names for everything created plus a `warnings[]` list of non-fatal errors. Failure semantics: budget / campaign / ad-group failures are **fatal** — the response will be a `4xx` / `5xx` and `partial: { budget?, campaign?, adGroup?, warnings }` will tell you how far the request got. Everything else is **non-fatal** — failures show up in `warnings[]` and other steps still run.

`containsEuPoliticalAdvertising` (boolean, default `false`) is optional on this endpoint just like on `POST /api/google/campaigns`. The launcher always forwards it to Google as the explicit enum (`CONTAINS_EU_POLITICAL_ADVERTISING` or `DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING`) — Google requires it on every create. Only pass `true` when the user has explicitly confirmed the ads contain EU political content.

## Common Mistakes to Avoid
- Do NOT pre-multiply budgets / bids / target CPA by 1,000,000 — pass dollars (`25.00`), the server converts to micros
- Do NOT include resource ids in URL paths (`/campaigns/123`). Always pass them in the JSON body or as query params (the one exception is `PATCH /api/google/campaigns/:id/status`, where the id is in the path by Google convention)
- Do NOT pass both `datePreset` and `since`/`until` — pick one
- Do NOT invent geo target constant ids. Always look them up via `/api/google/geo-search`
- Do NOT send brackets / quotes in keyword text to encode match type — use the `matchType` field. The server strips non-word/space/hyphen chars
- Do NOT set `target_cpa` or `target_roas` as the `biddingStrategy` value directly — the strategy key is `target_cpa` (lowercase, with underscore) and the bid value goes in the separate `targetCpa` / `targetRoas` field
- Do NOT rely on the Display / Video / PMax flow — the create endpoints are wired for `advertisingChannelType: 'SEARCH'`. Other channel types may succeed at the campaign level but ad-group / ad creation expects Search shapes
- Do NOT pass `containsEuPoliticalAdvertising: true` unless the user has explicitly confirmed the ads contain EU political content. Omitting the field is fine — the server defaults it to `false` and always forwards the explicit enum to Google (Google rejects creates without it)
- Do NOT expect the server to auto-process `{state}` / `{city}` / `{zip}` tokens in your creative or keywords — render them client-side before posting
- Do NOT call `PATCH /api/google/campaign-conversion-goals` with `(category, origin)` pairs that don't exist on the customer — Google's auto-created campaignConversionGoal rows only cover pairs the customer has at least one conversion action for. Check `GET /api/google/conversion-actions` first
- Do NOT pass `conversionGoals` on launch unless you actually want to override the ad account default. Omitting the field (or passing `[]`) means the new campaign inherits the customer-level `CustomerConversionGoal` settings — which is what almost every launch wants. Once you pass *any* goal, the account-level fallback is replaced by your explicit list, so include every goal you want biddable, not just the addition
- Do NOT invent, guess, or hardcode conversion-action ids or `(category, origin)` pairs when you want non-default goals. **Always look them up first** via `GET /api/google/conversion-goals?customerId=…` (or `/conversion-actions`). The customer can have any subset of categories defined; pairs you fabricate will be silently dropped (Google returns no row for them) and the campaign launches without the optimization you intended
- Do NOT treat an RSA launch as a finished product. Every launch is hypothesis #1 of a sequence. **Every 7–14 days**, call `GET /api/google/ad-asset-details?level=all&adId=…&campaignIds=…` for each live RSA, read the `performance_label` field, and swap out `LOW` headlines / descriptions for new angles. Skipping this loop leaves 30–50% of potential CTR on the table — see "Iterating on RSA creative" above for the full discipline
- Do NOT optimize only at the headline level. When an entire ad is underperforming (more `LOW` than `BEST`/`GOOD` assets, or sibling RSAs in the same ad group have meaningfully better CTR / conv rate), the right move is to **drop the whole ad and create a new one** — but mine its `/ad-asset-details` first to carry forward the angles that DID work and the `BEST` assets from the sibling. Writing a fresh RSA from nothing throws away learning. Pause (don't remove) the old ad so its historical asset data stays queryable. See "Two scopes of optimization" in the iteration section
- Do NOT launch with bare `finalUrls` and forget the standard tracking params — every URL launched through this server should carry `source=GoogleSearch&cid={campaignid}&gid={adgroupid}&aid={creative}`, either via `finalUrlSuffix` on the RSA (recommended — Google cascades it to sitelinks too) or baked into each `finalUrls[]` entry. Without these, downstream attribution can't tell which campaign / ad group / ad drove each click. See the "Standard tracking params" section above
- Do NOT pre-substitute the ValueTrack placeholders. Send `{campaignid}` and `{adgroupid}` and `{creative}` as literal strings — Google substitutes the real ids at click time
- Do NOT submit headlines longer than **30 characters** or descriptions longer than **90 characters**. The server truncates anything longer, but truncation cuts mid-word and produces ugly ads. Pre-trim every entry on your side
- Do NOT submit ads whose copy doesn't echo the ad group's keywords. The keyword `"buy widgets"` deserves headlines containing the word `widgets` (and ideally `buy`) — missing the keyword from the ad copy is the #1 cause of low Quality Score and inflated CPC
- Do NOT submit 15 paraphrases of the same line as your headlines. Diversity wins Ad Strength: lead with the keyword + offer in some, social proof in others, urgency in others, benefits in others, CTA in others. Same logic for descriptions
- Do NOT cram unrelated keywords into one ad group. Tightly themed ad groups (5–20 keywords sharing one intent) outperform loose ad groups every time. Multiple intents = multiple ad groups inside the same campaign, each with its own RSA tuned to that intent
- Do NOT pin headlines to slots unless legally / brand required. Pinning kills the variation Google needs to optimize and lowers Ad Strength
- Do NOT enable search partners (`targetSearchNetwork: true`) or the Display network (`targetContentNetwork: true`) on a Search campaign by default. Both reduce traffic quality. The server defaults to Google Search only — leave them alone unless you have a specific measured reason
- Do NOT flip a freshly-launched campaign to `ENABLED` without first reading the `warnings[]` from the launch response and verifying the criteria/keywords/ads landed correctly via the read endpoints

## Rate Limits
Google enforces per-developer-token QPS limits (basic access: 15 ops/sec; standard access higher). Common errors to back off on: `RATE_EXCEEDED` / `QuotaError`, `RESOURCE_EXHAUSTED` (HTTP 429). Wait at least 60 seconds before retrying. The mutate batch endpoints (`adGroupCriteria:mutate`, `assets:mutate`, `campaignCriteria:mutate`) count one operation per entry inside the `operations[]` array, not one per HTTP request.

## Error Shapes
- **Validation (this server)**: `{ error: "<reason>" }` — HTTP 400
- **Google passthrough**: `{ statusCode, error, code, status, request_id, errors: [{ message, code, trigger, location }], raw }`. The HTTP status mirrors Google's. The `errors[]` array surfaces every individual `GoogleAdsFailure.errors[]` entry; the top-level `error` and `code` are populated from the first one for convenience. Attach `request_id` to any support escalation
- **Partial-failure responses** (when `partialFailure: true`): the HTTP status is `200`, the response includes both `results` (succeeded) and a `partialFailureError` (failed). The bundled `/campaign/launch` flow already passes `partialFailure: true` on multi-row mutates so a single bad row doesn't fail the whole launch
