Machina
Technology

How to Build a World-Class GA4 Setup, Part 3: Activate & Govern — the Revenue Loop, Consent & Scale

The revenue loop is the move that pays for a clean GA4 build: carry each booked-job dollar back onto the click that produced it, so Google Ads bids on money instead of on the appearance of demand a form fill creates. This third part covers identity and per-lead value, US-sized Consent Mode v2, BigQuery warehousing, governance, and least-privilege access.

Francisco Contreras

Francisco Contreras · Founder, Machina

14 min read

Abstract fractal-glass artwork of translucent emerald and amber forms looping back on themselves like a closed revenue circuit, catching thin lines of light on a dark background

Key takeaways

  • Correct data is not the finish line — activation is. The offline-revenue loop attaches a real booked-job value to the original click, so Google Ads optimizes toward revenue rather than the appearance of demand a form fill creates.
  • Server-side collection has a hard ceiling. On the case property, 69% of leads carried a first-party analytics cookie (20 of 29); the missing 31% is ad-block and privacy-browser loss, not a bug — and the honest fix is to skip the send, never fabricate an identity.
  • Capture wbraid and gbraid, not just gclid. For a mobile, iOS-heavy local audience a gclid-only capture silently discards the majority of paid clicks, and the 90-day match window means slow-closing jobs need the hashed-identity fallback.
  • Consent Mode v2 is engineering, not paperwork. A US-only operator runs a granted global default with a denied default scoped to 32 EEA/UK/Switzerland region codes — cheap forward-compatibility that also unlocks Enhanced Conversions, which since June 2026 Google gates on the ad_user_data signal.
  • Correctness decays; governance catches it. A reconciliation check comparing database leads to GA4's generate_lead count would have caught the founding bug on day one — a server event POSTed to the wrong host, returning HTTP 404, so no server lead reached GA4 for the property's entire life.

Why are identity, attribution, and value one chain?

Identity answers who acted, attribution answers which marketing touch earned credit, and value answers how much the action was worth. They form a single dependency chain, and a break at any link degrades the two that follow. A local service business with no online checkout has to reconstruct that chain from cookie fragments and database columns, because its revenue lands offline as booked jobs, not in a browser. This is the third of a three-part series; Part 1 built the measurement plan and Part 2 fixed collection. Part 3 turns correct data into revenue, then keeps it correct.

GA4 identity is a ladder of durable keys. At the bottom sits client_id, minted by the gtag JavaScript and written to the first-party _ga cookie with a two-year life; it identifies the device, not the visit. The visit lives in a second cookie, _ga_<container>, which encodes the session_id. A server-side lead event that arrives with a valid client_id but no session_id cannot be matched to the visit's traffic source, so GA4 files it under (direct)/(none). On the case property the original Measurement Protocol payload omitted session_id entirely, which means a lead from google/organic would have been booked to direct traffic, silently. Attribution failure hid inside an identity omission. The documented fieldsclient_id, session_id, and engagement_time_msec — are what convert a technically-delivered event into an attributable one.

69%

Share of leads on the case property that carried a first-party analytics cookie (20 of 29). The missing 31% is ad-block and privacy-browser loss — the hard ceiling on server-side attribution, which no configuration change lifts.

Measured directly from the property's production database; paper Ch.9.

The ad-block ceiling, measured

Server-side collection is sold as the fix for ad blockers, and the pitch overstates it. The server can only forward the identity the browser minted; if a blocker stopped gtag from ever writing _ga, there is no client_id to send. The case property makes that measurable because every lead persists its captured client_id to the database. Of 29 leads, 20 carried one and 9 did not — a 69% capture rate — and the missing 31% is ad-block and privacy-browser loss, not timing, because _ga persists two years once written.

