Conversion Tracking Debugger Reference
Complete debugging guide for conversion tracking across GTM, GA4, Google Ads, and Meta Pixel. Covers discrepancy diagnosis, BigQuery validation queries, Consent Mode v2, Enhanced Conversions, server-side tagging, and step-by-step troubleshooting flowcharts.
Overview
Conversion tracking breaks silently. Tags fire but data never arrives. Platforms report different numbers for the same event. This guide provides a systematic approach to diagnosing and fixing conversion tracking issues across the full stack: Google Tag Manager, GA4, Google Ads, and Meta Pixel.
The Conversion Tracking Stack
Understanding data flow is the first step to debugging. Here is how the major platforms connect:
User Action (click, form submit, purchase)
|
v
+-------------------+
| Website / App |
| (dataLayer) |
+-------------------+
|
v
+-------------------+ +-------------------+
| Google Tag | ----> | GA4 |
| Manager (GTM) | | (Measurement |
| | | Protocol) |
| - GA4 Config Tag | +-------------------+
| - GA4 Event Tag | |
| - Google Ads Tag | v
| - Meta Pixel Tag | +-------------------+
+-------------------+ | BigQuery Export |
| | (daily/intraday) |
| +-------------------+
|
+----------> +-------------------+
| | Google Ads |
| | (Conversion |
| | Tracking) |
| +-------------------+
|
+----------> +-------------------+
| Meta Pixel |
| + Conversions |
| API (CAPI) |
+-------------------+
Server-Side Tagging (optional):
+-------------------+ +-------------------+
| GTM Web | ----> | GTM Server |
| Container | | Container |
+-------------------+ +-------------------+
|
+-----------+-----------+
| | |
v v v
GA4 Google Ads Meta CAPI
Key data flow points:
- dataLayer push -- The website pushes event data to
window.dataLayer - GTM triggers -- Tags fire based on trigger conditions matching dataLayer events
- Network requests -- Each tag sends a separate HTTP request to its destination
- Platform processing -- Each platform processes, deduplicates, and attributes conversions independently
- BigQuery export -- GA4 exports raw events to BigQuery (daily at ~5 AM property timezone, intraday every few hours)
Common Discrepancies and Why
GA4 vs Google Ads Conversion Count Differences
GA4 and Google Ads will almost never show the same conversion count. This is expected behavior, not a bug.
| Factor | GA4 | Google Ads |
|---|---|---|
| Attribution model | Data-driven (cross-channel) | Data-driven (Google channels only) |
| Counting method | Configurable: once per session or once per event | Configurable: one conversion or every conversion |
| Conversion window | Default 30 days | Default 30 days (configurable up to 90 days) |
| Cross-device | Based on Google Signals + User-ID | Based on Google account sign-in |
| View-through | Not counted by default | Counts view-through for Display/Video (1-day default) |
| Data freshness | 24-72 hour processing lag | Conversions can appear within hours |
| Modeled conversions | Behavioral modeling for consent gaps | Conversion modeling for unobserved conversions |
Typical variance: 5-20% difference is normal. Over 20% warrants investigation.
Meta Pixel vs GA4 Differences
| Factor | Meta | GA4 |
|---|---|---|
| Click-through window | 7 days (default) | 30 days (default) |
| View-through | 1-day view-through included by default | Not counted by default |
| Attribution | Last-touch within Meta ecosystem | Data-driven cross-channel |
| Deduplication | Uses eventID for browser+CAPI dedup | Uses transaction_id parameter |
| Cross-device | Facebook login graph | Google Signals + User-ID |
| Modeled data | Aggregated Event Measurement for iOS 14+ | Consent mode modeling |
Why Meta often reports higher: View-through conversions. A user sees a Meta ad, does not click, later converts via Google search. Meta counts it; GA4 attributes it to Google organic/CPC.
GTM Firing but GA4 Not Receiving
Common causes when GTM shows tags firing but GA4 has no data:
- Consent Mode blocking -- Tag fires but sends no data because consent was denied. Check
analytics_storageconsent state. - Ad blockers -- ~25-30% of users run ad blockers that block
google-analytics.comandgoogletagmanager.comrequests. - Race conditions -- Page unloads before the GA4 request completes. Common on form submits and outbound link clicks.
- Measurement ID mismatch -- Tag configured with wrong
G-XXXXXXXID. - Filter/modification rules -- GA4 property has data filters or event modifications that hide or alter events.
- Debug vs production container -- Testing in GTM Preview mode uses the draft container, but live site uses the published version.
Duplicate Conversions
The most expensive tracking bug. Common causes:
- Missing
transaction_id-- Without a unique transaction ID, GA4 cannot deduplicate purchase events. Every page reload fires another conversion. - Double-firing tags -- Multiple tags for the same conversion event (e.g., both a GA4 event tag and a Google Ads conversion tag firing
purchasewithsend_toincluding GA4). - Thank-you page reloads -- User bookmarks or refreshes the order confirmation page.
- Back button -- User navigates back to the confirmation page from a subsequent page.
- Missing CAPI deduplication -- Meta Pixel and Conversions API both sending the same event without matching
eventID.
Prevention:
// dataLayer push with transaction_id for deduplication
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'purchase',
ecommerce: {
transaction_id: 'T-12345', // REQUIRED for dedup
value: 99.99,
currency: 'USD',
items: [/* ... */]
}
});
// Prevent duplicate dataLayer pushes on page reload
if (!window.sessionStorage.getItem('purchase_tracked_T-12345')) {
window.dataLayer.push({
event: 'purchase',
ecommerce: {
transaction_id: 'T-12345',
value: 99.99,
currency: 'USD'
}
});
window.sessionStorage.setItem('purchase_tracked_T-12345', 'true');
}
Debugging Checklist by Platform
Google Tag Manager (GTM)
1. Preview Mode Inspection
Open GTM Preview mode (https://tagmanager.google.com > Preview) and verify:
- The correct workspace/container version is active
- Navigate to the page where the conversion should fire
- Confirm the trigger event appears in the event timeline (left panel)
- Confirm the tag shows as "Fired" (not "Not Fired")
- Check the "Tags" tab for any tags that fired unexpectedly (duplicates)
2. Trigger Conditions
For each conversion tag, verify the trigger:
- Trigger type matches the event (Custom Event, Page View, DOM Ready, etc.)
- Event name in trigger matches
dataLayer.push({ event: '...' })exactly (case-sensitive) - All trigger conditions/filters are correct (URL contains, variable equals, etc.)
- No blocking triggers are preventing the tag from firing
3. Variable Values
In Preview mode, click on the event in the timeline and check:
- Data Layer variables resolve to expected values
-
transaction_idis populated and unique per transaction -
valueandcurrencyare present and correct types (number, not string) - Custom JavaScript variables return expected values (check for errors)
4. dataLayer Inspection
Open browser console and inspect the dataLayer directly:
// View full dataLayer contents
console.table(window.dataLayer);
// Filter for specific events
window.dataLayer.filter(e => e.event === 'purchase');
// Check ecommerce data
window.dataLayer
.filter(e => e.event === 'purchase')
.map(e => e.ecommerce);
5. Network Request Verification
In Chrome DevTools Network tab, filter for:
google-analytics.com/g/collect-- GA4 requestsgoogleads.g.doubleclick.net/pagead/conversion-- Google Ads conversion requestsfacebook.com/tr-- Meta Pixel requests
Verify the request payload contains the expected parameters.
GA4
1. DebugView
Enable DebugView for real-time event inspection:
// Enable debug mode via gtag.js
gtag('config', 'G-XXXXXXX', { debug_mode: true });
Or install the GA Debugger Chrome extension. Then check GA4 > Admin > DebugView:
- Events appear in the event stream
- Event parameters are present and correct
- User properties are populated
- No error events visible
2. Realtime Reports
GA4 > Reports > Realtime:
- Event count is incrementing when you trigger the conversion
- Conversions card shows the event (if marked as conversion)
- User location and device match your test setup
3. BigQuery Raw Event Validation
The definitive source of truth. Query the raw export:
-- Check if conversion events are landing in BigQuery
SELECT
event_name,
COUNT(*) as event_count,
COUNT(DISTINCT user_pseudo_id) as unique_users,
MIN(TIMESTAMP_MICROS(event_timestamp)) as earliest,
MAX(TIMESTAMP_MICROS(event_timestamp)) as latest
FROM `project.analytics_PROPERTY_ID.events_*`
WHERE _TABLE_SUFFIX BETWEEN
FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 3 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE())
AND event_name IN ('purchase', 'generate_lead', 'sign_up', 'add_to_cart', 'begin_checkout')
GROUP BY event_name
ORDER BY event_count DESC
4. Conversion Marking
GA4 > Admin > Events > Mark as conversion:
- The event name is marked as a conversion
- The counting method is correct (once per event vs. once per session)
- The event is actually being received (check Events report)
Google Ads
1. Conversion Action Status
Google Ads > Goals > Conversions > Summary:
- Status shows "Recording conversions" (green badge)
- "No recent conversions" means the tag is installed but not firing or data is delayed
- "Tag inactive" means the tag has not received any hits in 7+ days
- "Unverified" means the tag was never detected on the website
2. Tag Diagnostics
Google Ads > Goals > Conversions > select action > Diagnostics:
- Tag is detected on the correct pages
- No "tag not found" warnings
- Enhanced conversions status (if enabled)
- Consent mode status
3. Google Tag Setup
Verify the Google tag configuration:
// Check if Google Ads conversion tracking is loading
// In browser console:
typeof gtag === 'function' // Should return true
// Verify conversion linker is present
// Look for _gcl_aw cookie:
document.cookie.split(';').filter(c => c.includes('_gcl'))
4. Enhanced Conversions Verification
// Check enhanced conversion data is being sent
// In GTM Preview mode, look for the Google Ads Conversion tag
// and verify the "User-provided data" section shows:
// - Email (hashed)
// - Phone (hashed)
// - Name (first + last)
// - Address (street, city, region, postal, country)
Meta Pixel
1. Events Manager Test Events
Meta Events Manager > Data Sources > Your Pixel > Test Events:
- Enter your website URL and click "Open Website"
- Perform the conversion action
- Check that the event appears in the test events log
- Verify event parameters (value, currency, content_ids, etc.)
- Check the "Event Match Quality" score (aim for 6+)
2. Pixel Helper Browser Extension
Install Meta Pixel Helper:
- Green icon = pixel detected and firing
- Check each event has correct parameters
- Look for warnings (missing parameters, invalid values)
- Verify
eventIDis present for CAPI deduplication
3. Conversions API (CAPI) Event Deduplication
When using both browser Pixel and server-side CAPI, both events must share:
- Same
eventIDvalue (orevent_idin CAPI) - Same
event_name - Events sent within 48 hours of each other
// Browser-side: include eventID in fbq call
fbq('track', 'Purchase', {
value: 99.99,
currency: 'USD',
content_ids: ['SKU-123'],
content_type: 'product'
}, { eventID: 'purchase_T-12345' });
# Server-side CAPI call (curl example)
curl -X POST \
"https://graph.facebook.com/v18.0/PIXEL_ID/events?access_token=TOKEN" \
-H "Content-Type: application/json" \
-d '{
"data": [{
"event_name": "Purchase",
"event_time": 1700000000,
"event_id": "purchase_T-12345",
"action_source": "website",
"user_data": {
"em": ["HASHED_EMAIL"],
"ph": ["HASHED_PHONE"],
"client_ip_address": "1.2.3.4",
"client_user_agent": "Mozilla/5.0..."
},
"custom_data": {
"value": 99.99,
"currency": "USD",
"content_ids": ["SKU-123"],
"content_type": "product"
}
}]
}'
4. Event Match Quality
Meta Events Manager > Data Sources > Your Pixel > Overview > Event Match Quality:
- Score of 6.0 or higher recommended
- Check which parameters are missing (email, phone, fbp, fbc, external_id)
- Higher match quality = better attribution = lower CPAs
BigQuery Validation Queries
1. Count Conversions by Day and Compare to GA4 UI
-- Compare this output against GA4 > Reports > Engagement > Conversions
SELECT
PARSE_DATE('%Y%m%d', event_date) as date,
event_name,
COUNT(*) as total_events,
COUNT(DISTINCT user_pseudo_id) as unique_users,
COUNT(DISTINCT
CONCAT(user_pseudo_id, '-',
(SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id'))
) as unique_sessions
FROM `project.analytics_PROPERTY_ID.events_*`
WHERE _TABLE_SUFFIX BETWEEN
FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
AND event_name IN ('purchase', 'generate_lead', 'sign_up')
GROUP BY 1, 2
ORDER BY 1 DESC, 2
Note: Small differences (1-5%) between BigQuery and GA4 UI are normal due to:
- Thresholding in the GA4 UI (data suppressed for small user counts)
- Sampling in the GA4 UI for large datasets
- Consent mode modeled conversions (present in UI, absent in BigQuery)
2. Find Duplicate Transaction IDs
-- Find transactions that appear more than once (duplicate conversions)
WITH purchase_events AS (
SELECT
PARSE_DATE('%Y%m%d', event_date) as date,
user_pseudo_id,
(SELECT value.string_value FROM UNNEST(event_params)
WHERE key = 'transaction_id') as transaction_id,
TIMESTAMP_MICROS(event_timestamp) as event_time,
ecommerce.purchase_revenue_in_usd as revenue
FROM `project.analytics_PROPERTY_ID.events_*`
WHERE _TABLE_SUFFIX BETWEEN
FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
AND event_name = 'purchase'
)
SELECT
transaction_id,
COUNT(*) as occurrence_count,
COUNT(DISTINCT user_pseudo_id) as distinct_users,
MIN(event_time) as first_occurrence,
MAX(event_time) as last_occurrence,
TIMESTAMP_DIFF(MAX(event_time), MIN(event_time), SECOND) as seconds_between,
SUM(revenue) as total_revenue_recorded,
MAX(revenue) as actual_revenue
FROM purchase_events
WHERE transaction_id IS NOT NULL
GROUP BY transaction_id
HAVING COUNT(*) > 1
ORDER BY occurrence_count DESC
-- Find purchase events with NULL transaction_id (cannot be deduplicated)
SELECT
PARSE_DATE('%Y%m%d', event_date) as date,
COUNT(*) as purchases_without_txn_id,
SUM(ecommerce.purchase_revenue_in_usd) as revenue_at_risk
FROM `project.analytics_PROPERTY_ID.events_*`
WHERE _TABLE_SUFFIX BETWEEN
FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
AND event_name = 'purchase'
AND (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'transaction_id') IS NULL
GROUP BY 1
ORDER BY 1 DESC
3. Check Conversion Event Parameters Completeness
-- Audit which parameters are present on conversion events
SELECT
event_name,
param.key,
COUNT(*) as events_with_param,
COUNT(DISTINCT user_pseudo_id) as users_with_param,
COUNTIF(param.value.string_value IS NOT NULL) as has_string_value,
COUNTIF(param.value.int_value IS NOT NULL) as has_int_value,
COUNTIF(param.value.float_value IS NOT NULL) as has_float_value,
COUNTIF(param.value.double_value IS NOT NULL) as has_double_value
FROM `project.analytics_PROPERTY_ID.events_*`,
UNNEST(event_params) as param
WHERE _TABLE_SUFFIX BETWEEN
FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
AND event_name IN ('purchase', 'generate_lead', 'sign_up', 'begin_checkout')
GROUP BY 1, 2
ORDER BY 1, events_with_param DESC
-- Check for required ecommerce fields on purchase events
SELECT
PARSE_DATE('%Y%m%d', event_date) as date,
COUNT(*) as total_purchases,
COUNTIF(ecommerce.transaction_id IS NOT NULL) as has_transaction_id,
COUNTIF(ecommerce.purchase_revenue_in_usd > 0) as has_revenue,
COUNTIF(ecommerce.total_item_quantity > 0) as has_quantity,
COUNTIF(ARRAY_LENGTH(items) > 0) as has_items,
COUNTIF((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'currency') IS NOT NULL) as has_currency,
-- Completeness percentage
ROUND(SAFE_DIVIDE(
COUNTIF(
ecommerce.transaction_id IS NOT NULL
AND ecommerce.purchase_revenue_in_usd > 0
AND ARRAY_LENGTH(items) > 0
),
COUNT(*)
) * 100, 1) as pct_fully_complete
FROM `project.analytics_PROPERTY_ID.events_*`
WHERE _TABLE_SUFFIX BETWEEN
FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
AND event_name = 'purchase'
GROUP BY 1
ORDER BY 1 DESC
4. Validate Enhanced Conversions Data
-- Check for enhanced conversion signals in user properties
-- Enhanced conversions data appears as hashed user data
SELECT
PARSE_DATE('%Y%m%d', event_date) as date,
event_name,
COUNT(*) as total_events,
-- Check for user-provided data signals
COUNTIF(user_id IS NOT NULL) as has_user_id,
COUNTIF(
(SELECT value.string_value FROM UNNEST(user_properties) WHERE key = 'email') IS NOT NULL
OR (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'user_data_email') IS NOT NULL
) as has_email_signal,
COUNTIF(
(SELECT value.string_value FROM UNNEST(user_properties) WHERE key = 'phone') IS NOT NULL
OR (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'user_data_phone') IS NOT NULL
) as has_phone_signal
FROM `project.analytics_PROPERTY_ID.events_*`
WHERE _TABLE_SUFFIX BETWEEN
FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
AND event_name IN ('purchase', 'generate_lead', 'sign_up')
GROUP BY 1, 2
ORDER BY 1 DESC, 2
5. Cross-Reference GA4 Events with Expected GTM Triggers
-- Compare expected conversion events against actual received events
-- Use this to find events that GTM should send but GA4 never receives
WITH expected_events AS (
SELECT event_name FROM UNNEST([
'purchase', 'add_to_cart', 'begin_checkout', 'view_item',
'generate_lead', 'sign_up', 'add_payment_info', 'add_shipping_info'
]) as event_name
),
actual_events AS (
SELECT
event_name,
COUNT(*) as event_count,
MAX(TIMESTAMP_MICROS(event_timestamp)) as last_seen
FROM `project.analytics_PROPERTY_ID.events_*`
WHERE _TABLE_SUFFIX BETWEEN
FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
GROUP BY event_name
)
SELECT
e.event_name,
COALESCE(a.event_count, 0) as event_count,
a.last_seen,
CASE
WHEN a.event_count IS NULL THEN 'MISSING - never received'
WHEN a.last_seen < TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 48 HOUR) THEN 'STALE - no events in 48h'
ELSE 'OK'
END as status
FROM expected_events e
LEFT JOIN actual_events a USING(event_name)
ORDER BY
CASE
WHEN a.event_count IS NULL THEN 0
WHEN a.last_seen < TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 48 HOUR) THEN 1
ELSE 2
END,
e.event_name
Consent Mode v2
How Consent Mode Affects Conversion Tracking
Google Consent Mode v2 (required in the EEA since March 2024) controls whether tags send full data, limited data, or no data based on user consent.
Two consent signals:
| Signal | Controls | Required |
|---|---|---|
analytics_storage | GA4 cookies and full measurement | Yes (for GA4) |
ad_storage | Google Ads cookies, remarketing, conversion measurement | Yes (for Google Ads) |
Additional signals (Consent Mode v2):
| Signal | Controls | Required |
|---|---|---|
ad_user_data | Sending user data to Google for advertising | Yes (EEA, required for Enhanced Conversions) |
ad_personalization | Remarketing and personalized advertising | Yes (EEA) |
Consent Mode Behavior
| Consent State | What Happens |
|---|---|
granted | Full data collection, cookies set, user-level data sent |
denied | Cookieless pings sent (no user identifiers), Google models conversions |
| Not set | Treated as granted (but risks compliance issues) |
Modeled conversions: When consent is denied, Google estimates conversions using machine learning based on observed patterns from consented users. Modeled conversions appear in the GA4 UI and Google Ads but do NOT appear in BigQuery exports.
Implementation Checklist
// 1. Set default consent state BEFORE loading GTM
// Place this in <head> before the GTM snippet
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
// Default: deny all (adjust based on your legal requirements)
gtag('consent', 'default', {
analytics_storage: 'denied',
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
wait_for_update: 500 // Wait 500ms for CMP to load
});
// 2. Update consent when user makes a choice (called by your CMP)
function updateConsent(consentChoices) {
gtag('consent', 'update', {
analytics_storage: consentChoices.analytics ? 'granted' : 'denied',
ad_storage: consentChoices.marketing ? 'granted' : 'denied',
ad_user_data: consentChoices.marketing ? 'granted' : 'denied',
ad_personalization: consentChoices.marketing ? 'granted' : 'denied'
});
}
// 3. Verify consent state in browser console
// Check current consent state
dataLayer.filter(e => e[0] === 'consent');
GTM Consent Mode setup:
- Consent Mode default is set BEFORE GTM snippet loads
- All four v2 signals are configured (
analytics_storage,ad_storage,ad_user_data,ad_personalization) - CMP (Cookie Consent Platform) calls
gtag('consent', 'update', ...)on user choice - GTM tags have correct consent settings (Built-in consent checks enabled)
-
wait_for_updateis set (500ms recommended) to give CMP time to initialize - Consent state persists across pages (CMP handles cookie storage)
- Test with consent denied: verify cookieless pings are sent (Network tab:
gcs=G100parameter) - Test with consent granted: verify full measurement resumes
BigQuery: Identify Consent Mode Impact
-- Check consent-related signals in your data
-- Events from users who denied consent will have limited parameters
SELECT
PARSE_DATE('%Y%m%d', event_date) as date,
COUNTIF(user_pseudo_id IS NOT NULL) as events_with_user_id,
COUNTIF(user_pseudo_id IS NULL) as events_without_user_id,
COUNTIF(
(SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id') IS NOT NULL
) as events_with_session,
COUNTIF(
(SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id') IS NULL
) as events_without_session,
-- Approximate consent denial rate
ROUND(SAFE_DIVIDE(
COUNTIF((SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id') IS NULL),
COUNT(*)
) * 100, 1) as pct_likely_consent_denied
FROM `project.analytics_PROPERTY_ID.events_*`
WHERE _TABLE_SUFFIX BETWEEN
FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
GROUP BY 1
ORDER BY 1 DESC
Enhanced Conversions
What Data Is Sent
Enhanced Conversions improve conversion measurement by sending hashed first-party customer data alongside your conversion tags. Google matches this data against signed-in Google accounts.
Supported data fields:
| Field | Format | Required |
|---|---|---|
| SHA-256 hashed, lowercase, trimmed | At least one of: email, phone, or name+address | |
| Phone | SHA-256 hashed, E.164 format (+1234567890) | Optional but recommended |
| First name | SHA-256 hashed, lowercase, trimmed | Optional |
| Last name | SHA-256 hashed, lowercase, trimmed | Optional |
| Street address | SHA-256 hashed, lowercase, trimmed | Optional |
| City | SHA-256 hashed, lowercase, trimmed | Optional |
| Region/State | SHA-256 hashed, lowercase, trimmed | Optional |
| Postal code | SHA-256 hashed, trimmed | Optional |
| Country | ISO 3166-1 alpha-2, unhashed | Optional |
How Hashing Works
Google auto-hashes in most implementations, but if you hash manually:
// SHA-256 hashing example (browser)
async function hashValue(value) {
const encoder = new TextEncoder();
// Normalize: lowercase, trim whitespace
const normalized = value.toLowerCase().trim();
const data = encoder.encode(normalized);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
// Usage
const hashedEmail = await hashValue('User@Example.com');
// Result: consistent SHA-256 hash of "user@example.com"
Setup via GTM
Option A: Automatic collection (recommended)
- In the Google Ads Conversion tag, enable "Include user-provided data from your website"
- Select "Automatic" -- GTM will scan the page for email/phone/address fields
- GTM uses CSS selectors and
autocompleteattributes to identify fields
Option B: Manual configuration via dataLayer
// Push user data to dataLayer (GTM will auto-hash)
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'purchase',
user_data: {
email: 'user@example.com', // Will be hashed by GTM
phone_number: '+11234567890', // Will be hashed by GTM
address: {
first_name: 'John',
last_name: 'Doe',
street: '123 Main St',
city: 'New York',
region: 'NY',
postal_code: '10001',
country: 'US'
}
},
ecommerce: {
transaction_id: 'T-12345',
value: 99.99,
currency: 'USD'
}
});
Option C: Code-based (gtag.js direct)
gtag('set', 'user_data', {
email: 'user@example.com',
phone_number: '+11234567890',
address: {
first_name: 'John',
last_name: 'Doe',
street: '123 Main St',
city: 'New York',
region: 'NY',
postal_code: '10001',
country: 'US'
}
});
Validation
- Google Ads > Goals > Conversions > select action > Diagnostics > Enhanced Conversions
- Status should show "Recording" or "Active"
- Match rate visible after sufficient data (48-72 hours)
- GTM Preview mode: check the conversion tag's "User-provided data" tab
- Network request: look for
em=(hashed email) in the conversion pixel request
Server-Side Tagging
Benefits for Conversion Accuracy
Server-side tagging moves tag execution from the user's browser to a server you control. Key benefits:
- Ad blocker resistance -- Requests go to your first-party domain, not blocked
google-analytics.com - Longer cookie lifetime -- First-party cookies set server-side last 2+ years vs. 7 days (ITP)
- Reduced page weight -- Fewer scripts in the browser = faster page load
- Data control -- Inspect and modify data before sending to vendors
- Better consent enforcement -- Single point to enforce consent rules server-side
GTM Server Container Setup Overview
Browser Your Infrastructure Vendor Endpoints
+----------------+ +--------------------+ +----------------+
| GTM Web | -----> | GTM Server | -------> | GA4 |
| Container | HTTPS | Container | +----------------+
| | | (Cloud Run / GCE) | -------> | Google Ads |
| Sends GA4 | | | +----------------+
| measurement | | Tags: | -------> | Meta CAPI |
| protocol hit | | - GA4 tag | +----------------+
+----------------+ | - Google Ads tag |
| - Meta CAPI tag |
+--------------------+
|
v
First-party domain:
gtm.yourdomain.com
Key setup steps:
- Create server container in GTM (Server-side container type)
- Deploy to Cloud Run (recommended) or App Engine
- Configure first-party domain -- Map
gtm.yourdomain.comto your server container - Update web container -- Change the GA4 Config tag's
transport_urlto your server domain - Add server-side tags -- GA4, Google Ads Conversion, Meta CAPI
- Configure server-side consent -- Mirror browser consent decisions
First-Party Domain Setup
// In your GTM web container's GA4 Config tag:
// Set the server container URL as the transport URL
gtag('config', 'G-XXXXXXX', {
server_container_url: 'https://gtm.yourdomain.com'
});
DNS setup:
# Add CNAME or A record pointing to your Cloud Run service
gtm.yourdomain.com CNAME your-cloud-run-service-xxxxxxxxx.run.app
Troubleshooting Flowcharts
Flowchart 1: Conversions Not Showing Up
START: Conversion not appearing in [Platform]
|
v
Is the tag installed on the page?
|
+-- NO --> Install the tag. Check GTM container is published.
| Verify GTM snippet is in <head> on all pages.
|
+-- YES
|
v
Does the tag fire in GTM Preview mode?
|
+-- NO --> Check trigger configuration:
| - Event name matches exactly (case-sensitive)?
| - Trigger conditions met (URL, variable values)?
| - Blocking triggers preventing fire?
| - dataLayer.push() happening before tag evaluation?
|
+-- YES
|
v
Do you see the network request in DevTools?
|
+-- NO --> Consent Mode blocking the request?
| - Check: analytics_storage / ad_storage state
| - Check: ad blocker active?
| - Check: CSP (Content Security Policy) blocking?
|
+-- YES
|
v
Is the request returning 200/204 (success)?
|
+-- NO --> Check endpoint URL, measurement ID, API credentials.
| For Meta CAPI: check access token and pixel ID.
|
+-- YES
|
v
Check platform-specific processing:
- GA4: Wait 24-48h. Check DebugView for real-time.
Check data filters, event modification rules.
- Google Ads: Wait 24h. Check conversion action status.
Verify conversion is included in "Conversions" column.
- Meta: Check Events Manager > Test Events.
Verify event match quality. Check CAPI dedup.
Flowchart 2: Conversion Count Mismatch
START: Platform A shows X conversions, Platform B shows Y
|
v
Is the difference > 20%?
|
+-- NO --> Likely normal. Different attribution models,
| counting methods, and conversion windows
| will always cause some variance. Document
| the baseline variance for future comparison.
|
+-- YES
|
v
Which platform shows MORE conversions?
|
+-- Google Ads > GA4:
| - Google Ads counting "every" conversion vs GA4 "once per session"?
| - Google Ads view-through conversions included (Display/Video)?
| - Google Ads longer conversion window (90d vs 30d)?
| - Google Ads modeled conversions from Consent Mode?
|
+-- GA4 > Google Ads:
| - GA4 counting all channels, Google Ads only Google-attributed?
| - Check: is the conversion action set to "Primary" in Google Ads?
| - Google Ads conversion tag not firing on some pages?
|
+-- Meta > GA4:
| - Meta counting view-through conversions (1-day window)?
| - Meta 7-day click window overlapping with GA4 30-day window?
| - Meta Aggregated Event Measurement modeling for iOS users?
|
+-- GA4 > Meta:
- Meta Pixel blocked by ad blockers more than GA4?
- CAPI not configured (missing server-side events)?
- Meta event match quality too low (< 5)?
- iOS 14+ App Tracking Transparency limiting Meta data?
|
v
Run BigQuery validation queries (above) to get ground truth.
Compare raw event counts against both platforms.
Flowchart 3: Conversion Value Wrong
START: Conversion value incorrect in [Platform]
|
v
Is the value always 0 or NULL?
|
+-- YES --> Check dataLayer push:
| - Is 'value' property present?
| - Is 'currency' property present? (Required for Google Ads)
| - Is value a number (not a string)?
| - GTM variable mapping: value variable resolving correctly?
|
+-- NO
|
v
Is the value consistently wrong (e.g., always 1.00)?
|
+-- YES --> Check GTM tag configuration:
| - Is the value field mapped to the correct variable?
| - Is a default/static value overriding the dynamic value?
| - Currency conversion: sending local currency but
| platform expecting USD?
|
+-- NO (value varies but is incorrect)
|
v
Is the value doubled or multiplied?
|
+-- YES --> Duplicate conversion events (see duplicate section above).
| Multiple tags sending the same conversion.
| dataLayer.push firing multiple times.
|
+-- NO
|
v
Check data type issues:
- Value including tax when it shouldn't (or vice versa)?
- Value including shipping?
- Currency mismatch (sending cents vs dollars)?
- String formatting ("$99.99" vs 99.99)?
- Locale issues ("99,99" vs "99.99")?
|
v
Validate in BigQuery:
SELECT
ecommerce.transaction_id,
ecommerce.purchase_revenue_in_usd,
ecommerce.purchase_revenue,
ecommerce.tax_value_in_usd,
ecommerce.shipping_value_in_usd
FROM events_*
WHERE event_name = 'purchase'
ORDER BY event_timestamp DESC
LIMIT 20
Quick Reference: dataLayer Push Examples
Standard E-Commerce Purchase
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ ecommerce: null }); // Clear previous ecommerce data
window.dataLayer.push({
event: 'purchase',
ecommerce: {
transaction_id: 'T-12345',
value: 99.99,
tax: 8.50,
shipping: 5.99,
currency: 'USD',
coupon: 'SUMMER2025',
items: [{
item_id: 'SKU-001',
item_name: 'Product Name',
item_brand: 'Brand',
item_category: 'Category',
item_variant: 'Blue',
price: 99.99,
quantity: 1,
coupon: 'ITEM10OFF'
}]
}
});
Lead Generation
window.dataLayer.push({
event: 'generate_lead',
value: 50.00,
currency: 'USD',
lead_source: 'contact_form',
form_id: 'demo_request'
});
gtag.js Conversion Tracking (No GTM)
// Google Ads conversion
gtag('event', 'conversion', {
send_to: 'AW-XXXXXXXXX/CONVERSION_LABEL',
value: 99.99,
currency: 'USD',
transaction_id: 'T-12345'
});
// GA4 purchase event
gtag('event', 'purchase', {
transaction_id: 'T-12345',
value: 99.99,
currency: 'USD',
items: [{ item_id: 'SKU-001', item_name: 'Product', price: 99.99, quantity: 1 }]
});
Claude Code Skill
This debugger reference is also available as a free Claude Code skill -- use it directly in your terminal with live GTM, GA4, Google Ads, and Meta API access:
# Install
curl -sSL https://raw.githubusercontent.com/cognyai/claude-code-marketing-skills/main/install.sh | bash
# Use
/conversion-debug # Full debugging overview
/conversion-debug duplicate conversions # Diagnose duplicate purchases
/conversion-debug consent mode # Consent Mode v2 audit
/conversion-debug meta vs ga4 # Cross-platform discrepancy analysis
Resources
- GA4 Conversion Setup: support.google.com/analytics/answer/9267568
- Google Ads Conversion Tracking: support.google.com/google-ads/answer/6095821
- Enhanced Conversions: support.google.com/google-ads/answer/9888656
- Consent Mode: developers.google.com/tag-platform/security/guides/consent
- Meta Conversions API: developers.facebook.com/docs/marketing-api/conversions-api
- Meta Pixel Helper: developers.facebook.com/docs/meta-pixel/support/pixel-helper
- GTM Server-Side Tagging: developers.google.com/tag-platform/tag-manager/server-side
- Claude Code Marketing Skills: github.com/cognyai/claude-code-marketing-skills