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,
transactionIddedups 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 separateUPLOAD_CLICKSaction; 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
consentfield,validateOnlyfor 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 thedatamanagerscope. Env varsGOOGLE_ADS_LOGIN_CUSTOMER_ID,GOOGLE_ADS_SERVICE_ACCOUNT_KEY[_PATH]already documented in.env.example. (GOOGLE_ADS_DEVELOPER_TOKENstays 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,captureErroron failure, 5s timeout, test-event-code support),hash-identity.ts, per-eventtrack-*.tshelpers, all call sites wrapped in Next.jsafter(). The Google lib should mirror this shape file-for-file. - Consent: Consent Mode v2 defaults in
layout.tsx, GPC honored in middleware,ConsentService.isOptedOutOfAdvertisinggates the Meta server events,runAdTrackingAftergates anonymous ones. Same gates apply verbatim; additionally populate Data Manager'sconsentobject.
Corrections to the request
- API: "Offline Conversions API" → Data Manager API (see verdict). ENV section changes:
drop
GOOGLE_ADS_DEVELOPER_TOKEN(for uploads), keep service-account creds, addGOOGLE_ADS_CONVERSION_ACTION_ID_PURCHASE/..._LEAD(numeric conversion-action IDs — note these are a different identifier from the gtagAW-17907719909/<label>send_to strings). - 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. - 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 asmetaEventIdfrom checkout-init) into the gtag conversion'stransaction_id. - "Closes the iOS/blocker gap entirely" is overstated.
- Ad blockers: our capture is first-party bundle JS, so gclid capture survives gtag being blocked — server upload does close that gap.
- 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. - gclid persists only 24h in sessionStorage — no
_gclidcookie, no first-touch column on User (both explicitly deferred indocs/features/fix-missing-attribution-rows/). Lockout/waitlist journeys routinely exceed 24h, so delayed conversions lose their click ID. Recommend pulling the_gclidcookie fix (Fix 5) into this work. - The "~30%" loss figure is unverified for Google; no measurement exists.
- Google Ads account-side setup is real work the request omits:
- Purchase uploads must target the same conversion action the tag fires
(behind label
9timCPn7j_0bEOW9iNtC) for dedup — look up its numeric ID. - 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).
- Verify the existing service account is a permitted
loginAccountfor 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). - 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:
validateOnlyround-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
- Purchase conversion-action granularity: keep single action (parity, simplest dedup) vs per-type?
- Lead action granularity: one "Lead" vs tour/inquiry/capture split?
- Pull the
_gclidpersistent 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)