Honey-Doers
← Links

Server-Side Conversion Tracking — I13

Rendered at build time from SERVER_SIDE_CONVERSIONS.md in the repo — edit the source file and rebuild to update this page.

Server-Side Conversion Tracking — I13

Version 1.7 · 2026-09-05 · Status: Phases 0–2 shipped 2026-09-03; Phase 2 superseded 2026-09-05 (Google closed its endpoint to new integrations). Phase 2b is fully unblocked: Data Manager API enabled on the GCP project, a scoped DATA_MANAGER_REFRESH_TOKEN minted and stored as a Cloudflare Pages secret, and contact.ts already rewritten against the new endpoint. Next: re-verify with a real gclid.

Objective Replace client-side Google Ads conversion tracking (which sets third-party cookies flagged by PageSpeed) with server-side conversion upload, using the same pattern already proven for OpenAI Ads in functions/api/contact.ts.
Nature of work One-time infra build (Google Ads API credentials + a new server-side call), not a recurring lever or an experiment arm.
Trigger PageSpeed audit flagged 16 third-party cookies from www.google.com/ccm/collect (Google Ads conversion/consent-mode pings) and bzrcdn.openai.com (OpenAI Ads pixel). The OpenAI half is fixed — see below.
Progress OpenAI Ads client-side pixel removed 2026-08-23 (c4d077b) — its cookies are gone, and server-side lead conversion tracking to bzr.openai.com in functions/api/contact.ts (lines ~138–160) was already implemented and is untouched, so OpenAI Ads attribution still works. This file scopes the equivalent build-out for Google Ads.
Next action Phases 0–2 shipped 2026-09-03; Phase 2 hit a real-gclid dead end (lead 1AEA4D, CUSTOMER_NOT_ALLOWLISTED_FOR_THIS_FEATURE — Google closed uploadClickConversions to new integrations, everyone migrates to the Data Manager API). Phase 2b resolved the auth question same day: the existing OAuth2 refresh-token flow works for the Data Manager API too (no service-account rebuild needed — see Phase 2b below), functions/api/contact.ts was rewritten against the new endpoint/field schema, the Data Manager API was enabled on honey-doers-ads-api, and a DATA_MANAGER_REFRESH_TOKEN (scoped datamanager, minted under itsbusinesstimellc@gmail.com via a second client secret on the existing "Google Ads API refresh token generator" OAuth client) is now live as a Cloudflare Pages secret — all done 2026-09-05. Next: re-verify with a real gclid (submit a real ad-click-driven lead, check the diagnostic email / LEAD_LOG KV record), then do Phase 3 cutover (remove the client-side Ads tag from GTM) and re-run PageSpeed.
Second consumer, added 2026-09-02 The same credential also unblocks Google Keyword Planner as an optional third input in the new-blog-post skill's primary-query volume gate (currently Ahrefs + live SERP/PAA only). No extra scoping needed here — the gate picks it up automatically once Phase 0 lands.

This file is self-contained. Ledger entry: I13 in Search_Strategy.md.


Problem

The ccm/collect cookies (__Secure-3PSID, __Secure-3PAPISID, __Secure-OSID, NID, COMPASS, etc.) come from Google's ads conversion/consent-mode measurement layer riding along with the GTM/gtag tag already on the site (PUBLIC_GTM_ID, src/components/SEOHead.astro). These are third-party cookies set on google.com, not honey-doers.com — the exact category Safari ITP and Firefox ETP already block by default and Chrome is deprecating. Two consequences:

  1. The PageSpeed/Lighthouse "uses third-party cookies" audit fails, same finding this file's companion fix (OpenAI pixel removal) already addressed for the smaller of the two sources.
  2. Real data loss, independent of the audit — any visitor on Safari or Firefox (a large minority of traffic) already isn't contributing to Google Ads' client-side conversion tracking today. This isn't hypothetical or future-dated; it's happening on every page load right now.

Why this isn't a simple removal (unlike the OpenAI pixel)

The OpenAI fix was a straight deletion: the client pixel handled page-view measurement only, and lead conversion tracking already ran entirely server-side, unrelated to the client script. Google Ads conversion tracking has no server-side equivalent yet — dropping the client-side tag without replacing it would kill Ads conversion measurement outright (Ads would optimize bidding blind), not just remove a page-view ping. This has to be a build-then-cutover, not a delete.


Strategy

Mirror the OpenAI Ads pattern in functions/api/contact.ts almost exactly — same file, same "POST a hashed-PII conversion event from the Cloudflare Function on lead submit" shape, using Google's equivalent API instead of OpenAI's. The Function already:

