Implementation Analytics: Pushing First-Party CRM Data into GA4 Custom Dimensions via GTM

This lesson explains the integration of HubSpot contact data with Google Analytics 4 (GA4) using Google Tag Manager (GTM). It covers the steps to push HubSpot data into the dataLayer, register custom dimensions in GA4, and pass values through GTM tags for better tracking and reporting of user context and behaviours.

What This Lesson Covers

GA4’s standard dimensions tell you what happened which page, which device, which country. Custom dimensions tell you who it happened to and what context they were in their lifecycle stage, subscription tier, lead score, industry. That data lives in HubSpot (or any CRM), not in GA4. This lesson covers the complete pipeline: getting HubSpot contact data into the dataLayer, reading it in GTM, registering custom dimensions in GA4, and forwarding values through the GA4 tag.

Conceptual Architecture

HubSpot Contact Record (lifecycle stage, lead score, company size, industry) | | (server-side render or JS identify callback) v window.dataLayer.push({…}) | | GTM reads Data Layer Variables v GTM GA4 Event Tag (event parameters → GA4 custom dimensions) | v GA4 Reports / Explorations / Audiences

The dataLayer is the only handshake point between your CRM and GTM. Everything else which GA4 dimension receives the value, which events carry it is configured inside GTM and GA4 Admin.

Step 1: Register Custom Dimensions in GA4 Admin

Before GTM can send a custom dimension value, GA4 must know the dimension exists. Unregistered parameters are collected but not processed into dimensions.

Path: GA4 > Admin > Data display > Custom definitions > Create custom dimensions

FieldWhat to Enter
Dimension nameHuman-readable label, e.g. “Lifecycle Stage”
ScopeEvent, User, or Item (see below)
Parameter nameThe exact key you will pass in the event, e.g. lifecycle_stage
DescriptionOptional but useful for team documentation

Choosing the Right Scope

1. Event:

Only on the hit it is sent with. Page-level context, content type, campaign variant

2. User:

Across all future sessions for that user. CRM segment, subscription tier, lead score bucket, industry

3. Item: 

On individual ecommerce items. Product category, brand, availability status.

Rule of thumb for CRM data: If the value describes the person (who they are in your CRM), use User scope. If it describes what they did on this visit, use Event scope.

GA4 limits: 50 event-scoped25 user-scoped10 item-scoped custom dimensions per property.

Step 2: Push HubSpot Data into the dataLayer

Pattern A: Server-Side Render (Recommended)

If your site is server-rendered and you can query HubSpot’s Contacts API at page load time (using the visitor’s HubSpot cookie as the lookup key), push data before the GTM snippet fires.

<!-- Rendered by your server BEFORE the GTM snippet -->
<script>
window.dataLayer = window.dataLayer || [];
dataLayer.push({
"event": "crmDataReady",
"user": {
"lifecycleStage": "opportunity",
"leadScore": "hot", // bucketed: cold / warm / hot
"industry": "SaaS",
"companySize": "51-200", // bucketed range, not raw headcount
"subscriptionTier": "pro",
"hsContactId": "hs_c_8a3f92k" // internal ID, not email
}
});
</script>
<!-- GTM snippet immediately below -->
<script>(function(w,d,s,l,i){...})(window,document,'script','dataLayer','GTM-XXXXXX');</script>

Pattern B: HubSpot Form Submission Callback

When a visitor submits a HubSpot-embedded form, HubSpot fires a JavaScript callback you can intercept. Push contact data at that moment.

// Place this in GTM as a Custom HTML tag, firing on All Pages
window.addEventListener('message', function(event) {
// HubSpot posts a message from its form iframe on submission
if (event.data.type === 'hsFormCallback' &&
event.data.eventName === 'onFormSubmit') {
var formData = event.data.data; // array of {name, value} field objects
// Extract specific fields from the form submission
var fields = {};
formData.forEach(function(field) {
fields[field.name] = field.value;
});
window.dataLayer = window.dataLayer || [];
dataLayer.push({
event: 'hsFormSubmit',
formId: event.data.id,
user: {
lifecycleStage: 'lead', // known from the form action
industry: fields['industry'] || '',
companySize: fields['numemployees'] || '',
jobRole: fields['jobtitle'] || ''
}
});
}
});

Pattern C: HubSpot Identify API (Known Returning User)

