Skip to content

Google Ads server-side conversions — investigation

Status: investigation complete, no implementation. 2026-07-06.

Verdict

The request is sound in goal and mostly right in shape, but its core API choice is stale: the Google Ads API's offline conversion upload (UploadClickConversions) was blocked for new integrations on June 15, 2026. Developer tokens that did not send conversion-upload requests between January and June 2026 are not allowlisted; ours only ever did read-only GAQL reporting, so we cannot use it. Google's replacement is the Data Manager API (datamanager.googleapis.com/v1, ingestEvents), and it is a better fit anyway:

  • No developer token required. Plain OAuth with scope https://www.googleapis.com/auth/datamanager; service accounts supported without OAuth app verification.
  • Dedup against the browser tag actually works. Within the same conversion action, transactionId dedups events across sources (website gtag vs API upload) — a matched upload just overrides the conversion value. Under the legacy API this was impossible (uploads required a separate UPLOAD_CLICKS action; order_id never dedups across actions), so the request's Meta-style event_id assumption only holds on the new API.
  • Accepts gclid/gbraid/wbraid or hashed user data (email/phone/address) as identifiers, has a consent field, validateOnly for testing, 2,000 events/request.

What already exists (head starts)

  • Google Ads auth infra: apps/api/src/lib/google-ads/client.ts — service-account JWT (google-auth-library, already a dependency), used for read-only GAQL reporting (insights.ts, ad-spend-sync cron). Reusable: add the datamanager scope. Env vars GOOGLE_ADS_LOGIN_CUSTOMER_ID, GOOGLE_ADS_SERVICE_ACCOUNT_KEY[_PATH] already documented in .env.example. (GOOGLE_ADS_DEVELOPER_TOKEN stays for GAQL reads but is NOT needed for uploads.)
  • gclid is captured and threaded end-to-end today: URL param → sessionStorage (first-touch, 24h TTL, apps/web/src/lib/utm-storage.ts) → attributionSchema (packages/shared-schemas/src/api/common.ts) → Stripe metadata (AttributionService.buildStripeAttributionMetadata) → read back in webhook fulfillment → conversion_attributions.gclid. The fulfillment-time server code can already read gclid; this was the hard prerequisite and it is mostly met.
  • Meta CAPI blueprint (PR #652, commit 9e58d8cce): apps/api/src/lib/meta/capi-client.ts (env-gated, never-throws, captureError on failure, 5s timeout, test-event-code support), hash-identity.ts, per-event track-*.ts helpers, all call sites wrapped in Next.js after(). The Google lib should mirror this shape file-for-file.
  • Consent: Consent Mode v2 defaults in layout.tsx, GPC honored in middleware, ConsentService.isOptedOutOfAdvertising gates the Meta server events, runAdTrackingAfter gates anonymous ones. Same gates apply verbatim; additionally populate Data Manager's consent object.

Corrections to the request

  1. API: "Offline Conversions API" → Data Manager API (see verdict). ENV section changes: drop GOOGLE_ADS_DEVELOPER_TOKEN (for uploads), keep service-account creds, add GOOGLE_ADS_CONVERSION_ACTION_ID_PURCHASE / ..._LEAD (numeric conversion-action IDs — note these are a different identifier from the gtag AW-17907719909/<label> send_to strings).
  2. gclid is not an "optional dedup nicety" like Meta's event_id — for purchase uploads it is the primary attribution key. Hashed email/phone alone only attributes via the enhanced-conversions- for-leads flavor (fine for our Lead events, not a substitute for gclid on purchases). Dedup is transactionId's job, not gclid's.
  3. Browser tag sends transaction_id: '' (apps/web/src/lib/tracking.ts:45, hardcoded). Dedup requires the browser and server to send the same ID. Must fix: thread the same Stripe object ID used as Meta's event_id (already returned to the client as metaEventId from checkout-init) into the gtag conversion's transaction_id.
  4. "Closes the iOS/blocker gap entirely" is overstated.
  5. Ad blockers: our capture is first-party bundle JS, so gclid capture survives gtag being blocked — server upload does close that gap.
  6. iOS: clicks from iOS apps / to iOS apps carry gbraid/wbraid, which we do not capture at all (grep is empty). Cheap to add to UTM_PARAMS + attributionSchema.
  7. gclid persists only 24h in sessionStorage — no _gclid cookie, no first-touch column on User (both explicitly deferred in docs/features/fix-missing-attribution-rows/). Lockout/waitlist journeys routinely exceed 24h, so delayed conversions lose their click ID. Recommend pulling the _gclid cookie fix (Fix 5) into this work.
  8. The "~30%" loss figure is unverified for Google; no measurement exists.
  9. Google Ads account-side setup is real work the request omits:
  10. Purchase uploads must target the same conversion action the tag fires (behind label 9timCPn7j_0bEOW9iNtC) for dedup — look up its numeric ID.
  11. No Lead conversion action exists at all (browser or server). Create one (or several) in Google Ads, decide primary vs secondary for bidding, enable enhanced conversions for leads (requires accepting Google's customer-data terms in the account).
  12. Verify the existing service account is a permitted loginAccount for Data Manager against our Ads account (it works for GAQL via developer token today; the DM permission model needs a one-time verification during setup).
  13. Single purchase label for everything: all purchase types (lockout monthly, hourly, credits, org-dedicated, waitlist deposit) fire one conversion label. Whether to split per-type server-side is a marketing/bidding decision to make before creating conversion actions — splitting later resets conversion history.

Proposed shape (mirror of lib/meta/)

apps/api/src/lib/google-ads/
  data-manager-client.ts   # ingestEvents wrapper: env-gated skip, never-throw, captureError,
                           # validateOnly support (test-mode analog of META_CAPI_TEST_EVENT_CODE)
  hash-identity.ts         # Google normalization ≠ Meta's: lowercase/trim, but ALSO strip
                           # periods + plus-suffix for @gmail/@googlemail; E.164 phone; SHA-256 hex
  track-purchase.ts        # gclid/gbraid/wbraid from attribution + hashed user data + transactionId
  track-lead.ts            # enhanced-conversions-for-leads style: hashed email/phone, gclid if present

Wire sites = exactly the Meta CAPI table (7 purchase sites: PaymentIntentHandler.ts:248,348,437, FulfillmentService.ts:144,266,572,729; 6 lead sites: tours, contact ×2, email-capture, green-room, comp). transactionId = the same Stripe object ID already used as Meta event_id. Browser change: trackGoogleConversion gains transactionId param; callers already hold it.

Acceptance criteria adjustments

  • "Conversion data appears in Google Ads UI within 24–48h" — keep, but note gbraid/wbraid-keyed conversions can take up to 72h.
  • Add: validateOnly round-trip green in CI/dev before any live upload.
  • Add: browser gtag conversion carries real transaction_id (prerequisite for the dedup criterion).

Quantified gap (2026-07-06)

Measured via apps/api/scripts/marketing/cross-check-google-attribution.ts (committed to main). Comparison basis: Google "Purchase" action, all_conversions by conversion date, vs prod conversion_attributions with gclid, vs actual purchases (reservations with PAID payment + credit/waitlist payments). Attribution infra timeline: pipeline live 2026-04-12 (#588), account final_url_suffix 2026-05-21, carry-forward fix 2026-05-26 (#727) — June is the only clean month.

June 2026 count
Actual purchases, all channels (17 hourly + 19 monthly + 6 credit + 32 waitlist) 74
Google Ads-reported "Purchase" conversions 27
Server-verified Google-attributed purchases (gclid in DB) 5

May shows the same shape (109 actual / 37 Google-reported / 4 verified). April: Google reported 43 purchases ($10,967) while DB capture was ~zero — April was also legacy-migration month (237 migrated monthlies), unverified hypothesis that migration checkouts fired the purchase tag.

The request's premise is inverted. The browser tag is not under-counting ~30%; Google claims ~36% of ALL purchases while only ~7% can be server-verified as Google-attributed. Truth lies between (DB side undercounts: no gbraid/wbraid → PMax clicks invisible, 24h sessionStorage TTL → waitlist/lockout journeys lose the click ID, staff-invited checkouts have no browser attribution). But a large share of the Google number is modeling/engaged-view inflation on an un-deduplicated tag (transaction_id: '').

Implication: server-side upload's value here is replacing modeled inflation with ground truth (bidding on real signal, likely REDUCING reported conversions), not recovering missed ones. At 4–5 verified Google purchases/month, the big build is not urgent; the cheap fidelity fixes are: 1. Send real transaction_id in the browser gtag conversion. 2. Capture gbraid/wbraid + persist _gclid as a cookie (fix-missing-attribution-rows Fix 5). 3. Check which conversion actions are primary-for-goal — "Local actions" (8,668) and "Store visits" (398) are GBP noise that must not be in the biddable Conversions column.

Open questions for Aaron

  1. Purchase conversion-action granularity: keep single action (parity, simplest dedup) vs per-type?
  2. Lead action granularity: one "Lead" vs tour/inquiry/capture split?
  3. Pull the _gclid persistent cookie + gbraid/wbraid capture into scope? (Recommended — cheap, and without it delayed conversions upload with user-data-only matching.)

Sources

  • https://developers.google.com/google-ads/api/docs/conversions/upload-offline (June 15, 2026 block)
  • https://developers.google.com/data-manager/api/devguides/events/google-ads/offline/upgrade
  • https://developers.google.com/data-manager/api/devguides/events/send-events
  • https://developers.google.com/data-manager/api/devguides/quickstart/set-up-access
  • https://support.google.com/google-ads/answer/6386790 (transaction_id dedup)