The right response to that ceiling is to stop faking identity. The pre-reconstruction code minted a fresh server.<timestamp> identifier whenever _ga was missing; every phantom became a brand-new single-event user in GA4, inflating the user count and attaching a lead to a person the property would never see again. The rewrite deletes the fallback: no real client_id, no Measurement Protocol send. The untrackable lead stays in the database for the offline loop, and GA4 never receives a hallucinated user. Honest under-reporting beats confident fiction, and it keeps the user count clean.

Why capture wbraid and gbraid, not just gclid?

The connective tissue of the whole revenue loop is the click identifier Google appends to a paid landing-page URL. Capture it, store it against the lead, upload it later with the outcome, and Google matches the conversion back to the auction, keyword, and campaign. The historically canonical identifier is gclid — and for a mobile, iOS-heavy local audience, a gclid-only capture is a quiet, expensive mistake.

Under Apple's privacy handling, Google does not always emit a gclid. It emits wbraid for web-to-web clicks, or gbraid for app-to-web and iOS contexts, where the classic identifier cannot be set. These are aggregate, privacy-safe identifiers, but for offline conversion import they play the same structural role: they let Google match the conversion back to the campaign. A capture that reads only gclid discards exactly the mobile, Safari, and consent-limited slice — which for a local service business is the majority of the paid audience. The lost conversions do not error; they simply never match, and the bidder never learns from them. The offline-conversion upload carries all three identifiers for exactly this reason.

Value is the third link, and it has to be a number before it can travel. The case property replaced a flat value: 150, which treated a $100 ant call and a $500 fumigation as identical, with a per-service estimate assigned at submit time:

Provisional per-service lead value assigned at form submission.
Service typeProvisional lead value (USD)Note
Fumigation$500Highest-value job type
Termite$400Seasonal spring driver
Commercial$400Recurring-contract intent
Bed bug$250
Rodent$180Cooler-months driver
General / pest$150Default when unspecified
Ant / wasp-bee$100Lowest-value single call
Other$120

Provisional, internal per-service estimates assigned at submission on the case property. A GA4 lead value is a bidding and reporting input, never a figure shown to a customer. Source: paper Ch.7 and Ch.9.

Read the label carefully: these are provisional and internal. A GA4 lead value is a modeling input, never shown to a customer, so it can be an honest estimate. But the gap between an estimated lead value and the realized job value — quoted and closed weeks later in the office — is the entire commercial question, and no browser or server event can close it. That is the job of the offline loop below.

One attribution caveat governs everything downstream. GA4's default reporting model is data-driven attribution, which historically needs on the order of 400 conversions per 28 days to fit a stable model, and silently falls back to last-click behavior below that. A property with lead conversions in the low tens is effectively running last-click no matter which model the interface names. Read the model label as a description of intent, not of what is happening, and read multi-touch reports as last-touch summaries until volume justifies otherwise.

What is the offline-revenue loop, and why is it the point?

Every capture decision in a build like this exists to enable one outcome: teaching Google's bidding systems what a real booked job is worth, not what a form fill appears to be worth. For Cypress, a homeowner clicks an ad, reads a service page, and either calls or submits a form. Nothing transacts online. Days or weeks later a technician inspects the property, the office quotes a price, and some fraction of quotes become paid work. Revenue lives in that offline gap. The loop is the set of mechanisms that carries the money back across the gap and stamps it onto the original click, so the ad platform can learn.

Bidding on form fills optimizes for the appearance of demand. A fumigation lead and an ant lead fire the same event, and a form fill is not a sale — some services close at high rates, some at low, and a bidder trained on form fills will happily spend to acquire tire-kickers it cannot see never became jobs. Report the closed job with its actual value and close date, and the bidder learns to buy the clicks that become paid work, weighted by how much that work is worth. The keywords, geographies, and times of day that produce high-value jobs get more budget; the ones that produce cheap leads that never close get less.