All of that carries over unchanged. The two real deltas are:

  1. Auth is heavier. OpenAI's setup is a static bearer key (OPENAI_ADS_CONVERSION_KEY) — copy-paste-simple as a Cloudflare Pages secret. Google Ads API requires a developer token + OAuth2 client ID/secret + a long-lived refresh token, and the Function needs to exchange the refresh token for a short-lived access token before each call (or cache one for its ~1hr lifetime). This is real code, not just a new env var.
  2. Click-to-lead attribution needs a gclid. OpenAI's matching relies on hashed email + referrer URL alone. Google Ads' conversion upload wants a gclid (Google Click ID) tied to the ad click that drove the visit, or it falls back to a weaker cross-domain/enhanced-conversions-only match. That means a small piece of client-side plumbing is still required — capturing gclid from the landing URL into a short-lived first-party cookie (not Google's third-party one) so the contact form submission can read it and forward it server-side. This first-party cookie is not what the audit flagged and doesn't reintroduce the problem.

Phased plan

Phase 0 — credentials (business-owner step, blocks everything else)

Phase 1 — first-party gclid capture (small, client-side, no new cookie audit risk)

Done, 2026-09-03 (cf0bdaf). SEOHead.astro sets a first-party gclid cookie (90-day expiry) when ?gclid= is present in the URL. Both ContactForm.astro and the standalone /contact-us/ page's inline form read the cookie and append gclid to the FormData POST to functions/api/contact.ts. Verified in dev server: cookie sets and persists across navigation, both forms' FormData includes it before the fetch call. contact.ts itself doesn't read gclid yet — that's Phase 2 — but ignores unrecognized fields today, so this is forward-compatible.

Phase 2 — server-side upload (mirrors contact.ts's existing OpenAI block)

Done, 2026-09-03 (e38ad85). functions/api/contact.ts now: exchanges the stored refresh token for a short-lived access token per request (plain re-exchange, not KV-cached — Open Decision #2 below, fine at current lead volume); POSTs to https://googleads.googleapis.com/v25/customers/{customerId}:uploadClickConversions with developer-token and login-customer-id headers, gclid (from Phase 1's cookie), the new conversion action's resource name (customers/{id}/conversionActions/7746080556), conversionDateTime in UTC (Google's required yyyy-MM-dd HH:mm:ss+00:00 format), a flat $1/USD conversionValue (no real per-lead value data yet — see B1/B2 in the Business Questions register), the lead ID as orderId for dedup, and hashedEmail (reusing the existing sha256Hex helper) as a user identifier. Only attempts an upload when gclid is present — no gclid means no ad click to attribute, and the API requires one of gclid/gbraid/wbraid. Fires via waitUntil(), same as the OpenAI call; errors are logged, never surfaced to the user or allowed to fail the form submission.

Live verification attempted 2026-09-05 — surfaced a real logging gap, now fixed (76bfd50). Two test leads were submitted with synthetic gclid values (not real click IDs) while running wrangler pages deployment tail in JSON format. Both returned outcome: "ok", HTTP 200, with empty logs/exceptions — yet the "Server-Side Lead Conversion (API)" action showed 0 conversions in the Ads UI. Root cause: uploadClickConversions is called with partialFailure: true, so a rejected individual conversion (bad gclid, etc.) still returns HTTP 200 — the failure lives in the response body's partialFailureError field, which the code never checked. A silently-dropped conversion was indistinguishable from a real success. Fixed same day: the code now parses the body on a 200 response and logs partialFailureError if present. Still not verified end-to-end with a real gclid — a synthetic one is expected to fail Google's validation regardless of the fix, so this only confirms failures are now visible, not that a real conversion succeeds. Deliberately did not test with a real ad click (self-clicking a live ad spends real budget and risks tripping Google's invalid-click detection) — next real organic lead (ad click → contact form submit) will be the actual test, watched via the same live tail.

Real-gclid test (lead E5331A, 2026-09-05) exposed a deeper problem than a missed capture — the live-tail approach itself is fragile. The test's traffic landed on a deployment that finished building mid-session (an unrelated commit triggered a rebuild) after the tail had attached to the prior deployment, so its logs were never seen. The underlying issue: wrangler pages deployment tail only shows events to whoever has it running at the exact moment they happen — it's a live debugging stream, not a log store, so a lead's outcome is unrecoverable after the fact regardless of how carefully the tail is set up. Fixed properly, not just re-attempted (de627e6). Added logGoogleAdsResult() and sendGoogleAdsDiagnosticEmail(): every upload attempt (token failure, network failure, HTTP error, partial failure, or success) now writes a record to a new LEAD_LOG KV namespace (gads:{leadId}, 90-day TTL) and sends a short internal email ([I13] Google Ads conversion OK/FAILED — lead {leadId}) — so any lead's outcome is checkable after the fact without a tail session. Needs one manual step before it's live: the LEAD_LOG namespace (id 5931d834d2a041f4887c2857a6722b6c, created via wrangler kv namespace create) has to be bound to the hd-com-astro Pages project in the Cloudflare dashboard (Settings → Functions → KV namespace bindings) — Git-connected Pages builds don't pick up bindings from a repo wrangler.toml. Until bound, env.LEAD_LOG is undefined and logGoogleAdsResult no-ops safely; the diagnostic email still fires regardless, so it doesn't block verification, just narrows it to one recorded source instead of two.

The KV binding worked, the diagnostic email worked, and the real answer it surfaced is a hard blocker: uploadClickConversions is closed to new integrations. Real-gclid test, lead 1AEA4D, 2026-09-05, on the freshly-bound deployment (29b10cbe) — the diagnostic email arrived immediately with Success: false and a Google error: CUSTOMER_NOT_ALLOWLISTED_FOR_THIS_FEATURE"New integrations for uploading click conversions should use the Data Manager API. Usage of ConversionUploadService.UploadClickConversions is limited to existing users." This is a genuine platform change, not a bug in Phase 2's code — confirmed via Google's developer blog: offline/click conversion imports migrate to the Data Manager API for everyone by 2026-06-15, and new integrations (ours, Basic Access approved only 2 days prior) are already locked out of the legacy endpoint ahead of that date. See Phase 2b below for the migration this requires.

Phase 2b — migrate to the Data Manager API (scoped 2026-09-05, code shipped 2026-09-05, blocked on a new credential)

Why this is a separate phase, not a patch to Phase 2's code: the Data Manager API is a different Google API with a different auth model, different request/response shape, and a different failure model — not a drop-in replacement for uploadClickConversions.

Auth question — resolved 2026-09-05. Google's own docs for setting up access state you can use "any of the Authentication methods at Google besides API keys" — service-account impersonation is presented as the easiest path (and the only one most third-party guides, e.g. Stape's, document), not the only one. The scope list (https://www.googleapis.com/auth/datamanager) is requested the same way as any other Google OAuth2 scope. Conclusion: the existing OAuth2 client-credentials + refresh-token flow (the same architecture Phase 0 already built for the Google Ads API, using GOOGLE_ADS_CLIENT_ID / GOOGLE_ADS_CLIENT_SECRET) works for the Data Manager API too — no service-account rebuild needed. The one catch: OAuth scopes are baked into a refresh token at mint time, so the existing GOOGLE_ADS_REFRESH_TOKEN (minted with only the adwords scope) can't be reused as-is. A new refresh token has to be minted against the same OAuth2 client, requesting the datamanager scope (alone, or combined with adwords in one token — kept separate here as DATA_MANAGER_REFRESH_TOKEN so Phase 2's legacy path and Phase 2b's new path don't share a credential during cutover). datamanager is a sensitive (not restricted) scope, so the existing OAuth consent screen — Testing mode, three test users already added in Phase 0 — can authorize it without a Google verification review. Developer token is not required for this call — confirmed via the docs above; GOOGLE_ADS_DEVELOPER_TOKEN is dropped from this code path (still relevant if the Google Keyword Planner consumer, Search_Strategy.md's I13 entry, ever calls the Ads API directly).

Endpoint: POST https://datamanager.googleapis.com/v1/events:ingest. Confirmed request shape, from Google's Send events guide and Destinations guide (field names verified against Google's docs, not guessed):

{
  "destinations": [{
    "operatingAccount": { "accountType": "GOOGLE_ADS", "accountId": "6365308143" },
    "loginAccount":     { "accountType": "GOOGLE_ADS", "accountId": "6074905262" },
    "productDestinationId": "7746080556"
  }],
  "encoding": "HEX",
  "events": [{
    "eventTimestamp": "2026-09-05T20:07:01Z",
    "transactionId": "1AEA4D",
    "adIdentifiers": { "gclid": "..." },
    "currency": "USD",
    "conversionValue": 1,
    "eventSource": "WEB",
    "userData": { "userIdentifiers": [{ "emailAddress": "<sha256 hex>" }] }
  }],
  "validateOnly": false
}

operatingAccount = the production Honey-Doers account (GOOGLE_ADS_CUSTOMER_ID); loginAccount = the IBT-LLC manager account (GOOGLE_ADS_LOGIN_CUSTOMER_ID) — this replaces the login-customer-id header the legacy API used with a body field. productDestinationId is the bare numeric conversion action ID (GOOGLE_ADS_CONVERSION_ACTION_ID, 7746080556), not the full customers/.../conversionActions/... resource path Phase 2 built. linkedAccount is omitted — that field is for data-partner/reseller scenarios, not direct advertiser access, which is what we have. Auth is a bearer access token in the Authorization header, same as Phase 2, just exchanged using DATA_MANAGER_REFRESH_TOKEN instead of GOOGLE_ADS_REFRESH_TOKEN.

Field mapping (legacy → Data Manager API):

uploadClickConversions field Data Manager API field Note
gclid adIdentifiers.gclid Direct
conversionAction resource name destinations[].productDestinationId (bare numeric ID) + operatingAccount/loginAccount Restructured into the destination, not a per-event field
conversionDateTime (custom yyyy-MM-dd HH:mm:ss+00:00) eventTimestamp RFC 3339 (Date.toISOString() works directly — formatConversionDateTime() is no longer needed)
conversionValue conversionValue Same field name, same unit
currencyCode currency Renamed
orderId transactionId Renamed
userIdentifiers[].hashedEmail userData.userIdentifiers[].emailAddress Still SHA-256 hashed hex (matches the existing sha256Hex helper — top-level encoding: "HEX" tells Google how to interpret it)
developer-token header (none) Not required for this API
login-customer-id header destinations[].loginAccount Moved from header to body

Failure model is different and actually simpler for our diagnostics: Data Manager API is fail-fast — a request with multiple events fails entirely if any one has an error, unlike partialFailure: true's per-conversion reporting. Since we only ever send one conversion per request, this doesn't change our code's shape much — logGoogleAdsResult and sendGoogleAdsDiagnosticEmail (added same day as this finding, see above) already treat the whole attempt as one success/failure, so they carry over largely as-is; no partialFailureError parsing is needed for this API — any non-2xx response is a clean failure.

Migration sequence — all setup steps done 2026-09-05:

  1. Resolve the auth questiondone. See above.
  2. Enable the Data Manager API on the existing honey-doers-ads-api Google Cloud projectdone, via the Cloud Console API Library.
  3. Mint a new refresh tokendone. Google no longer allows viewing/downloading an OAuth client's original secret after creation, so rather than disturb the client Phase 0 already built (GOOGLE_ADS_CLIENT_ID, "Google Ads API refresh token generator", Desktop type), a second client secret was added to that same client (Cloud Console supports up to two active secrets per client for rotation; both remain valid, so GOOGLE_ADS_CLIENT_SECRET in Cloudflare is untouched and still works for Phase 0's own token). The new secret was used locally (a scratch Python venv + google-auth-oauthlib's InstalledAppFlow, deleted after use) to run the OAuth consent flow under itsbusinesstimellc@gmail.com requesting https://www.googleapis.com/auth/datamanager, producing a refresh token stored directly as the DATA_MANAGER_REFRESH_TOKEN Cloudflare Pages secret (Production) via wrangler pages secret put — never written to a file. GOOGLE_ADS_CLIENT_ID/GOOGLE_ADS_CLIENT_SECRET (the original, first secret) are what contact.ts uses for the Data Manager token exchange too, per the "any currently-valid secret works for a client" rotation model — no separate client-ID env var needed for Phase 2b.
  4. Rewrite the upload block in functions/api/contact.tsdone. Replaces the Phase 2 (legacy uploadClickConversions) block outright rather than running both in parallel, since Phase 2's endpoint is confirmed closed to this integration (see the 1AEA4D finding above) — there's nothing for it to coexist with. logGoogleAdsResult/diagnostic-email plumbing and the LEAD_LOG KV namespace carried over unchanged.
  5. Not yet done — next action. Re-verify with a real gclid: submit a real ad-click-driven lead and check the diagnostic email / LEAD_LOG KV record (gads:{leadId}) for success: true.

Phase 3 — cutover and verification


Open decisions

  1. New conversion action vs. reusing SUBMIT_LEAD_FORMResolved, 2026-09-03. New action created (Server-Side Lead Conversion (API), ID 7746080556), per Google's guidance against mixing import sources. See Phase 0 above for full detail.
  2. Access-token caching strategyResolved, 2026-09-03. Plain re-exchange per request, implemented as-is in contact.ts. Revisit only if lead volume grows enough to matter — it isn't at ~55–65 leads/month per current Channel performance.
  3. Whether to also move GA4 off client-side third-party dependencies via the Measurement Protocol — out of scope here (GA4's own cookies are first-party; this file is scoped to the Ads-specific third-party cookies the audit actually flagged), but worth a future line item if GA4 ever needs the same treatment.

Why this is worth doing beyond the audit finding

Beyond "PageSpeed stops flagging it," server-side conversions:

The honest caveat: this is not a page-speed score project. The dominant levers for PageSpeed score on this site are elsewhere (image sizing, render-blocking CSS/JS, LCP element). This is a data-quality and audit-compliance project that happens to shave a small amount of JS execution as a side effect — scope and prioritize it as that, not as a speed initiative.