Google Ads Attribution Setup Guide
End-to-end Google Ads measurement: gtag, Enhanced Conversions for Web and Leads, Consent Mode v2, offline imports, gclid/gbraid/wbraid, sGTM, and Customer Match.
The Google Attribution Stack in 2026
Google Ads measurement is no longer "drop a conversion pixel and you're done." Cookies are restricted, iOS strips identifiers, GA4 replaced Universal Analytics in July 2024, and Consent Mode v2 has been enforced in the EEA since 2024-03-06. The accurate setup is a stack — each layer covers a different gap.
| Layer | Purpose | What it solves |
|---|---|---|
Google tag (gtag.js) | Single page tag for Google Ads + GA4 + Floodlight | Replaces the legacy gtag('config', ...) per-product loaders. One tag, multiple destinations. |
| Conversion linker | Captures gclid/gbraid/wbraid from URL into a first-party cookie (_gcl_aw) | Multi-page funnels — the click ID is on the landing page, the conversion is 3 pages later. |
| Enhanced Conversions for Web | Sends hashed first-party PII alongside the conversion | ITP/cookie loss, cross-device, signed-in users. ~5–10% lift in modeled conversions is typical. |
| Enhanced Conversions for Leads | Offline closed-loop: form fill on site -> CRM close -> uploaded with same hashed identifiers | B2B / lead-gen where conversion happens days/weeks after the click. |
| Consent Mode v2 | Sends consent state to Google so it can model unconsented conversions | Required to keep ad personalization & remarketing working in the EEA, UK, and Switzerland. |
| Offline conversion imports (gclid) | Upload gclid + conversion within 90 days | Phone calls, sales-assisted closes, anywhere a gclid is the only signal you have. |
| GA4 -> Google Ads conversion import | Mirror GA4 key events into Google Ads | When you've already standardized measurement on GA4 and want bidding to use the same definitions. |
| Customer Match | Hashed first-party audience lists for targeting / suppression | Retention, suppression of existing customers, lookalike-style seed lists. |
| Server-side Google Tag (sGTM) | First-party endpoint that proxies to Google + other vendors | Ad-blocker / ITP resilience, payload control, PII redaction before it leaves your domain. |
Rule of thumb: Google tag + Conversion linker + Enhanced Conversions + Consent Mode v2 is the modern baseline. Everything else is additive.
Click ID Landscape: gclid, gbraid, wbraid
Google emits one of three click identifiers depending on platform and ad surface. Capturing all three is required — older code that only handles gclid silently drops iOS attribution.
| Param | When it fires | Surface | Notes |
|---|---|---|---|
gclid | Click on a Google ad on web (non-iOS, or iOS with ATT consent) | Search, Display, YouTube, Shopping web | Deterministic, one ID per click. Retained by Google for 90 days for offline imports. |
gbraid | iOS user clicks a web ad that lands on an iOS app | App campaigns, iOS app installs | Privacy-preserving, aggregated (multiple users may share an ID). Introduced after iOS 14.5 (Apr 2021). |
wbraid | iOS user clicks inside an iOS app and lands on a website | In-app placements -> web destination | Same aggregated/privacy model as gbraid. |
Why three identifiers exist
iOS 14.5 introduced App Tracking Transparency (ATT). Without ATT consent, Google cannot send gclid (which can identify a single user) on iOS surfaces. gbraid and wbraid are aggregated across users so they survive ATT — at the cost of some attribution granularity.
Auto-tagging: the prerequisite
gclid/gbraid/wbraid are only appended when Auto-tagging is enabled at the Google Ads account level. With auto-tagging off, Google falls back to UTM-only tracking and Enhanced Conversions / GCLID-based offline imports stop working.
In Google Ads -> Admin -> Account settings -> Auto-tagging -> Tag the URL that people click through from my ad. Default for new accounts is on; legacy accounts may have it off.
?utm_source=google), Google appends &gclid=.... If you're using server-side redirects that strip query params, the click ID is lost before it reaches the landing page. Test with a real ad click, not a manual URL.Capturing click IDs on the landing page
Capture all three. Even on a non-iOS site you'll occasionally see wbraid from in-app YouTube traffic.
// Run on every landing page, before any conversion logic
function captureGoogleClickIds() {
const url = new URLSearchParams(window.location.search);
const ids = {
gclid: url.get('gclid'),
gbraid: url.get('gbraid'),
wbraid: url.get('wbraid'),
};
for (const [key, value] of Object.entries(ids)) {
if (value) {
// 90-day cookie, lined up with Google's retention window
const expires = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toUTCString();
document.cookie = `_orbit_${key}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax; Secure`;
// Also keep in localStorage for SPA navigation
try { localStorage.setItem(`_orbit_${key}`, value); } catch {}
}
}
}
captureGoogleClickIds();
If you have the Conversion linker tag installed (GTM) or the Google tag (gtag.js) loaded with conversion_linker: true (default), Google does this for you and stores the value in a first-party cookie called _gcl_aw. You only need the manual capture above when you intend to send the click ID server-side from your own backend.
_gcl_aw cookie format is GCL.{timestamp}.{click_id} — parse it server-side if you're sending click IDs to the Ads API yourself.Google Tag Setup
There are two install paths. Pick one — running both creates duplicate hits and breaks deduplication.
Path A — Direct gtag.js install
Add to <head>, as high as possible, on every page. Replace AW-XXXXXXXXXX with your Google Ads conversion ID (Tools -> Conversions -> any conversion action -> "Tag setup -> Install the tag yourself").
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=AW-XXXXXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
// Default consent state — required for Consent Mode v2.
// See "Consent Mode v2" section below for the full configuration.
gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
wait_for_update: 500
});
gtag('config', 'AW-XXXXXXXXXX', {
// conversion_linker: true is the default — leaving this on
// is what populates the _gcl_aw first-party cookie from gclid/gbraid/wbraid.
conversion_linker: true,
// Optional: bind a stable user_id for cross-device matching
user_id: window.__currentUserId
});
// If you also use GA4, add the same script with G-XXXXXXX — one snippet, multiple IDs.
</script>
Fire a conversion event:
gtag('event', 'conversion', {
send_to: 'AW-XXXXXXXXXX/AbC-D_efG-h12_34-XYZ', // conversion label from Ads UI
value: 49.99,
currency: 'USD',
transaction_id: 'order_5193' // dedup key — required if you also import the same order via API
});
Path B — Google Tag Manager (web container)
For most agency clients GTM is easier to maintain. Setup:
- Install the GTM container snippet (
<script>in<head>,<noscript>after<body>) on every page. - Add a Google Tag (the unified tag, not the legacy "Google Ads Conversion Tracking" tag) configured with your
AW-XXXXXXXXXXID. Trigger: All Pages. - Add a Conversion Linker tag. Trigger: All Pages. This is what enables
_gcl_awcookie writes; without it, multi-page funnels lose attribution. - Add a Google Ads Conversion Tracking tag for each conversion action, with the conversion label. Trigger on the relevant funnel event (e.g. a purchase confirmation page or a custom
dataLayerevent).
// Push from your purchase confirmation page
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'purchase',
ecommerce: {
transaction_id: 'order_5193',
value: 49.99,
currency: 'USD'
},
user_data: {
email: 'customer@example.com', // raw — GTM will hash for Enhanced Conversions
phone: '+12125551234'
}
});
gtag/js loads and inflate metrics.Conversion Actions: Settings That Actually Matter
When you create a conversion action in Google Ads (Tools -> Conversions -> + New conversion action) the defaults are not always right.
| Setting | What it does | What to actually pick |
|---|---|---|
| Goal & action type | Bucket for reporting + Smart Bidding | Match the funnel stage. Don't lump all leads under "Submit lead form". |
| Primary vs. Secondary | Primary actions feed Smart Bidding and the "Conversions" column. Secondary actions are observation-only. | One primary per Smart Bidding goal. Move soft conversions (newsletter, scroll depth) to secondary. |
| Value | "Use the same value for each", "Use different values", or "Don't use a value" | For lead-gen B2B, set an estimated lead value. Performance Max + Maximize Conversion Value won't bid intelligently without one. |
| Count | "Every" (every conversion counts — best for ecom) or "One" (best for lead gen — one signup per click max) | Ecom: Every. Lead-gen / SaaS trials: One. |
| Click-through window | How long after a click a conversion can be attributed | Default 30 days. Long sales cycles: 90 days. |
| View-through window | Same, for impressions | Default 1 day. Don't extend without a reason — view-through is noisy. |
| Attribution model | Last-click or Data-driven (DDA) | DDA is the default for new actions since June 2023. Last-click only for compliance/baselining. |
| Include in "Conversions" | Toggles whether this action feeds Smart Bidding | Off for any soft/observational action. |
Enhanced Conversions for Web
Enhanced Conversions sends SHA-256 hashed first-party data alongside each conversion event. Google matches the hash against signed-in Google accounts to recover conversions lost to cookie deletion, ITP, or cross-device journeys.
Three setup methods
| Method | Best for | Effort |
|---|---|---|
| Google tag — auto-detect | Sites where the conversion page renders the email/phone in the DOM (order confirmation pages, "Thanks Jane" headers) | Low — Google scrapes the DOM. Validate matches in the diagnostics report. |
| Google tag — manual JS | SPAs, sites where PII isn't rendered, or you want explicit control over what gets sent | Medium — call gtag('set', 'user_data', ...) before the conversion event |
| Google Ads API | Server-side: send hashed identifiers from your backend with the gclid (or with no gclid for closed-loop CRM) | Highest — full control, survives ad blockers |
Required user fields
At minimum send one of: email, OR full address (first name + last name + postal code + country). More fields = higher match rate.
| Field | Hashed? | Normalization rules |
|---|---|---|
sha256_email_address | Yes | Lowercase + trim whitespace before hashing |
sha256_phone_number | Yes | E.164 format (+12125551234) before hashing |
sha256_first_name | Yes | Lowercase + trim, strip non-letter chars except hyphens |
sha256_last_name | Yes | Same as first name |
sha256_street | Yes | Lowercase + trim, expand abbreviations (st -> street) |
postal_code | No | Send raw |
country | No | Two-letter ISO (US, GB) — raw |
Manual JS configuration (Google Tag)
Call gtag('set', 'user_data', ...) before the conversion event fires:
async function sha256(value) {
const buf = new TextEncoder().encode(value);
const hashBuf = await crypto.subtle.digest('SHA-256', buf);
return Array.from(new Uint8Array(hashBuf))
.map(b => b.toString(16).padStart(2, '0')).join('');
}
function normalizeEmail(e) { return e.trim().toLowerCase(); }
function normalizePhone(p) { return p.replace(/[^\d+]/g, ''); } // assume already E.164
function normalizeName(n) { return n.trim().toLowerCase().replace(/[^a-z\-]/g, ''); }
async function setEnhancedConversionsUserData({ email, phone, firstName, lastName, postalCode, country }) {
const userData = {};
if (email) userData.sha256_email_address = await sha256(normalizeEmail(email));
if (phone) userData.sha256_phone_number = await sha256(normalizePhone(phone));
if (firstName) userData.address = userData.address || {};
if (firstName) userData.address.sha256_first_name = await sha256(normalizeName(firstName));
if (lastName) userData.address.sha256_last_name = await sha256(normalizeName(lastName));
if (postalCode) userData.address = { ...userData.address, postal_code: postalCode };
if (country) userData.address = { ...userData.address, country };
gtag('set', 'user_data', userData);
}
// On purchase confirmation:
await setEnhancedConversionsUserData({
email: 'jane@example.com',
phone: '+12125551234',
firstName: 'Jane',
lastName: 'Doe',
postalCode: '10001',
country: 'US'
});
gtag('event', 'conversion', {
send_to: 'AW-XXXXXXXXXX/AbC-D_efG-h12_34-XYZ',
value: 49.99,
currency: 'USD',
transaction_id: 'order_5193'
});
Google Ads API (server-side)
For high-trust, ad-blocker-resilient setup, upload from your backend. Use the google-ads-api Node client or raw REST.
// Node 20+ / google-ads-api v17+
import { GoogleAdsApi } from 'google-ads-api';
import crypto from 'node:crypto';
const client = new GoogleAdsApi({
client_id: process.env.GOOGLE_ADS_CLIENT_ID,
client_secret: process.env.GOOGLE_ADS_CLIENT_SECRET,
developer_token: process.env.GOOGLE_ADS_DEVELOPER_TOKEN,
});
const customer = client.Customer({
customer_id: process.env.GOOGLE_ADS_CUSTOMER_ID, // 10 digits, no dashes
login_customer_id: process.env.GOOGLE_ADS_MCC_ID, // if accessing via MCC
refresh_token: process.env.GOOGLE_ADS_REFRESH_TOKEN,
});
const sha256 = (v) => crypto.createHash('sha256').update(v).digest('hex');
async function uploadEnhancedConversion({ gclid, orderId, value, currency, email, phone }) {
const userIdentifiers = [];
if (email) userIdentifiers.push({ hashed_email: sha256(email.trim().toLowerCase()) });
if (phone) userIdentifiers.push({ hashed_phone_number: sha256(phone.replace(/[^\d+]/g, '')) });
// up to 5 user_identifiers per conversion
const conversion = {
conversion_action: `customers/${process.env.GOOGLE_ADS_CUSTOMER_ID}/conversionActions/123456789`,
conversion_date_time: new Date().toISOString().replace('T', ' ').slice(0, 19) + '+00:00',
conversion_value: value,
currency_code: currency,
order_id: orderId, // dedup key — same as gtag transaction_id
gclid, // if you have it; recommended even with user_identifiers
user_identifiers: userIdentifiers,
consent: {
ad_user_data: 'GRANTED', // pass through actual consent state
ad_personalization: 'GRANTED'
}
};
return customer.conversionUploads.uploadClickConversions({
customer_id: process.env.GOOGLE_ADS_CUSTOMER_ID,
conversions: [conversion],
partial_failure: true
});
}
transaction_id / order_id on both. Without that key Google double-counts. This is the single most common Enhanced Conversions misconfiguration we see.Enhanced Conversions for Leads
Different feature, similar name. EC for Leads is the closed-loop offline flow: a user fills a form on your site, you receive their (hashed) identifiers via the Google tag, and weeks later when the lead converts in your CRM you upload the conversion with the same hashed identifiers. Google matches it back to the original click.
Flow
[1] User clicks ad -> lands on site (gclid captured)
|
[2] User submits lead form
- Google tag fires lead_submit event with email/phone (hashed)
- Same identifiers stored in your CRM with the lead record
|
[3] (days/weeks pass)
|
[4] Lead closes in CRM (deal won)
- Your backend calls Google Ads API with hashed email/phone + conversion value
- Google matches the hash to the original gclid -> attributes conversion
Key differences from EC for Web
| EC for Web | EC for Leads | |
|---|---|---|
| Trigger | Online conversion (purchase, signup) | Offline conversion (deal won, qualified lead) |
| Conversion action type | WEBPAGE | UPLOAD_CLICKS |
gclid required? | No (auto from cookie) | No (matched via user_identifiers) but recommended if you have it |
| Time-to-conversion | Real-time | Up to 90 days post-click |
| Send method | gtag / GTM / API | API only (or Google Sheets / Salesforce / HubSpot connector) |
Configure the Google tag for EC for Leads
In Google Ads -> Tools -> Conversions -> diagnostics for the lead conversion action, enable Turn on Enhanced Conversions for Leads and choose either Google tag or GTM. The tag will start collecting hashed form-field values. Then you upload the closed deal:
// CRM webhook handler (Node) — fires when deal moves to "won"
import { GoogleAdsApi } from 'google-ads-api';
import crypto from 'node:crypto';
const sha256 = (v) => crypto.createHash('sha256').update(v).digest('hex');
async function uploadLeadConversion({ leadEmail, leadPhone, dealValue, dealClosedAt, conversionActionId }) {
const customer = client.Customer({ /* ... as above ... */ });
const conversion = {
conversion_action: `customers/${process.env.GOOGLE_ADS_CUSTOMER_ID}/conversionActions/${conversionActionId}`,
conversion_date_time: dealClosedAt, // 'YYYY-MM-DD HH:MM:SS+00:00'
conversion_value: dealValue,
currency_code: 'USD',
user_identifiers: [
{ hashed_email: sha256(leadEmail.trim().toLowerCase()) },
{ hashed_phone_number: sha256(leadPhone.replace(/[^\d+]/g, '')) }
],
consent: { ad_user_data: 'GRANTED', ad_personalization: 'GRANTED' }
};
return customer.conversionUploads.uploadClickConversions({
customer_id: process.env.GOOGLE_ADS_CUSTOMER_ID,
conversions: [conversion],
partial_failure: true
});
}
UPLOAD_CLICKS for EC for Leads. If you accidentally upload to a WEBPAGE conversion action, the API returns INVALID_CONVERSION_ACTION_TYPE and the conversion is silently dropped on partial-failure mode.Consent Mode v2
Mandatory in the EEA, UK, and Switzerland since 2024-03-06 if you want ad personalization, remarketing, or modeled conversions to keep working. v2 added two parameters on top of v1's ad_storage / analytics_storage:
| Param | Controls |
|---|---|
ad_storage | Cookies/local storage for advertising |
analytics_storage | Cookies/local storage for analytics |
ad_user_data | (v2) Whether user data may be sent to Google for advertising |
ad_personalization | (v2) Whether the user may receive personalized ads / remarketing |
Basic vs. Advanced
| Mode | Behavior on consent denied | Modeling quality |
|---|---|---|
| Basic | Google tags don't load at all until consent. No pings sent. | Lower — Google has no signal to model from. |
| Advanced | Google tags load before consent. On denied, send cookieless pings (no identifiers, just event). | Higher — Google can model conversions for unconsented users. |
Advanced is the recommended setup. It requires a Google-certified CMP (OneTrust, Cookiebot, CookieHub, Usercentrics, etc.) — DIY consent banners that just toggle gtag on/off don't qualify.
Wiring it up (gtag, Advanced mode)
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
// 1. Default — DENIED for everything before the user has chosen
gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
wait_for_update: 500, // ms — gives the CMP time to update before pings fire
region: ['BE','BG','CZ','DK','DE','EE','IE','GR','ES','FR','HR','IT','CY','LV','LT',
'LU','HU','MT','NL','AT','PL','PT','RO','SI','SK','FI','SE','IS','LI','NO','GB','CH']
// Outside EEA/UK/CH you can default to 'granted' — not legally required.
});
gtag('js', new Date());
gtag('config', 'AW-XXXXXXXXXX');
</script>
<!-- Later, when the CMP receives consent: -->
<script>
// Called by your CMP's onAccept handler
function onConsentAccepted() {
gtag('consent', 'update', {
ad_storage: 'granted',
ad_user_data: 'granted',
ad_personalization: 'granted',
analytics_storage: 'granted'
});
}
</script>
Server-side consent passthrough (API)
When uploading conversions via the Google Ads API, always include the consent object — Google needs to know whether to use the conversion for personalization vs. measurement-only.
{
// ... rest of conversion
consent: {
ad_user_data: userConsented ? 'GRANTED' : 'DENIED',
ad_personalization: userConsented ? 'GRANTED' : 'DENIED'
}
}
Offline Conversion Imports
Three import paths. Use the right one for your stack.
| Method | Conversion action type | When to use | Gotchas |
|---|---|---|---|
| gclid upload | UPLOAD_CLICKS | You captured gclid at the form submit and want to import the closed deal | Click ID retained for 90 days only |
| Enhanced Conversions for Leads | UPLOAD_CLICKS | You have hashed email/phone but no gclid (or both) | Same 90-day window; preferred over raw gclid since 2024 |
| Google Sheets / SFTP / Data Manager | Either | No-code, low-volume, manual | Lookback only 90 days; less granular error reporting |
Required CSV schema for gclid upload
If you're using the legacy CSV uploader (Tools -> Conversions -> Uploads -> + New upload), the file needs these columns at minimum:
| Column | Example |
|---|---|
Parameters | TimeZone=America/New_York (first row only) |
Google Click ID | EAIaIQobChMI... |
Conversion Name | Sales-Closed (must match the Conversion action name exactly) |
Conversion Time | 2026-04-15 14:32:01 |
Conversion Value | 499 |
Conversion Currency | USD |
The 90-day rule
Google retains gclid for 90 days. Conversions uploaded more than 90 days after the click are silently dropped — they don't error, they just don't show up. If your sales cycle is longer than 90 days, EC for Leads with hashed identifiers (no gclid required) is the only path that works.
GA4 → Google Ads Conversion Import
When to use this path: you already have GA4 instrumented with key events (purchase, generate_lead, sign_up) and don't want to maintain a parallel Google Ads conversion stack. Bidding will work off the same events your analytics team already trusts.
Setup
- Link the accounts. GA4 -> Admin -> Product Links -> Google Ads Links -> Link. Pick the Google Ads account, enable Personalized advertising if you want audiences too. Auto-tagging must be on in Google Ads.
- Mark the GA4 event as a key event. GA4 -> Admin -> Events -> toggle "Mark as key event" on the event you care about.
- Import in Google Ads. Tools -> Conversions -> + New conversion action -> Import -> Google Analytics 4 properties -> select your event(s).
- Wait up to 24h for conversion data to populate.
When to use vs. native conversion tracking
| Use GA4-imported conversions when... | Use native Google Ads conversions when... |
|---|---|
| GA4 is your source of truth for measurement | You need real-time bidding feedback (GA4 import has up to 24h lag) |
| You want consistency between Ads & Analytics reports | You're using Enhanced Conversions for Web/Leads |
| Multiple ad platforms import from GA4 already | The conversion is offline (CRM, phone calls) |
Customer Match
Hashed first-party audiences for targeting, suppression, and lookalikes. Useful for retention campaigns, suppressing existing customers from acquisition, and seeding optimized targeting in Performance Max.
Supported identifier types
| Identifier | Hashing | Format |
|---|---|---|
| SHA-256 | Lowercase + trim, then hash | |
| Phone | SHA-256 | E.164 (+12125551234), then hash |
| Mailing address | SHA-256 | First name, last name, postal code, country — hash names; raw postal/country |
| Mobile device ID (IDFA / GAID) | None — send raw | iOS IDFA (UUID) or Android GAID |
| User-provided ID (CRM ID) | SHA-256 | Your internal user identifier |
List size minimums
Google reduced minimum audience size to 100 active users across Search, Display, and YouTube networks for all segment types (down from 1000 historically). You still typically need a few thousand for meaningful reach.
Upload via API
// Excerpt — google-ads-api OfflineUserDataJobService
const job = await customer.offlineUserDataJobs.create({
type: 'CUSTOMER_MATCH_USER_LIST',
customer_match_user_list_metadata: {
user_list: `customers/${customerId}/userLists/${userListId}`,
consent: { ad_user_data: 'GRANTED', ad_personalization: 'GRANTED' }
}
});
await customer.offlineUserDataJobs.addOperations({
resource_name: job.resource_name,
operations: customers.map(c => ({
create: {
user_identifiers: [
{ hashed_email: sha256(c.email.trim().toLowerCase()) },
c.phone ? { hashed_phone_number: sha256(c.phone) } : null
].filter(Boolean)
}
}))
});
await customer.offlineUserDataJobs.run({ resource_name: job.resource_name });
OfflineUserDataJobService and UserDataService Customer Match uploads will fail for developer tokens that haven't sent a Customer Match request in the previous 180 days. If your developer token is dormant, run a small test upload before this date to keep access. New integrations should plan to migrate to the Data Manager API as Google rolls it out.Server-side Google Tag (sGTM)
sGTM gives you a tagging endpoint on your own domain (e.g. metrics.yoursite.com) that proxies events to Google Ads, GA4, and other vendors. Why it matters in 2026:
- First-party context: cookies set by sGTM are first-party, far less likely to be capped at 7 days by ITP.
- Resilience: ad blockers don't have a generic blocklist for your
metrics.yoursite.comendpoint the way they do forgoogletagmanager.com. - Payload control: you can redact PII before it hits Google, attach server-side data (LTV, plan tier), and mirror the same event to multiple destinations.
Reference architecture (Cloud Run)
Browser Your domain Google
| | |
| --(POST /g/collect)------------------>| |
| [sGTM container |
| on Cloud Run] |
| |---Google Ads conversion --->|
| |---GA4 measurement protocol->|
| |---Meta CAPI / TikTok Events |
Setup outline
- Create a server container in GTM (tagmanager.google.com -> Admin -> + New Container -> Server).
- Deploy on Cloud Run. GTM provides a one-click deploy script — pick a region close to your users, set min instances to ≥3 for production (cold starts add latency).
- Map a custom domain. In Cloud Run, add a custom domain mapping for
metrics.yoursite.com(or a subpath on your main domain). Point a DNS A/AAAA record at the load balancer the integration provisions. Wait for the SSL cert to provision. - Configure GTM. Server container -> Admin -> Container Settings -> add your server URL.
- Point your web container at sGTM. In the web container's Google Tag config -> Configuration settings -> set
transport_urltohttps://metrics.yoursite.com. - Add the Google Ads Conversion Tracking tag in the server container.
// Web container: route Google tag through sGTM
gtag('config', 'AW-XXXXXXXXXX', {
transport_url: 'https://metrics.yoursite.com',
first_party_collection: true
});
metrics.yoursite.com) but a same-origin path (yoursite.com/metrics) gives you slightly more durable cookies. The trade-off: same-origin requires routing through your edge / load balancer, which most teams find more operationally fragile than a subdomain CNAME.Attribution Models in 2026
What you can pick today:
| Model | When to use |
|---|---|
| Data-driven attribution (DDA) | Default for new conversion actions since June 2023. Uses ML on your conversion path data to credit each touch fractionally. Required to use most effectively for Performance Max. |
| Last click | Legal/baseline reporting only, or when you're seeding a brand-new account with too little data for DDA. |
The other four (First click, Linear, Time decay, Position-based) were sunset in October 2023 and converted to DDA automatically.
What "data-driven" actually means
Google trains a model per conversion action on your account's last 30 days of conversion paths. It compares the paths of converters vs. non-converters and assigns fractional credit to each interaction (search click, display impression, YouTube engaged-view) based on uplift. Floor for eligibility used to be 3,000 ad interactions and 300 conversions per 30 days; that bar was effectively removed in 2023, making DDA the default for almost every active conversion action.
Performance Max attribution
PMax campaigns appear in attribution reports the same as other campaign types. Credit is distributed across the channels (Search, Display, YouTube, Shopping, Gmail, Discover) according to the attribution model on the conversion action. DDA is strongly recommended for PMax — last-click systematically under-credits Display/YouTube touches, which then bleeds into Smart Bidding signal quality.
When NOT to trust Google's reported conversions
- Heavy view-through windows on Display/YouTube — view-through conversions in those channels are noisy. Cross-check with GA4 last-touch.
- Performance Max in mature accounts — PMax sometimes claims credit for branded search that would have converted anyway. Run an incrementality test (geo holdout) at least once a year.
- Over-modeled conversions in EEA — if your modeled conversion rate exceeds ~30% of total, your Consent Mode setup is likely under-collecting actual events. Audit before celebrating.
AI Max for Search Campaigns: Conversion Handling
AI Max for Search reached general availability April 15, 2026. It's an optimization layer on existing Search campaigns (search term matching, text customization, final URL expansion) — not a new campaign type. It uses the conversion actions already attached to the campaign.
Two attribution-relevant gotchas:
- Final URL expansion + tracking templates. AI Max can expand to URLs different from your final URL. If you have a custom tracking template (
{lpurl}?utm_source=google&utm_campaign={campaignid}), the expanded URL needs to be reachable too. Mismatched templates cause 404s on the expanded landing pages and drop conversions. - Conversion accuracy is gating. AI Max bidding optimizes against the conversion actions you've marked Primary. Garbage in, garbage out: dedupe, value-track, and verify in Tag Assistant before turning AI Max on for high-spend campaigns.
Existing campaigns using Dynamic Search Ads (DSA), automatically created assets (ACA), or campaign-level broad match are being automatically upgraded to AI Max through September 2026. Audit conversion settings before the upgrade window for those campaigns.
Cross-Device & Cross-Account Tracking
| Feature | What it does | Setup |
|---|---|---|
| Cross-device conversions | Attributes a desktop conversion to a mobile click (or vice versa) when the user is signed into Google on both | Automatic — included in the "Conversions" column. Requires sufficient signed-in user volume in your account. |
| Conversion linker (first-party cookies) | Stores gclid/gbraid/wbraid in _gcl_aw so the click ID survives multi-page navigation | gtag.js: on by default. GTM: add the Conversion Linker tag. |
| Parallel tracking | Sends users directly from ad to final URL, runs click tracking in background | Required for Search, Shopping, Display, Video, and Performance Max since 2018. Your tracking template must support 200ms response. |
Verification & Debugging
Tools
| Tool | Use for |
|---|---|
| Google Tag Assistant (tagassistant.google.com) | Live page-by-page inspection. Shows which tags fired, payloads, errors. |
| GTM Preview/Debug | Step through a session and see tag firing order, variable values, trigger evaluations. |
| Tag Coverage report (GTM) | Site-wide check for pages where Google tag failed to fire. |
| Tag Diagnostics (GTM) | Auto-detected issues by severity. Catches missing user_data fields, malformed hashes, etc. |
| EC for Web Diagnostics (Google Ads) | Per-conversion-action match-rate report and field-level errors for Enhanced Conversions. |
| Browser DevTools -> Network | Filter by google to see /g/collect, /pagead/conversion, etc. — last resort but always available. |
Common failure modes
| Symptom | Likely cause | Fix |
|---|---|---|
| Conversions show but match rate <30% | Wrong field hashing (e.g. trailing whitespace, non-lowercase email) | Re-verify normalization. Test one record manually with printf "user@example.com" | sha256sum. |
| EC for Leads conversions never appear | Conversion action type is WEBPAGE, not UPLOAD_CLICKS | Create a new UPLOAD_CLICKS conversion action. |
gclid not captured server-side | Conversion linker disabled, or tracking template strips it | Re-enable, or read from _gcl_aw cookie which is set by the linker. |
| Modeled conversions = 60%+ in EEA | Consent Mode v2 not implemented, all users effectively "denied" | Wire up a Google-certified CMP, set defaults to denied, update on accept. |
| Double-counted conversions | Same event sent via gtag + API without shared transaction_id / order_id | Use transaction_id (gtag) === order_id (API). Google dedupes on this key. |
| "Unverified" status in Conversions diagnostics | New conversion action created <4–6 hours ago | Wait. Don't trust the first day of data. |
| Attribution flipped from DDA to last-click after migration | Old action archived, new action defaulted to last-click | Edit each new action -> Attribution model -> Data-driven. |
2026: What Changed in the Last 12 Months
- Universal Analytics fully sunset 2024-07-01. GA4 is now the only Google analytics source for Google Ads conversion imports.
- Consent Mode v2 enforcement tightened (July 2025). EEA accounts without correct signals had remarketing, optimized targeting, and Customer Match disabled.
- Privacy Sandbox shut down (October 2025). Topics, Protected Audience (PAAPI), and Attribution Reporting API were retired by Google after low adoption. Third-party cookies in Chrome were not deprecated — Google reversed course in 2024 and again in April 2025. Plan for a 3PC-permitted-but-degraded world, not a cookieless one.
- Customer Match Data Manager migration. As of April 1, 2026, dormant developer tokens (180+ days no Customer Match traffic) lose
OfflineUserDataJobServiceaccess. New integrations should target the Data Manager API. - AI Max for Search GA (April 15, 2026). DSA, ACA, and campaign-level broad match campaigns are auto-upgrading through September 2026 — re-verify conversion accuracy on those campaigns before the upgrade.
- Simplified conversion measurement setup UI rolled out December 2024 across most accounts. The new flow is the same conversion actions under the hood — older runbooks still apply, the screenshots just look different.
Orbit MCP Integration
If your team uses Claude Code, Cursor, or any other MCP client for development, connect the Orbit MCP server to your AI assistant. The MCP exposes:
- This setup guide as a reference document via
get_guide - Live Google Ads diagnostics:
audit_conversion_setup,diagnose_conversion_tracking - Conversion action management:
create_conversion_action,update_conversion_action,get_conversion_tag_snippet - GA4-to-Ads import:
import_ga4_conversion_to_google_ads,create_native_conversion_from_ga4 - GTM container inspection and updates:
list_gtm_tags,create_gtm_tag,diagnose_gtm_container,create_gtm_version_and_publish
Ask your Orbit contact for MCP credentials, or generate them at orbitllm.com/settings/mcp.
Reference Links
Last updated: May 2026 · Maintained by Orbit (orbitllm.com)