There are two ways to get an offline conversion into Google, and one is a dead end for a new account. On 2026-06-15 Google blocked the legacy Google Ads API path (OfflineConversionUpload) for developer tokens not previously allowlisted, returning CUSTOMER_NOT_ALLOWLISTED_FOR_THIS_FEATURE, per the Google Ads Developer Blog. Any property standing up its integration after mid-2026 must go through the Data Manager API. The upload itself is a projection of one database row — every field already stored the moment the form posted:

  • The click identifiergclid, wbraid, or gbraid — that ties the conversion back to the auction, keyword, and campaign.
  • The conversion time (the job's close date), which Google requires and which the 90-day match window is measured against.
  • The conversion value — the real booked job_value, not the submit-time estimate.
  • Where consent permits, the SHA-256-hashed email and phone, normalized to lowercase and E.164 form, for the Enhanced Conversions identity match.

The correct sequencing is not to build the automated pipeline first. Google Ads imports offline conversions directly from a scheduled Google Sheet — one row per won job — so the loop can run end to end and start teaching the bidder before any pipeline exists; automate when the sheet's manual burden or its latency becomes the binding constraint. Set expectations honestly: uploads that land weeks after the click commonly match in the 20-50% range, and the single most important constraint is the 90-day gclid window. A homeowner who clicks during spring termite season and finally books in mid-summer can cross it, at which point only the Enhanced Conversions hashed-identity path recovers the conversion. Upload won jobs weekly, not quarterly.

20–50%

The normal, expected match rate for offline conversions uploaded weeks after the click — not a defect. The matched half is teaching the bidder; the gap comes from missing identifiers, the 90-day window, and privacy states.

Paper Ch.11, "The Offline-Revenue Loop."

Honesty about scale is part of doing the work. At roughly 46 paid clicks a month, the count that become won jobs sits below the ~15-30 conversions a month that value-based Smart Bidding needs to engage, so the loop will not fully drive automated bidding yet. That does not make it pointless: it delivers cost-per-booked-job visibility immediately — ad spend divided by actually-won jobs, not raw form fills — which is decision-grade on its own. Build it now, and the same loop becomes the training signal at volume with no rework. That is the shape of Google Ads management done properly: the account optimizes on revenue, not on the shadow of it.

Why warehouse GA4 data in BigQuery?

Event-data retention on the case property sat at the two-month floor — the GA4 minimum. A single dataRetentionSettings PATCH raised it to 14 months, and that is still not enough. Retention governs only Explorations and funnels that read event- or user-scoped custom dimensions; standard aggregate reports retain effectively indefinitely. For a seasonal operator whose core question is whether this termite season is bigger than last, and in which counties, 14 months shows one spring — never this spring against last spring, because last spring's event-scoped detail has aged out by the time this spring arrives.

The GA4-to-BigQuery daily export removes the horizon, and the cost objection does not survive arithmetic. BigQuery's free tier grants 10 GB of storage and 1 TB of query scanning a month; the property produces roughly 5 MB of event data a month — three orders of magnitude under the storage grant — with daily batch export free and only streaming export billed. Leave streaming off. Rows land unsampled and reprocessable: a count in BigQuery is a true count, taken before the sampling and thresholding GA4's Explorations apply, and a taxonomy error caught later can be recomputed against the raw parameters in place.

≈ $0

Cost to warehouse this property's GA4 export: ~5 MB of monthly event data against a 10 GB storage and 1 TB query free tier — roughly 2,000 months of accumulation before storage alone breaches the free tier.

BigQuery pricing; paper Ch.12.

GA4 answers the behavioral, pre-conversion question well; it structurally cannot answer the economic one, because close rate and revenue-per-service depend on an outcome that enters through the office, not a tag. So the reporting surface splits along that seam: BigQuery for multi-year seasonality an analyst queries with SQL, and a native admin dashboard driven by the property's own PostgreSQL for the office that needs close rate and booked revenue this week. Two questions, two stores, neither able to do the other's job.

How do you keep a correct property correct?

A GA4 property is not a building you finish and photograph; it is a garden that reverts. The case property is the proof. Its server-side generate_lead event had never once reached Google, because both API routes posted to https://www.google.com/mp/collect — a host that returns HTTP 404 — instead of https://www.google-analytics.com/mp/collect, which returns 204. The measurement was wrong from the property's creation, and the wrongness was silent: no dashboard turned red, and the event's absence looked identical to a business that simply had not yet generated a lead. Governance is the discipline that installs a complaint where the system provides none.

The single most important complaint is reconciliation. GA4 reports a generate_lead count; PostgreSQL holds one contact_submissions row per lead, written by the same route. Those two numbers measure the same event through different pipes, and the healthy ratio is not 1:1 — it is the ~69% capture rate, because the server event fires only when a real client_id is present. A ratio that collapses toward zero means the pipe is severed, which is the exact signature of the 404 bug and the exact state the property sat in for its whole life. A ratio climbing past 1.0 means something double-fires. The database is the reference standard because it is written in the same transaction path as the business action and does not depend on a reachable third-party endpoint.

Two standing rules prevent the rest. Register a custom dimension before, or in the same change as, the code that first sends its parameter — GA4 collects an unregistered parameter and then discards it from every report, invisibly, and registration is not retroactive, so every day of delay forfeits that day's analyzable history. And gate every server change through the debug endpoint (/debug/mp/collect, expect validationMessages: []) before the live send (expect 204), then confirm arrival in Realtime. The founding 404 would have been caught at the live-send step by anyone who looked. These rules and the committed measurement plan are the foundation Part 1 builds; the host fix and server-side collection are Part 2.

Anti-pattern catalog: high-severity entries to check at launch and each quarter.
Anti-patternWhat breaksSeverity
Measurement Protocol posted to www.google.com, not www.google-analytics.comServer conversions reach GA4 zero times; the loss is completely silentCritical
Synthetic client_id (server.timestamp) minted per untracked leadPhantom single-event users inflate counts and sever attributionHigh
Send failures swallowed by an empty catch blockA 404 looks identical to a 204; the critical bug hides for the property's lifeHigh
Flat lead value across every service typeReports and bidding cannot tell a $500 job from a $100 oneMedium
Event-data retention left at the two-month floorYear-over-year seasonal, dimension-driven analysis dead-endsMedium

Selected entries from the paper's anti-pattern catalog (Ch.14), rated as impact on the case property.

The reconciliation query runs on a schedule and pages a human when the ratio leaves its band — the kind of standing check a marketing automation program should own, because the dangerous failures in GA4 are precisely the ones that never throw an exception, fail a build, or redden a dashboard.

Who should hold the keys?

Every control above is itself configuration, and configuration has an owner. GA4 exposes two programmatic surfaces: the Admin API reads and writes configuration (custom dimensions, key events, retention, product links, access bindings); the Data API reads reporting. The audit ran through both under a Google Cloud service account reached by impersonation, not a downloaded JSON key. The token is short-lived — valid for about an hour — so a leaked credential's blast radius is bounded by its lifetime, and the durable trust lives in one auditable IAM binding rather than a key file scattered across laptops.

A request must pass two independent gates: the OAuth scope (was this token minted to perform this class of operation) and the GA4 role (is this identity allowed to touch this resource at all). They fail differently, and confusing them wastes hours. Reading the access-binding roster needs the analytics.manage.users.readonly scope; a token minted with analytics.edit returns ACCESS_TOKEN_SCOPE_INSUFFICIENT against that endpoint — which is a statement about the token, never a signal to widen a role. Mint three narrow tokens rather than one maximal one, so no token in the working set carries more authority than the operation it is about to perform.

The reconstruction ran almost entirely inside the Editor role. The one place it hit a wall was the BigQuery link. bigQueryLinks.create returned HTTP 403 even with GA4 Administrator, and again after the account was granted roles/bigquery.admin. The reason rewards close reading: creating the link is a compound operation — GA4 must grant its own managed export service account project-level IAM, which requires project-IAM-admin authority (Owner or resourcemanager.projectIamAdmin), categorically distinct from administering BigQuery. The link's own setup docs name it: Editor or above on the Analytics property plus Owner on the destination Cloud project.

A standing grant of the most dangerous project role to a non-human identity, in exchange for saving a human a single click, is a bargain no security review should accept.
The least-privilege thesis of the paper's access-and-permissions chapter

So the owner created the link once through the GA4 UI, under a human's standing privilege used a single time, and the automation confirmed the result it was allowed to read — dailyExportEnabled: true, streaming off — without ever holding the power to write it. The correct response to that 403 was not to escalate the automation; it was to hand one step to the human owner who already held the privilege. A measurement system is worth exactly what it can prove, and it stays worth that only as long as someone owns both the rules and the keys.

FAQ

Frequently asked questions

What is the offline-revenue loop in GA4 and Google Ads?

It is the set of mechanisms that carries a real booked-job dollar value back onto the click that produced the lead, so Google Ads bids on revenue rather than on form fills. A lead is captured with its click identifier and stored; weeks later, when the job closes, the actual value and close date are uploaded back to Google, matched to the original click by gclid/wbraid/gbraid or by hashed email and phone. For any account standing up its integration after the 2026-06-15 legacy cutoff, the upload goes through the Data Manager API.

gclid vs wbraid vs gbraid — which click identifier should I capture?

Capture all three. gclid is the classic Google Click Identifier; wbraid accompanies web-to-web clicks and gbraid accompanies app-to-web and iOS contexts where Apple's privacy handling prevents a gclid from being set. For a mobile, Safari-heavy local audience, a gclid-only capture silently discards the majority of paid clicks — they never error, they just never match. The 90-day gclid match window is a second reason to also capture the hashed-identity fallback (Enhanced Conversions), for jobs that close slowly.

Does a US-only local business need a cookie banner and Consent Mode?

No US federal statute and no California statute requires an analytics tag to default to denied or to sit behind a consent banner. CalOPPA requires a privacy policy (a content obligation), and CCPA/CPRA does not bind below its thresholds — $25M revenue, 100,000 consumers, or 50% of revenue from selling data — which a ~6,000-session-a-year operator clears none of. Still run a granted global default with a denied default scoped to 32 EEA/UK/Switzerland region codes: it is cheap forward-compatibility and, since June 2026, the ad_user_data signal it emits is what unlocks Enhanced Conversions.

Is a GA4 BigQuery export expensive for a small business?

No — at this volume it is effectively free. BigQuery's free tier grants 10 GB of storage and 1 TB of query scanning per month, and a property producing roughly 5 MB of event data a month sits three orders of magnitude under the storage grant. Daily batch export is free; only streaming export bills, so leave streaming off. The export lifts the 14-month retention ceiling into an accumulating multi-year archive, which is what makes year-over-year seasonal analysis possible.

Why does GA4 attribute my server-side conversions to (direct)/(none)?

Almost always a missing session_id. A server event that arrives with a valid client_id but no session_id cannot be matched to the visit's traffic source, so GA4 files it under (direct)/(none) even when the visit came from google/organic. Parse session_id from the _ga_ cookie (handling both the legacy GS1 dot-delimited and the current GS2 dollar-delimited formats) and send engagement_time_msec alongside it, so the event joins the right session.

What single governance check catches a broken GA4 setup fastest?

Reconciliation: compare the count of lead rows in your own database against GA4's generate_lead count over the same window. The healthy ratio is not 1:1 — it tracks your cookie capture rate (about 69% on the case property) — and a collapse toward zero is the unmistakable signature of a severed collection pipe. This one check would have caught the founding 404 bug on day one, when every other dashboard stayed silent.

Next steps

Let's talk about your project

Tell us what you're working on. We'll tell you if we can help — and exactly what we'd do.