When a user logs in or you can identify them client-side, use HubSpot’s tracking API to both identify the contact in HubSpot and push their properties to the dataLayer simultaneously.

// Fires after your authentication system confirms the user's identity
function onUserIdentified(user) {
// 1. Tell HubSpot who this is
var _hsq = window._hsq = window._hsq || [];
_hsq.push(['identify', {
email: user.email,
id: user.crmId
}]);
// 2. Push CRM properties to dataLayer for GTM
window.dataLayer = window.dataLayer || [];
dataLayer.push({
event: 'userIdentified',
user: {
lifecycleStage: user.crmData.lifecycleStage,
subscriptionTier: user.crmData.tier,
leadScore: bucketScore(user.crmData.score),
industry: user.crmData.industry
}
});
}
function bucketScore(score) {
if (score >= 80) return 'hot';
if (score >= 40) return 'warm';
return 'cold';
}

Step 3: Create GTM Data Layer Variables

In GTM, create one Data Layer Variable for each CRM property you pushed.

GTM path: Variables > New > Data Layer Variable

GTM Variable NameData Layer Key PathDefault Value
DLV - Lifecycle Stageuser.lifecycleStageunknown
DLV - Subscription Tieruser.subscriptionTiernone
DLV - Lead Score Bucketuser.leadScorecold
DLV - Industryuser.industry(not set)
DLV - Company Sizeuser.companySize(not set)

Always set a default value. If the variable is empty when GA4 receives the event, GA4 records a blank value which then pollutes the dimension’s row with empty entries in reports.

Step 4: Pass Variables to GA4 via the GTM Tag

Option A: GA4 Configuration Tag (User-Scoped Dimensions)

User-scoped dimensions should be sent on every page so GA4 associates them with the user profile. Set them in the GA4 Configuration tag.

In GTM, open your Google Analytics: GA4 Configuration tag:

Tag: GA4 Configuration Measurement ID: G-XXXXXXXXXX

Fields to Set: user_id → {{DLV – HS Contact ID}}

User Properties: lifecycle_stage → {{DLV – Lifecycle Stage}} subscription_tier → {{DLV – Subscription Tier}} lead_score → {{DLV – Lead Score Bucket}} industry → {{DLV – Industry}}

The parameter names under “User Properties” must exactly match the parameter names you registered in GA4 Admin (Step 1). GA4 is case-sensitive.

Option B: GA4 Event Tag (Event-Scoped Dimensions)

For event-scoped dimensions that only apply to specific events (e.g., form submissions), create a dedicated GA4 Event tag.

Tag: GA4 Event – HS Form Submit Event Name: generate_lead

Event Parameters: lifecycle_stage → {{DLV – Lifecycle Stage}} form_id → {{DLV – Form ID}} industry → {{DLV – Industry}} company_size → {{DLV – Company Size}}

Triggering: Custom Event – hsFormSubmit

The complete GTM tag configuration in code terms is equivalent to calling:

// What GTM's GA4 Event tag sends to GA4
gtag('event', 'generate_lead', {
lifecycle_stage: 'opportunity',
form_id: 'abc123',
industry: 'SaaS',
company_size: '51-200'
});

Step 5: Handle the Timing Problem

The most common failure mode: the GTM GA4 Configuration tag fires before the dataLayer.push containing CRM data, so GA4 receives the event with empty custom dimensions.

Solution A: Trigger Sequencing

In the GA4 Configuration tag, set the trigger to the CRM-specific event rather than All Pages:

Trigger: Custom Event Event Name: crmDataReady ← matches the “event” key in your dataLayer push

This ensures the tag only fires after CRM data is available in the dataLayer.

Solution B: GTM Tag Sequencing

If the GA4 Configuration tag must fire on All Pages (e.g., for non-CRM visitors too), use GTM’s tag sequencing to fire a “set user properties” tag afterward:

Tag: GA4 Event – Set CRM User Properties Event Name: (none — use set_user_properties or just update config)

Setup tag for: GA4 Configuration Fire this tag after the setup tag has completed. Triggering: Custom Event – crmDataReady

Solution C: Merge dataLayer Pushes Explicitly

Use GTM’s dataLayer merge behaviour. The dataLayer retains all pushed keys for the session. A later pageview event tag can still read values pushed earlier:

