The Universal GTM Form Tracker That Doesn’t Exist Yet (Swipe, Unbounce, Gravity, and GoHighLevel…
A confirmation-render-based event listener for SwipePages, Gravity Forms, GoHighLevel (inline + iframe), and Unbounce. Built after Chloe…
The Universal GTM Form Tracker That Doesn’t Exist Yet (Swipe, Unbounce, Gravity, and GoHighLevel forms)

A confirmation-render-based event listener for SwipePages, Gravity Forms, GoHighLevel (inline + iframe), and Unbounce. Built after Chloe Christine’s wake-up call. Hardened against the standards Simo Ahava and Julius Fedorovicius have set. Open for the community to break.
— -
A few weeks ago, Chloe Christine, GA4 & GTM specialist, wrote a LinkedIn post that should be required reading for anyone managing a GTM container.
Her thesis was blunt:
“If you’ve just taken over a client’s GTM setup, the form tracking is wrong. I’m not telling you it might be. I’m telling you it almost definitely is.”
She’s right. And she’s not the only one saying it.
Simo Ahava (https://www.simoahava.com/) has called the built-in GTM “Form Submission” trigger one of the most overrated triggers in the platform for years.
Julius Fedorovicius (https://www.analyticsmania.com/) writes about it constantly on Analytics Mania. Charles Farina, Brian Clifton, Lukas Oldenburg, and the whole server-side measurement crew converge on the same point.
The form is the lead source. Get the form event right and everything downstream — GA4, Meta CAPI, Google Enhanced Conversions, CRM attribution — gets easier. Get it wrong, and the bid algorithm trains on phantom leads for three months while real ones rot in the CRM unattributed.
So I built one event listener that fixes it across five form platforms in a single file. Open-source. Free. I want experts to break it.
Here’s how it works, what’s unique about it, what it deliberately doesn’t do, and what I’d love feedback on.
— -
Why the default is broken
Default form tracking in most GTM installs fires on one of two signals:
Signal A — Button click. Counts validation failures, rage clicks, accidental double-clicks. Wildly overcounts.
Signal B — Thank-you page load. Counts direct navigation, browser refreshes, back-button visits, and shared confirmation URLs. Misses submissions that don’t redirect (AJAX-only success) and overcounts ones that do.
Both are proxy signals. Neither is the actual conversion. The actual conversion is the confirmation render — the moment the success message appears in the DOM, or the success state of the form’s own AJAX response. That signal is platform-specific and requires platform-specific code.
The cost of getting this wrong is real. Chloe quoted 5–30% miscount depending on form complexity and user browser. That tracks with what I’ve seen in audits. Half the time, the algorithm is bidding on phantom leads. The other half, real submissions, are missing from the CRM. Either way, the data is lying to you.
— -
Why “just use the confirmation render” is harder than it sounds
Every form platform renders confirmation differently:
- Gravity Forms dispatches a JavaScript event called
gform_confirmation_loadedwhen AJAX submission succeeds, and renders a.gform_confirmation_wrapperelement into the DOM. - SwipePages uses an AMP-style form that adds an
amp-form-submit-successclass to the form element on success. - GoHighLevel inline mutates the DOM after submission — the specific behavior varies by template.
- GoHighLevel iframe is cross-origin. You can’t read its DOM at all. You have to catch its
postMessageevents. - Unbounce typically removes the form from the DOM, replacing it with a confirmation block or redirecting.
There is no universal “form submitted” event you can subscribe to. There is no single DOM signal that means the same thing on every platform. You have to write five different listeners and a unified architecture around them.
Most form-tracking tutorials cover one platform. Most “universal” community scripts handle one or two with a button-click fallback for the rest. I have searched, and I have not found a single public listener that handles all five platforms with the correct platform-native signal for each.
So I wrote one.
— -
The script — high-level
The whole thing lives in a single IIFE. You drop it into a GTM Custom HTML tag, set it to fire on All Pages, and it does the rest. On each page load it:
- Checks if it’s already initialized (idempotent — safe against double-loading).
- Detects which form platforms are present on the page.
- Attaches platform-specific success and failure listeners to each form independently.
- Watches for late-loaded or dynamically-injected forms via
MutationObserverand a 12-second polling fallback. - Captures form data progressively (on every keystroke) so partial submissions can still be reconstructed.
- Persists captured data across page navigation via
localStorage+sessionStorage(TTL-gated, consent-gated). - Fires platform-specific dataLayer events on confirmation render — never on click, never on thank-you page load.
The events it pushes:
swipe_form_success/swipe_form_failedgf_form_success/gf_form_failedghl_form_success/ghl_form_failedunbounce_form_success/unbounce_form_failed
Platform-specific names by design — so each platform can be triggered independently in GTM. To match across all four in a single trigger, use a regex: ^(swipe|gf|ghl|unbounce)_form_(success|failed)$.
— -
The architecture
State and dedupe
Every form gets its own state object stored in a WeakMap, keyed by the form DOM element. This means: multiple forms on a single page (top hero, middle CTA, footer) are tracked independently with separate successFired / failedFired flags. Multi-form awareness comes free.
Position labels (top / middle / footer) are derived from DOM order at attach time and included in every payload. This is one of those things you don’t appreciate until you’re trying to figure out which of three CTAs on a landing page is actually converting.
The hardest dedupe case is the form-page-then-thank-you-page flow: if Gravity Forms succeeds via AJAX, fires gform_confirmation_loaded, AND then redirects to a thank-you URL, you can easily double-count. The script handles this with a _success_fired flag persisted alongside the form data. When the thank-you page bootstraps, it checks the flag before firing. One success per submission, always.
Storage strategy
Triple persistence: in-memory (window._ec_form_data), localStorage, and sessionStorage. The in-memory copy survives same-page operations. The two storage layers survive navigation. A 10-minute TTL stamp on each persist means stale ghost data from yesterday won’t resurrect itself when a user visits a thank-you URL directly.
Every storage write is wrapped in try/catch because Safari private mode, storage quota limits, and disabled cookies will all throw — and a script that crashes on localStorage.setItem is a script that breaks form tracking for some percentage of your users.
The consent gate
This is the part I’m most happy with. Most form-tracking scripts I’ve seen treat consent as someone else’s problem. This one bakes it in.
function getConsent() {
// 1. Caller override — always wins
if (typeof window.EC_FORM_CONSENT_CHECK === ‘function’) {
try {
var r = window.EC_FORM_CONSENT_CHECK();
if (r === true) return { analytics: true, pii: true };
if (r === false) return { analytics: false, pii: false };
if (r && typeof r === ‘object’) {
return {
analytics: r.analytics !== false,
pii: r.pii === true || r.ads === true || r.marketing === true
};
}
} catch (e) { log(‘Consent check threw:’, e); }
}
// 2. Google Consent Mode v2
// 3. OneTrust
// 4. Cookiebot
// 5. Default: full consent
}
Two consent axes, intentionally:
analytics— controls whether any event fires at all. Denied → no dataLayer push, no storage. Period.pii— controls whetherec_email/ec_phone/ec_name/ec_raw_fieldsare included in the push. Denied → fields stripped,ec_pii_redacted: trueflag added so downstream tags can detect the redaction.
Auto-detection covers Google Consent Mode v2, OneTrust (with the common C0002 / C0004 group IDs), and Cookiebot. For every other CMP — Iubenda, Termly, Usercentrics, custom solutions — you override with one function:
window.EC_FORM_CONSENT_CHECK = function() {
return {
analytics: yourCmp.statisticsGranted(),
pii: yourCmp.marketingGranted()
};
};
Drop that into a GTM Consent Initialization tag before the main script loads. Done.
PII routing — why GA4 isn’t the right destination
The event payload includes ec_email, ec_phone, ec_name, and ec_raw_fields. These should never reach GA4 raw.
GA4’s Terms of Service explicitly prohibit PII in event parameters. Google can — and does — disable properties caught sending raw email addresses. Simo has been hammering this point for years.
The intended destinations for these fields are:
- Server-side GTM with a SHA-256 hashing transformation before forwarding to Meta CAPI, Google Enhanced Conversions, etc.
- First-party CRM integrations where the data stays in your own infrastructure.
If you’re firing GA4 Event tags off this dataLayer push directly, configure the tag to explicitly exclude ec_email, ec_phone, ec_name, and ec_raw_fields from the parameters list. The script can’t enforce this for you — it’s your GTM configuration. But the documentation makes it impossible to miss.
Per-platform detection — the interesting bits
Gravity Forms is the cleanest case because they expose a native JS event:
document.addEventListener(‘gform_confirmation_loaded’, function () {
if (state.successFired) return;
state.successFired = true;
// … push gf_form_success
});
Belt-and-suspenders: a parallel MutationObserver watches for .gform_confirmation_wrapper rendering, in case the native event doesn’t fire for some configuration. The successFired lock ensures only one event ever pushes.
SwipePages is also clean — AMP-style forms toggle amp-form-submit-success / amp-form-submit-error classes on the form element. An attribute-filtered observer catches them efficiently:
new MutationObserver(function (mutations) {
mutations.forEach(function (m) {
if (m.attributeName !== ‘class’) return;
if (form.classList.contains(‘amp-form-submit-success’)) { /* fire success */ }
if (form.classList.contains(‘amp-form-submit-error’)) { /* fire failure */ }
});
}).observe(form, { attributes: true, attributeNames: [‘class’] });
GoHighLevel inline is harder. There’s no native event. The script stamps a submitAt timestamp when the submit button is clicked, then watches the form’s parent container for any new sibling node within a 15-second window. If a sibling appears with error-flavored class names (error, invalid, wrong, fail), it’s a failure. Otherwise, it’s a success. Form removal or hiding also counts as success (the most common GHL success behavior is replacing the form with a confirmation block).
GoHighLevel iframe is the case I’m most proud of. The form is cross-origin — you cannot inspect its DOM, you cannot attach event listeners, you cannot detect class changes. The only signal available is postMessage from the iframe.
window.addEventListener(‘message’, function (event) {
if (!/leadconnectorhq\.com|msgsndr\.com|gohighlevel\.com/.test(event.origin)) return;
// Verify the source iframe matches one we attached to
var source = null;
for (var i = 0; i < iframes.length; i++) {
if (iframes[i].contentWindow === event.source) { source = iframes[i]; break; }
}
if (!source) { logUnmatched(‘source iframe not matched’, event.data); return; }
// Parse data[2] as JSON, check for identifier fields, push success
});
Origin check + contentWindow verification (defense against postMessage spoofing) + JSON parse of the third array element + identifier-field detection. Every fail branch logs an ignored message line so if GoHighLevel changes their postMessage format you see it instantly in DevTools instead of discovering it months later from a CRM mismatch.
I cannot find a public implementation of this. If you know of one, link me — I’d love to compare approaches.
Unbounce uses two detection paths in parallel. Native HTML5 invalid events catch validation failures (Unbounce uses native required / pattern attributes). A render watcher on the parent node catches form removal, hiding, or sibling node appearance — the three main Unbounce success patterns. pagehide (not beforeunload — see caveats below) catches redirect-to-thank-you scenarios.
Failure tracking
Most scripts I’ve seen don’t track failures at all. This one does, gated by a hadInput flag so empty-click rage doesn’t fire *_form_failed. The hadInput gate flips to true the moment any field receives input, blur, or change. Failed submissions only fire if the user actually engaged with the form and the submission attempt produced an error.
This data is gold. The ratio of *_form_failed to *_form_success is one of the cleanest leading indicators of form UX problems. If you suddenly see a spike in failures on one form, something broke. You can’t measure what you don’t track.
— -
What’s genuinely unique
After expert review and rewrite, this is what I believe is unique versus anything else available:
-
Five platforms in one file with platform-native signals for each. Not button-click fallbacks. The actual right signal per platform.
-
GoHighLevel iframe via postMessage parsing. I have not found a public implementation. Cross-origin form tracking is genuinely hard.
-
SwipePages support. Smaller platform, no community scripts I’ve located.
-
Universal consent gate with override hook. Auto-detects three major CMPs; one-function override for everything else.
-
PII redaction with detection flag (
ec_pii_redacted: true) so downstream tags can branch behavior. -
Failure tracking with hadInput gate — failures only fire on actual submission attempts, not empty clicks.
-
Idempotent load — safe against double-injection (GTM tag + embedded copy on the same page).
-
Multilingual thank-you URL fallback — English plus eight other languages.
-
WeakMap per-form state — multi-form pages tracked independently with position labels.
-
pagehideinstead ofbeforeunload— reliable on mobile Safari and bfcache.
— -
Honest caveats
This is where I want to be straight with you, because the script will land in front of people who know more than me.
-
Not every CMP is auto-detected. Iubenda, Termly, Usercentrics, and custom solutions require wiring
EC_FORM_CONSENT_CHECKyourself. One function. Documented in the header. -
The GHL iframe postMessage format is reverse-engineered. If GoHighLevel changes their internal message structure, the listener silently stops firing for that iframe. The unmatched-message debug logs are there specifically to surface this fast, but you’d still need to push an update.
-
The
SUBMIT_WINDOWis 15 seconds. Generous, but on extremely slow networks with file uploads or CAPTCHA chains, it could still time out. Configurable at the top of the script. -
MutationObserverhas theoretical race edge cases on very slow renders. In practice, it’s rock solid. In principle, if a render takes longer than the submit window OR a sibling node appears for unrelated reasons during the window (popups, A/B testing tools, chat widgets), you can get false positives or false negatives. The submit-window gate and classname filter mitigate this. Test on your pages. -
HubSpot, Marketo, WPForms, and CF7 are not covered. They follow the same architectural pattern — each one has a native success event or a confirmation render signal — so they’re addable. They’re not in this release because I haven’t built and tested them yet. If there’s community demand, that’s v2.1.
-
This is not a replacement for server-side CAPI dedupe. This script gives you clean client-side data with PII fields ready for hashing. Sending that to Meta or Google’s Conversions API with proper deduplication is still your job. The data is the foundation; CAPI is the structure on top.
-
The dataLayer push includes raw PII. This is by design — server-side GTM and CAPI integrations need the raw values. But it means you must not send these fields to GA4 directly. Either use sGTM with a hashing transformation, or filter them out in your GA4 Event tag configuration. The header comment in the script repeats this warning. Please read it.
— -
How to deploy it (5 minutes)
- Download the GTM container JSON—
[GITHUB-LINK](https://github.com/Iyke76/universal-gtm-form-tracker.git)or DM me on LinkedIn. - Import the container in GTM (Admin → Import Container → choose Merge → Rename Conflicting Tags).
- Open the variable
Const — GA4 Measurement IDand replace the placeholder value with your own GA4 Measurement ID. - Preview the container* on a page with one of the supported form platforms. Submit a test form. Confirm the
[EC] ->log appears in the console exactly once per success, and the corresponding `_form_success` event lands in the dataLayer. - Publish.
That’s it. Five minutes if you don’t get distracted.
— -
The invitation
I want this broken.
Specifically, I want the people who shaped how I think about measurement to read this script and tell me what’s wrong with it. Simo Ahava and Julius Fedorovicius — your work is the reason this script exists at all. If either of you sees this, I would value your eyes on it more than I can say.
Charles Farina, Lukas Oldenburg, Krista Seiden, Markus Baersch, Chloe Christine — same invitation. Tear into it. Tell me where the assumptions break.
And to everyone else who tracks forms for a living — small agencies, in-house analysts, freelance measurement engineers — please test it on your client sites. Open issues. Submit pull requests. If something fires wrong, screenshot it and send it to me.
The whole point of putting this out is that the next person who inherits a broken GTM container shouldn’t have to write five DOM observers from scratch. They should be able to drop in a file, swap one variable, and have the form tracking actually work.
Chloe lit the fire. Simo and Julius set the bar. I just wrote the code.
Let’s make it better.
— -
GitHub repository: [GITHUB-LINK](https://github.com/Iyke76/universal-gtm-form-tracker.git)
GTM container JSON: linked in the GitHub repo, or DM me on LinkedIn — happy to send directly.
Contact: *LinkedIn* · abeliyke05@gmail.com
If this helped, the most useful thing you can do is forward it to one measurement engineer who manages a GTM container they didn’t build. That’s the audience this is for.
메타데이터
- post_id
- b3d8dfa432c7
- slug
- the-universal-gtm-form-tracker-that-doesnt-exist-yet-swipe-unbounce-gravity-and-gohighlevel-b3d8dfa432c7
- url
- https://medium.com/@abeliyke/the-universal-gtm-form-tracker-that-doesnt-exist-yet-swipe-unbounce-gravity-and-gohighlevel-b3d8dfa432c7
- canonical_url
- https://medium.com/@abeliyke/the-universal-gtm-form-tracker-that-doesnt-exist-yet-swipe-unbounce-gravity-and-gohighlevel-b3d8dfa432c7
- author_url
- https://medium.com/@abeliyke
- status
- ok
- fetched_at
- 2026-07-11 13:11:35