Rendered at build time from SERVER_SIDE_CONVERSIONS.md in the repo — edit the source
file and rebuild to update this page.
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.
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:
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.
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:
sha256Hex(email.toLowerCase())) before sending — no raw PII leaves the server.waitUntil() so the conversion POST doesn't block the form response to the user.All of that carries over unchanged. The two real deltas are:
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.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.IBT-LLC (607-490-5262) under itsbusinesstimellc@gmail.com,
with the production Honey-Doers account (636-530-8143) linked under it as a client. Developer
token issued — Test Account Access level only, not yet Basic Access (Basic Access needs a
design-doc upload describing the tool; deferred until Phase 1/2 code exists to describe). Google
Cloud project honey-doers-ads-api created (project number 1070355077045), Google Ads API
enabled, OAuth2 client (Desktop app type) created, one-time refresh token generated via
google-auth-oauthlib's InstalledAppFlow against the https://www.googleapis.com/auth/adwords
scope. OAuth consent screen is in Testing mode with three test users
(itsbusinesstimellc@gmail.com, spenser.baldwin@gmail.com, solutions@honey-doers.com) — fine
for this internal-only tool, no public verification needed.spenser.baldwin@gmail.com (720-940-6360, 196-195-8254) and one duplicate under
itsbusinesstimellc@gmail.com (512-527-9164), all unrelated to the real MCC — deleted.hd-com-astro (Settings → Variables and secrets, Production environment): GOOGLE_ADS_DEVELOPER_TOKEN,
GOOGLE_ADS_CLIENT_ID, GOOGLE_ADS_CLIENT_SECRET, GOOGLE_ADS_REFRESH_TOKEN (all type Secret).
Two supporting IDs added as plain Text vars (not sensitive, just config Phase 2's code needs):
GOOGLE_ADS_LOGIN_CUSTOMER_ID = 6074905262 (the IBT-LLC manager account, no dashes — required
as the login-customer-id header on API calls made on behalf of a client account) and
GOOGLE_ADS_CUSTOMER_ID = 6365308143 (the production Honey-Doers account, no dashes — the
actual target of the conversion upload).Form,
Lead form - Submit, SUBMIT_LEAD_FORM) confirmed live under the same "Submit lead forms"
goal. Named "Server-Side Lead Conversion (API)", ID 7746080556 — stored as
GOOGLE_ADS_CONVERSION_ACTION_ID (Text) in Cloudflare. Deliberately set as a secondary action,
not used for bidding optimization (not primary) until the pipeline is verified working over a
real submission cycle — avoids the new action silently double-counting against or skewing the
three existing primary actions before it's trusted. Same value ($1 default) and Count = One
(recommended for leads, not "Every"). Full API resource name for Phase 2's code:
customers/6365308143/conversionActions/7746080556.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.
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.
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:
honey-doers-ads-api Google Cloud projectGOOGLE_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.functions/api/contact.tsuploadClickConversions) 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.LEAD_LOG KV record (gads:{leadId}) for success: true.ccm/collect
cookies) — this is the step that actually clears the PageSpeed finding. Leave the base GA4/GTM tag
in place; only the Ads-specific conversion/remarketing tag needs to go.gclid cookie, which shouldn't trip this specific check).SUBMIT_LEAD_FORM — Resolved, 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.contact.ts. Revisit only if lead volume grows enough to matter — it
isn't at ~55–65 leads/month per current Channel performance.Beyond "PageSpeed stops flagging it," server-side conversions:
gclid.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.