ads-google
Google Ads conversion tracking for a web app has three layers, and they stack — get the lower ones right or the top one has nothing to count:
- GA4 client tag (
gtag.js) — fires purchase in the browser. Easy to see in a real browser, easy to miss server-side.
- GA4 server-side Measurement Protocol — your backend posts the same
purchase to GA4 after the charge actually succeeds (e.g. a payment webhook), so you don't lose conversions to ad-blockers, closed tabs, or client failures.
- Google Ads conversion import from GA4 — Google Ads doesn't track the purchase itself; it imports the GA4
purchase event as a conversion action. If GA4 never recorded the event, there is nothing to import.
You configure these in order. Skipping layer 2 is the usual reason "it worked in testing but production shows nothing."
GA4 client tag (gtag)
Standard GA4 purchase on the confirmation/thank-you page:
gtag('event', 'purchase', {
transaction_id: '<order-or-session-id>',
value: 25,
currency: 'USD',
items: [{ item_id: 'plan_pro', item_name: 'Pro plan', price: 25, quantity: 1 }]
});
transaction_id is your dedup key — GA4 de-duplicates a purchase by transaction_id, so send the same value from client and server (below).
value + currency are what Smart Bidding optimizes toward; always send both.
- Sequence
gtag('js') → gtag('config') → any manual page_view in one effect. If a page_view is queued from a different effect/hook than the js/config init, it can execute before config lands and gtag silently drops it — GA4 then shows sessions but 0 pageviews. Same-effect ordering fixes it.
GA4 server-side Measurement Protocol
Post the same event from your backend after the charge is confirmed:
POST https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXX&api_secret=<API_SECRET>
Content-Type: application/json
{
"client_id": "<GA4 client_id from the browser _ga cookie>",
"events": [{
"name": "purchase",
"params": {
"transaction_id": "<same id as the client event>",
"value": 25,
"currency": "USD",
"items": [{ "item_id": "plan_pro", "price": 25, "quantity": 1 }]
}
}]
}
api_secret is NOT the measurement id. The measurement id is the public G-XXXXXXX. The api_secret is a separate server credential created per data stream in GA4 Admin → Data Streams → (your stream) → Measurement Protocol API secrets → Create. It is a secret — keep it server-side only.
- If
api_secret is unset, the server purchase silently no-ops. The Measurement Protocol accepts the call shape but sends nothing to the property without a valid secret — you get 0 server conversions and no error. This is the #1 pitfall here. Any if (!apiSecret) return guard in your code makes it worse: the code runs, the guard takes the empty branch, nothing is sent.
- Use the browser's GA4
client_id so the server event joins the same user as the client session. Read it from the first-party _ga cookie (value looks like GA1.1.1234567890.1700000000; the client_id is the last two dotted segments, 1234567890.1700000000) and carry it to the backend. A random/new client_id fragments the user and can drop the join.
Google Ads: import the GA4 purchase as a conversion action
In Google Ads → Goals → Conversions → New conversion action → Import → Google Analytics 4 → Web, pick the GA4 purchase event.
- A conversion action can be HIDDEN — imported but not counted. It must be enabled and set to Primary for Smart Bidding to optimize toward it. A "Secondary" action is observed only, never bid on.
- Attributed conversions require a real ad click. With no ads running (or clicks that don't match), the conversion action correctly reports 0 — that's expected, not a broken tag. The tag can be firing perfectly and Ads still shows 0 because nobody clicked an ad.
Click id
Google's GA4-import path attributes on the GA4 client_id / gclid, so you generally do not need to capture gclid yourself when using conversion import. Capturing gclid (from the landing-page query param, first-touch) is only needed if you later switch to offline/enhanced conversions that key on it. Optional for the import flow.
Conversion tracking & Enhanced Conversions for Leads
Enhanced Conversions for Leads (EC-for-Leads) has two implementation paths:
- Google tag / GTM: captures first-party user data in the browser with the lead conversion.
- Manual/offline upload: sends hashed identifiers with an offline click conversion through the API.
Prefer the offline-upload path when your server already captures gclid and you have an UPLOAD_CLICKS conversion action. It keeps the conversion record and match data on the server.
Enabling EC-for-Leads is UI-only. customer.conversion_tracking_setting.enhanced_conversions_for_leads_enabled is Output-only in the Google Ads API. A CLI can build a mutate request for it, but the API rejects that request. Enable the setting in the Google Ads UI, through Data Manager or Conversions settings. accepted_customer_data_terms is a separate prerequisite and is usually handled in the UI.
For offline click uploads, call customers:uploadClickConversions. Each ClickConversion includes both the click id and user identifiers:
{
"gclid": "<click-id>",
"conversionAction": "customers/<customer-id>/conversionActions/<action-id>",
"conversionDateTime": "2026-01-15 18:30:00+00:00",
"conversionValue": 25,
"currencyCode": "USD",
"userIdentifiers": [{
"userIdentifierSource": "FIRST_PARTY",
"hashedEmail": "<sha256-hash>"
}]
}
- Send
gclid and userIdentifiers together. Use UTC conversionDateTime in yyyy-MM-dd HH:mm:ss+00:00 format. The email value is a SHA-256 hash of the lowercase, trimmed email only. Do not remove Gmail dots or +tags; Google canonicalizes those server-side, and over-normalizing lowers match rate.
- Use
partialFailure: true, but always inspect per-row errors and exit non-zero when any row fails. The endpoint can return HTTP 200 with partial errors. This is a classic silent-failure money-path trap.
- Google deduplicates on
(gclid, conversionAction, conversionDateTime). ONE_PER_CLICK reinforces this rule, so re-running the same upload is safe.
- A HIDDEN conversion action is not a primary goal. Un-hide it before expecting Smart Bidding to optimize toward it.
Customer Match (audience expansion — there is no lookalike object)
Google's classic "similar audiences" was deprecated (Aug 2023) — you can't create a lookalike object. To reach people similar to your users:
- Upload a Customer Match list: a
crm_based_user_list, populated via an OfflineUserDataJob with hashedEmail members (emails normalized trim + lowercase, then SHA-256).
- Let optimized targeting and value-based Smart Bidding expand from that list — the "lookalike" behavior is a bidding/targeting property, not a separate audience.
- A Customer Match list needs ~1,000 members to serve. A tiny seed won't activate — a high-intent signup segment beats a tiny paying-customer-only seed. Seed-sizing and the PII-export authorization boundary are in
ad-experiments.
Campaign setup (search): defaults that quietly burn a small budget
Learned launching a live small-budget ($50-scale) search test. A new Search campaign's defaults are tuned for spend, not for a clean signal — fix these before you turn it on:
- Display Network + Search partners default ON. A new "Search" campaign is created targeting Google Search plus Search partners plus the Display Network by default, so a search-intent budget bleeds onto junk Display placements. Set it to Google Search only (
targetContentNetwork=false, targetSearchNetwork=false on the campaign's NetworkSettings) unless you deliberately want partners/Display.
- Language defaults to "All languages" → you serve users who can't read the ad. Restrict targeting to your target market's language.
- Add negative keywords up front. PHRASE/BROAD match will match junk ("free …", "jobs", "how to …", "reviews", "hire a …", off-topic and competitor terms) and eat the budget before you can read any signal. Put a campaign negative-keyword list in place at launch, not after you've spent.
- Bid strategy on a tiny budget with no conversion history: Manual CPC (full control) or Maximize Clicks (volume, to fill the top of the funnel so you can read clicks → signups). Smart Bidding (Maximize Conversions / tCPA / tROAS) needs a conversion action wired up and roughly 15-30 conversions to exit the learning phase — a small test won't produce that, so it can't optimize. Switch to Smart Bidding only once conversions have accumulated.
- Set the campaign conversion goal explicitly. Don't leave it on "account default: no goals" — point it at the action you care about (e.g. signup) so bidding optimizes toward it once you do move to Smart Bidding.
- Value rules apply only to value-based / tROAS bidding — skip them for a signup- or clicks-based test.
- Head terms are unaffordable on a small budget — broad category terms get bid up by large advertisers (think tens of dollars per click). Ride long-tail, vertical, high-intent phrases instead.
- Check search volume before you spend. Pull exact-match monthly volume (Keyword Planner, or the API's
generateKeywordHistoricalMetrics) — a hyper-long-tail term in a tiny geo can have too little volume to spend even a small budget in your window, which starves the test of a read. Confirm there's enough volume to actually spend before you launch.
Then run tightly-scoped experiments on top of this setup — see the ad-experiments skill for the methodology (hypothesis → narrow audience×geo×creative → cheapest conversion first → server-side verification).
Google Ads API launch nuances (generic)
If you drive campaign creation through the Google Ads API rather than the UI:
containsEuPoliticalAdvertising is now REQUIRED on campaign create — a CampaignOperation create mutate returns a 400 without it. Set the declaration field explicitly (typically "does not contain").
- Search ads are text-only. A responsive search ad takes headlines + descriptions; there is no image asset on a search ad. Don't try to attach an image to one.
- The API must be enabled on the OAuth client's GCP project — the project behind your OAuth credentials needs the Google Ads API enabled and a developer token, or calls fail before they reach your account.
Customer Match audience upload (the trap)
Uploading a first-party customer list for lookalike/retargeting is the single most failure-prone part of Google Ads automation. Expect a multi-step yak-shave:
- The classic Ads API
OfflineUserDataJob path may be blocked — CUSTOMER_NOT_ALLOWLISTED if the dev token / account isn't approved for Customer Match. Teams then pivot to the Data Manager API (datamanager.googleapis.com, audienceMembers:ingest) with SHA-256-hashed CONTACT_INFO.
- Batch the ingest — chunk to roughly ≤10k members per request, sequentially. One oversized POST silently truncates (only the first slice lands) with no error surfaced — an undocumented per-request member ceiling.
- NEVER trust the ingest success response as proof of membership. A
200 + returned request IDs can still leave the list effectively empty. Always follow with a size-verification query against the destination user_list (size_for_display, size_range_for_display) before treating the upload as done. Silent partial/zero success is a real, common failure mode here — not hypothetical.
- The empty-list trap: a Customer Match list can report a non-zero
match_rate_percentage yet size_for_display = 0 / LESS_THAN_FIVE_HUNDRED, and never serve. match_rate alone does not prove a servable, populated list. Things to check when a list stays empty:
- members not landing in the Ads-queryable
user_list at all — the ingest may be populating a Data-Manager audience surface distinct from the targetable Ads user_list (verify which resource actually holds membership before assuming success);
- the account isn't approved/eligible for Customer Match (a policy/spend-history gate);
- missing consent fields on ingest (
ad_user_data / ad_personalization) dropping members from the servable pool;
- size sometimes only computes once a live campaign targets the list — the truest verification is to point a tiny campaign at it and see if it serves, not to stare at the size field.
- A "5,000-email list matched at 82%" that shows size 0 is broken, not warming up. Waiting 24–48h does not fix a structural ingest/eligibility problem — verify size, and if it's still 0 after a full day, it's not timing.
- Bring-your-own analytics ≠ native dashboard. Some site builders only offer a connect-your-own-GA4-tag setup, not native visitor analytics — a fact worth respecting in ad copy (don't promise a dashboard the product doesn't have; see the
ad-experiments truthfulness gate).
Verification (server-side truth, not "the tag is on the page")
- GA4 Data API
runReport — confirm GA4 actually recorded purchases. Requires: (a) a service account added as a Viewer on the GA4 property, (b) the Analytics Data API enabled on its project, and (c) the numeric property id (found in GA4 Admin → Property Settings), not the G-XXXXXXX measurement id. Query the conversions / eventCount metrics for eventName == purchase.
- Google Ads GAQL — list conversion action state and totals:
SELECT conversion_action.name, conversion_action.status, conversion_action.primary_for_goal,
metrics.all_conversions
FROM conversion_action
status shows ENABLED vs HIDDEN/REMOVED; all_conversions counts even without an ad click, so it separates "tag firing" from "no ad clicks yet".
- Validate the MP secret without waiting — post to the debug endpoint
https://www.google-analytics.com/debug/mp/collect?measurement_id=...&api_secret=... with the same body. It returns validationMessages synchronously; an empty array means the event (and the secret) are accepted. This is how you prove the secret is live in seconds instead of waiting for reporting to populate.
Access setup (read + MP secret management)
- Create a service account; add it as a Viewer on the GA4 property (Admin → Property Access Management).
- Enable the Analytics Data API (for
runReport) and the Analytics Admin API (to read/create Measurement Protocol secrets programmatically) on its project.
- JWT scopes:
analytics.readonly for reporting; add analytics.edit if you create/read MP secrets via the Admin API.
- Never print retrieved secrets. When reading an MP secret back through the Admin API, use it, don't log it — treat it like any bearer credential.
Cross-cutting lessons (the ones that actually bite)
- Timing: a conversion count of 0 over a window that predates the tracking deploy is expected, not a bug. Check the deploy date before debugging — you can't count conversions from before the tag existed.
- Build-scoped env: if the
api_secret / measurement id are baked into a generated module at build time (common on serverless/edge), setting them in your dashboard does nothing until the next deploy. Redeploy after adding — match wherever your other working analytics secrets are imported from rather than assuming a runtime process.env read works.
- Ground truth: cross-check Google Ads / GA4
purchase counts against your payment provider's actual succeeded-charge count. Real succeeded charges > 0 with GA4 purchases = 0 means tracking is broken, full stop — the payment provider is the source of truth, the analytics layer is the thing under test.
- Silent failure is the norm: these integrations fail by sending nothing (an early return on a missing
api_secret, a HIDDEN conversion action), not by throwing. Verify with server-side truth — the Data API, GAQL, the payment provider — never "I saw the pixel load in the browser."
Common pitfalls
- Confusing
api_secret with the measurement id — one is a public G-XXXXXXX, the other a per-stream server secret. Server purchase silently no-ops without the secret.
- Using the numeric property id where the code wants
G-XXXXXXX, or vice-versa — Data API wants the numeric id, gtag/MP want G-XXXXXXX.
- Leaving the imported conversion action HIDDEN or Secondary, then wondering why Smart Bidding ignores it — enable it and mark it Primary.
- Debugging "0 conversions" that's actually just "no ad clicks yet" — check
all_conversions and whether any campaign is live before assuming the tag is broken.
- Generating a fresh
client_id server-side instead of reusing the browser's _ga value — fragments the user and can break attribution.
- Campaign-create 400s from the Google Ads API because
containsEuPoliticalAdvertising wasn't declared, or the API isn't enabled on the OAuth project.
Security — secret handling
The GA4 api_secret is a server-side secret. Source it from an environment variable (e.g. $GA4_API_SECRET) — never hardcode, commit, or log it, and never echo its value back to the user. It appears in the Measurement Protocol query string because Google's /mp/collect and /debug/mp/collect endpoints require it there; always send over HTTPS and redact it from any captured request logs. The placeholders in this doc (<API_SECRET>, ...) are not real values.
1---2name: ads-google3description: Wire up Google Ads + GA4 conversion tracking for a web app — the three layers (GA4 client gtag, GA4 server-side Measurement Protocol, Google Ads conversion import from GA4), the Measurement Protocol api_secret that silently no-ops a server purchase when unset, importing/enabling/marking-Primary a GA4 conversion action in Google Ads, Google Ads API launch nuances (containsEuPoliticalAdvertising, text-only search ads, OAuth-project enablement), and server-side verification via the GA4 Data API and GAQL. Also covers audience expansion via Customer Match (crm_based_user_list + OfflineUserDataJob, since classic similar-audiences was deprecated, ~1,000-member serve floor), a gtag page_view ordering bug that silently zeroes GA4 pageviews, and small-budget Search campaign setup — the defaults that burn budget (Display Network/Search partners on by default, All-languages targeting, missing negative keywords), bid-strategy choice when you have no conversion history (Manual CPC / Maximize Clicks vs a Smart Bidding learn4---56# ads-google78Google Ads conversion tracking for a web app has **three layers**, and they stack — get the lower ones right or the top one has nothing to count:9101. **GA4 client tag (`gtag.js`)** — fires `purchase` in the browser. Easy to see in a real browser, easy to miss server-side.112. **GA4 server-side Measurement Protocol** — your backend posts the same `purchase` to GA4 after the charge actually succeeds (e.g. a payment webhook), so you don't lose conversions to ad-blockers, closed tabs, or client failures.123. **Google Ads conversion import from GA4** — Google Ads doesn't track the purchase itself; it *imports* the GA4 `purchase` event as a conversion action. If GA4 never recorded the event, there is nothing to import.1314You configure these in order. Skipping layer 2 is the usual reason "it worked in testing but production shows nothing."1516## GA4 client tag (gtag)1718Standard GA4 `purchase` on the confirmation/thank-you page:1920```js21gtag('event', 'purchase', {22 transaction_id: '<order-or-session-id>',23 value: 25,24 currency: 'USD',25 items: [{ item_id: 'plan_pro', item_name: 'Pro plan', price: 25, quantity: 1 }]26});27```2829- `transaction_id` is your dedup key — GA4 de-duplicates a `purchase` by `transaction_id`, so send the *same* value from client and server (below).30- `value` + `currency` are what Smart Bidding optimizes toward; always send both.31- **Sequence `gtag('js')` → `gtag('config')` → any manual `page_view` in one effect.** If a `page_view` is queued from a *different* effect/hook than the `js`/`config` init, it can execute before `config` lands and gtag **silently drops it** — GA4 then shows sessions but **0 pageviews**. Same-effect ordering fixes it.3233## GA4 server-side Measurement Protocol3435Post the same event from your backend after the charge is confirmed:3637```38POST https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXX&api_secret=<API_SECRET>39Content-Type: application/json40```4142```json43{44 "client_id": "<GA4 client_id from the browser _ga cookie>",45 "events": [{46 "name": "purchase",47 "params": {48 "transaction_id": "<same id as the client event>",49 "value": 25,50 "currency": "USD",51 "items": [{ "item_id": "plan_pro", "price": 25, "quantity": 1 }]52 }53 }]54}55```5657- **`api_secret` is NOT the measurement id.** The measurement id is the public `G-XXXXXXX`. The `api_secret` is a separate server credential created per data stream in **GA4 Admin → Data Streams → (your stream) → Measurement Protocol API secrets → Create**. It is a secret — keep it server-side only.58- **If `api_secret` is unset, the server `purchase` silently no-ops.** The Measurement Protocol accepts the call shape but *sends nothing* to the property without a valid secret — you get 0 server conversions and no error. This is the #1 pitfall here. Any `if (!apiSecret) return` guard in your code makes it worse: the code runs, the guard takes the empty branch, nothing is sent.59- **Use the browser's GA4 `client_id`** so the server event joins the *same user* as the client session. Read it from the first-party `_ga` cookie (value looks like `GA1.1.1234567890.1700000000`; the client_id is the last two dotted segments, `1234567890.1700000000`) and carry it to the backend. A random/new client_id fragments the user and can drop the join.6061## Google Ads: import the GA4 purchase as a conversion action6263In Google Ads → **Goals → Conversions → New conversion action → Import → Google Analytics 4 → Web**, pick the GA4 `purchase` event.6465- A conversion action can be **HIDDEN** — imported but *not counted*. It must be **enabled** and set to **Primary** for Smart Bidding to optimize toward it. A "Secondary" action is observed only, never bid on.66- **Attributed conversions require a real ad click.** With no ads running (or clicks that don't match), the conversion action correctly reports **0** — that's expected, not a broken tag. The tag can be firing perfectly and Ads still shows 0 because nobody clicked an ad.6768## Click id6970Google's GA4-import path attributes on the **GA4 `client_id` / `gclid`**, so you generally do **not** need to capture `gclid` yourself when using conversion import. Capturing `gclid` (from the landing-page query param, first-touch) is only needed if you later switch to offline/enhanced conversions that key on it. Optional for the import flow.7172## Conversion tracking & Enhanced Conversions for Leads7374Enhanced Conversions for Leads (EC-for-Leads) has two implementation paths:7576- **Google tag / GTM:** captures first-party user data in the browser with the lead conversion.77- **Manual/offline upload:** sends hashed identifiers with an offline click conversion through the API.7879Prefer the offline-upload path when your server already captures `gclid` and you have an `UPLOAD_CLICKS` conversion action. It keeps the conversion record and match data on the server.8081**Enabling EC-for-Leads is UI-only.** `customer.conversion_tracking_setting.enhanced_conversions_for_leads_enabled` is Output-only in the Google Ads API. A CLI can build a mutate request for it, but the API rejects that request. Enable the setting in the Google Ads UI, through Data Manager or Conversions settings. `accepted_customer_data_terms` is a separate prerequisite and is usually handled in the UI.8283For offline click uploads, call `customers:uploadClickConversions`. Each `ClickConversion` includes both the click id and user identifiers:8485```json86{87 "gclid": "<click-id>",88 "conversionAction": "customers/<customer-id>/conversionActions/<action-id>",89 "conversionDateTime": "2026-01-15 18:30:00+00:00",90 "conversionValue": 25,91 "currencyCode": "USD",92 "userIdentifiers": [{93 "userIdentifierSource": "FIRST_PARTY",94 "hashedEmail": "<sha256-hash>"95 }]96}97```9899- Send **`gclid` and `userIdentifiers` together**. Use UTC `conversionDateTime` in `yyyy-MM-dd HH:mm:ss+00:00` format. The email value is a SHA-256 hash of the lowercase, trimmed email only. Do **not** remove Gmail dots or `+tags`; Google canonicalizes those server-side, and over-normalizing lowers match rate.100- Use `partialFailure: true`, but **always inspect per-row errors and exit non-zero when any row fails**. The endpoint can return HTTP 200 with partial errors. This is a classic silent-failure money-path trap.101- Google deduplicates on `(gclid, conversionAction, conversionDateTime)`. `ONE_PER_CLICK` reinforces this rule, so re-running the same upload is safe.102- A **HIDDEN** conversion action is not a primary goal. Un-hide it before expecting Smart Bidding to optimize toward it.103104## Customer Match (audience expansion — there is no lookalike object)105106Google's classic **"similar audiences" was deprecated (Aug 2023)** — you can't create a lookalike *object*. To reach people similar to your users:107108- Upload a **Customer Match** list: a `crm_based_user_list`, populated via an `OfflineUserDataJob` with `hashedEmail` members (emails normalized trim + lowercase, then SHA-256).109- Let **optimized targeting** and value-based **Smart Bidding** expand from that list — the "lookalike" behavior is a bidding/targeting property, not a separate audience.110- **A Customer Match list needs ~1,000 members to serve.** A tiny seed won't activate — a high-intent signup segment beats a tiny paying-customer-only seed. Seed-sizing and the PII-export authorization boundary are in `ad-experiments`.111112## Campaign setup (search): defaults that quietly burn a small budget113114Learned launching a live small-budget ($50-scale) search test. A new Search campaign's defaults are tuned for spend, not for a clean signal — fix these *before* you turn it on:115116- **Display Network + Search partners default ON.** A new "Search" campaign is created targeting Google Search **plus Search partners plus the Display Network** by default, so a search-intent budget bleeds onto junk Display placements. Set it to **Google Search only** (`targetContentNetwork=false`, `targetSearchNetwork=false` on the campaign's `NetworkSettings`) unless you deliberately want partners/Display.117- **Language defaults to "All languages"** → you serve users who can't read the ad. Restrict targeting to your target market's language.118- **Add negative keywords up front.** PHRASE/BROAD match will match junk ("free …", "jobs", "how to …", "reviews", "hire a …", off-topic and competitor terms) and eat the budget before you can read any signal. Put a campaign negative-keyword list in place *at launch*, not after you've spent.119- **Bid strategy on a tiny budget with no conversion history: Manual CPC** (full control) **or Maximize Clicks** (volume, to fill the top of the funnel so you can read clicks → signups). **Smart Bidding** (Maximize Conversions / tCPA / tROAS) needs a conversion action wired up **and roughly 15-30 conversions to exit the learning phase** — a small test won't produce that, so it can't optimize. Switch to Smart Bidding only once conversions have accumulated.120- **Set the campaign conversion goal explicitly.** Don't leave it on "account default: no goals" — point it at the action you care about (e.g. signup) so bidding optimizes toward it once you do move to Smart Bidding.121- **Value rules** apply only to value-based / tROAS bidding — skip them for a signup- or clicks-based test.122- **Head terms are unaffordable on a small budget** — broad category terms get bid up by large advertisers (think tens of dollars per click). Ride **long-tail, vertical, high-intent** phrases instead.123- **Check search volume before you spend.** Pull exact-match monthly volume (Keyword Planner, or the API's `generateKeywordHistoricalMetrics`) — a hyper-long-tail term in a tiny geo can have too little volume to spend even a small budget in your window, which starves the test of a read. Confirm there's enough volume to actually spend before you launch.124125Then run tightly-scoped experiments on top of this setup — see the `ad-experiments` skill for the methodology (hypothesis → narrow audience×geo×creative → cheapest conversion first → server-side verification).126127## Google Ads API launch nuances (generic)128129If you drive campaign creation through the Google Ads API rather than the UI:130131- **`containsEuPoliticalAdvertising` is now REQUIRED on campaign create** — a `CampaignOperation` create mutate returns a 400 without it. Set the declaration field explicitly (typically "does not contain").132- **Search ads are text-only.** A responsive search ad takes headlines + descriptions; there is no image asset on a search ad. Don't try to attach an image to one.133- **The API must be enabled on the OAuth client's GCP project** — the project behind your OAuth credentials needs the Google Ads API enabled *and* a developer token, or calls fail before they reach your account.134135## Customer Match audience upload (the trap)136137Uploading a first-party customer list for lookalike/retargeting is the single most failure-prone part of Google Ads automation. Expect a multi-step yak-shave:138139- **The classic Ads API `OfflineUserDataJob` path may be blocked** — `CUSTOMER_NOT_ALLOWLISTED` if the dev token / account isn't approved for Customer Match. Teams then pivot to the **Data Manager API** (`datamanager.googleapis.com`, `audienceMembers:ingest`) with **SHA-256-hashed `CONTACT_INFO`**.140- **Batch the ingest** — chunk to roughly **≤10k members per request**, sequentially. One oversized POST **silently truncates** (only the first slice lands) with no error surfaced — an undocumented per-request member ceiling.141- **NEVER trust the ingest success response as proof of membership.** A `200` + returned request IDs can still leave the list **effectively empty**. Always follow with a **size-verification query** against the destination `user_list` (`size_for_display`, `size_range_for_display`) before treating the upload as done. Silent partial/zero success is a real, common failure mode here — not hypothetical.142- **The empty-list trap:** a Customer Match list can report a **non-zero `match_rate_percentage`** yet `size_for_display = 0` / `LESS_THAN_FIVE_HUNDRED`, and never serve. `match_rate` alone does **not** prove a servable, populated list. Things to check when a list stays empty:143 - members not landing in the **Ads-queryable `user_list` at all** — the ingest may be populating a Data-Manager audience surface distinct from the targetable Ads `user_list` (verify which resource actually holds membership before assuming success);144 - the account isn't **approved/eligible for Customer Match** (a policy/spend-history gate);145 - missing **consent fields** on ingest (`ad_user_data` / `ad_personalization`) dropping members from the servable pool;146 - size sometimes only **computes once a live campaign targets the list** — the truest verification is to **point a tiny campaign at it and see if it serves**, not to stare at the size field.147- **A "5,000-email list matched at 82%" that shows size 0 is broken, not warming up.** Waiting 24–48h does not fix a structural ingest/eligibility problem — verify size, and if it's still 0 after a full day, it's not timing.148- **Bring-your-own analytics ≠ native dashboard.** Some site builders only offer a *connect-your-own-GA4-tag* setup, not native visitor analytics — a fact worth respecting in ad copy (don't promise a dashboard the product doesn't have; see the `ad-experiments` truthfulness gate).149150## Verification (server-side truth, not "the tag is on the page")151152- **GA4 Data API `runReport`** — confirm GA4 actually recorded purchases. Requires: (a) a service account added as a **Viewer on the GA4 property**, (b) the **Analytics Data API enabled** on its project, and (c) the **numeric property id** (found in GA4 Admin → Property Settings), *not* the `G-XXXXXXX` measurement id. Query the `conversions` / `eventCount` metrics for `eventName == purchase`.153- **Google Ads GAQL** — list conversion action state and totals:154 ```sql155 SELECT conversion_action.name, conversion_action.status, conversion_action.primary_for_goal,156 metrics.all_conversions157 FROM conversion_action158 ```159 `status` shows ENABLED vs HIDDEN/REMOVED; `all_conversions` counts even without an ad click, so it separates "tag firing" from "no ad clicks yet".160- **Validate the MP secret without waiting** — post to the debug endpoint `https://www.google-analytics.com/debug/mp/collect?measurement_id=...&api_secret=...` with the same body. It returns `validationMessages` synchronously; an empty array means the event (and the secret) are accepted. This is how you prove the secret is live in seconds instead of waiting for reporting to populate.161162## Access setup (read + MP secret management)163164- Create a **service account**; add it as a **Viewer** on the GA4 property (Admin → Property Access Management).165- Enable the **Analytics Data API** (for `runReport`) and the **Analytics Admin API** (to read/create Measurement Protocol secrets programmatically) on its project.166- JWT scopes: `analytics.readonly` for reporting; add `analytics.edit` if you create/read MP secrets via the Admin API.167- **Never print retrieved secrets.** When reading an MP secret back through the Admin API, use it, don't log it — treat it like any bearer credential.168169## Cross-cutting lessons (the ones that actually bite)170171- **Timing:** a conversion count of **0 over a window that predates the tracking deploy** is expected, not a bug. Check the deploy date before debugging — you can't count conversions from before the tag existed.172- **Build-scoped env:** if the `api_secret` / measurement id are baked into a generated module at **build time** (common on serverless/edge), setting them in your dashboard does *nothing* until the next deploy. **Redeploy after adding** — match wherever your other working analytics secrets are imported from rather than assuming a runtime `process.env` read works.173- **Ground truth:** cross-check Google Ads / GA4 `purchase` counts against your **payment provider's actual succeeded-charge count**. Real succeeded charges > 0 with GA4 purchases = 0 means tracking is broken, full stop — the payment provider is the source of truth, the analytics layer is the thing under test.174- **Silent failure is the norm:** these integrations fail by **sending nothing** (an early return on a missing `api_secret`, a HIDDEN conversion action), not by throwing. Verify with server-side truth — the Data API, GAQL, the payment provider — never "I saw the pixel load in the browser."175176## Common pitfalls177178- Confusing `api_secret` with the measurement id — one is a public `G-XXXXXXX`, the other a per-stream server secret. Server `purchase` silently no-ops without the secret.179- Using the numeric property id where the code wants `G-XXXXXXX`, or vice-versa — Data API wants the *numeric* id, `gtag`/MP want `G-XXXXXXX`.180- Leaving the imported conversion action HIDDEN or Secondary, then wondering why Smart Bidding ignores it — enable it and mark it Primary.181- Debugging "0 conversions" that's actually just "no ad clicks yet" — check `all_conversions` and whether any campaign is live before assuming the tag is broken.182- Generating a fresh `client_id` server-side instead of reusing the browser's `_ga` value — fragments the user and can break attribution.183- Campaign-create 400s from the Google Ads API because `containsEuPoliticalAdvertising` wasn't declared, or the API isn't enabled on the OAuth project.184185---186187## Security — secret handling188189The GA4 `api_secret` is a server-side secret. Source it from an environment variable (e.g. `$GA4_API_SECRET`) — never hardcode, commit, or log it, and never echo its value back to the user. It appears in the Measurement Protocol query string because Google's `/mp/collect` and `/debug/mp/collect` endpoints require it there; always send over HTTPS and redact it from any captured request logs. The placeholders in this doc (`<API_SECRET>`, `...`) are not real values.