// Push 1 — fires at page load (may have CRM data or not)
dataLayer.push({ event: 'pageview', page: { type: 'article' } });
// Push 2 — fires 200ms later after CRM lookup resolves
dataLayer.push({ event: 'crmDataReady', user: { lifecycleStage: 'opportunity' } });

GTM retains both pushes. A tag triggered by crmDataReady can read user.lifecycleStage from push 2 and page.type from push 1 simultaneously.

What the Pipeline Looks Like End to End

// 1. Server renders CRM data into page before GTM loads
dataLayer.push({
event: 'crmDataReady',
user: {
lifecycleStage: 'opportunity',
subscriptionTier: 'pro',
leadScore: 'hot',
industry: 'SaaS',
companySize: '51-200'
}
});
// 2. GTM fires GA4 Configuration tag on crmDataReady trigger
// (internally equivalent to:)
gtag('config', 'G-XXXXXXXXXX', {
user_properties: {
lifecycle_stage: 'opportunity',
subscription_tier: 'pro',
lead_score: 'hot',
industry: 'SaaS'
}
});
// 3. Later — user submits a form
dataLayer.push({
event: 'hsFormSubmit',
formId: 'form_abc123',
user: {
industry: 'SaaS',
companySize: '51-200'
}
});
// 4. GTM fires GA4 Event tag on hsFormSubmit trigger
// (internally equivalent to:)
gtag('event', 'generate_lead', {
form_id: 'form_abc123',
industry: 'SaaS',
company_size: '51-200'
});

Validating the Implementation

  1. Open GTM Preview, load a page where CRM data is pushed
  2. Click the crmDataReady event in the left panel
  3. Confirm the Data Layer tab shows the expected CRM keys and values
  4. Confirm the Variables tab shows the GTM variables resolving to non-empty values
  5. Confirm the Tags tab shows the GA4 Configuration tag fired (not just Paused)

In GA4 DebugView

GA4 Admin > DebugView (with ?gtm_debug=x parameter active)

  • Each event appears in real time
  • Click an event → inspect its parameters → confirm lifecycle_stageindustry, etc. appear with correct values
  • User properties appear under the user icon, not the event parameters

In GA4 Reports (24-hour delay)

Custom dimensions do not appear in standard reports immediately. After 24 hours:

  • Explore > Free Form → drag the custom dimension in as a dimension
  • If values appear as (not set), the parameter was not included in any event during the period

Common Pitfalls

1. Registering the dimension in GA4 Admin after data collection begins GA4 does not backfill. Dimensions registered on day 10 only show data from day 10 onward. Register all custom dimensions before deployment.

2. Parameter name mismatch between GTM and GA4 Admin If GTM sends lifecycleStage but GA4 Admin has lifecycle_stage registered, the values arrive in GA4 as an unregistered parameter collected but not processed into a dimension. Use snake_case consistently and copy-paste the parameter name exactly.

3. Pushing raw PII from HubSpot Email addresses in dataLayer are readable by every GTM tag including third-party pixels. Always use hashed email for audience matching, never plaintext. Use internal IDs, not email, as the contact identifier.

4. User-scoped properties sent only once GA4 user properties are associated with a user profile, but if a user clears cookies or uses a new device, their profile resets. You cannot rely on a single session’s push to persist indefinitely send user properties on every session where you have CRM data available.

5. Forgetting to handle logged-out users When user.lifecycleStage is undefined (anonymous visitor), GTM sends an empty string to GA4. Set default values in GTM Data Layer Variables (unknownanonymous) to keep dimension values clean.

6. SPA route changes re-firing GA4 config without CRM data In SPAs, if a route change fires the GA4 Configuration tag before the CRM push has re-run, user properties reset to empty. Re-push CRM data on every virtual pageview or configure the GA4 Configuration tag to fire only after crmDataReady.

7. Sending unbucketed numeric scores A raw lead score of 73 in a GA4 dimension creates one line per distinct score value up to 500K unique values before the dimension hits GA4’s cardinality limit and collapses into (other). Always bucket: cold / warm / hot.

See you soon.

View Comments (2)

Leave a Reply

Prev Next

Subscribe to My Newsletter

Subscribe to my email newsletter to get the latest posts delivered right to your email. Pure inspiration, zero spam.

Discover more from Datalad - Data Science and ML

Subscribe now to keep reading and get access to the full archive.

Continue reading