Gravity Forms — GTM Listener
Gravity Forms — GTM Listener
Pushes Gravity Forms front-end events to window.dataLayer so GTM Custom Event triggers can fire downstream tags (Meta Pixel Lead, GA4 conversion, etc.).
What it does
Subscribes to Gravity Forms' client-side hooks — both the legacy jQuery events (pre-2.9) and the native gform/... CustomEvents / filter API introduced in 2.9 — and pushes a normalized event to the GTM dataLayer. Duplicate pushes are suppressed when both paths fire for the same submission.
| Gravity Forms hook | dataLayer event | dataLayer variables |
|---|---|---|
submit on a GF form (any submission method) | gforms_form_submit | gforms_form_id, gforms_fields |
gform_confirmation_loaded (jQuery, ≤2.8 + 2.9 compat) | gforms_form_success | gforms_form_id |
gform/ajax/post_ajax_submission (native filter, 2.9+) | gforms_form_success | gforms_form_id |
gform_confirmation_message_{id} element on page load (non-AJAX) | gforms_form_success | gforms_form_id |
gform_page_loaded (jQuery, ≤2.8 + 2.9 compat) | gforms_page_loaded | gforms_form_id, gforms_current_page |
gform/ajax/post_page_change (native event, 2.9+) | gforms_page_loaded | gforms_form_id, gforms_current_page |
focusout on a GF input with a non-empty value | gforms_field_complete | gforms_form_id, gforms_field_id |
Installation
Step 1: Create the Custom HTML tag
In GTM, go to Tags → New:
- Click Tag Configuration → choose Custom HTML
- Paste the contents of
listener-inline.htmlinto the HTML field - Click Triggering → choose All Pages — Page View
- Name the tag:
cHTML - Gravity Forms Listener - Save
Step 2: Create dataLayer triggers per event
For form success, in GTM go to Triggers → New:
- Trigger Type: Custom Event
- Event name:
gforms_form_success - Name:
Custom Event - GF Form Success
Repeat for gforms_page_loaded if you want to track multi-page navigation.
Step 3: Create dataLayer variables
For each variable you want to use in downstream tags, go to Variables → User-Defined Variables → New:
- Variable Type: Data Layer Variable
- Data Layer Variable Name:
gforms_form_id - Name:
DLV - GF Form ID
Repeat for gforms_current_page if needed.
Step 4: Use the trigger in a downstream tag
Example — fire a Meta Pixel Lead event when any Gravity Form succeeds:
- Trigger:
Custom Event - GF Form Success - Tag: Meta Pixel Lead event tag (use
{{DLV - GF Form ID}}as a custom param to segment by form)
To fire only for a specific form, add a trigger condition: DLV - GF Form ID equals 3.
Capturing hidden field values (fbc, gclid, utm_*) for Meta CAPI / GA4
gforms_form_submit fires on every Gravity Forms submission (AJAX or not) and includes a gforms_fields object with every user-defined hidden field in the form, keyed by HTML name. GF's own internal hiddens (gform_submit, state_*, _wp_http_referer, etc.) are skipped. Visible inputs (text, email, radio, etc.) are not included — see "Known limitations" below.
Typical Meta Pixel CAPI flow:
- In Gravity Forms, add a Hidden field to your form. Note its field ID (e.g., field 20).
- Populate it with the visitor's
fbcusinggform_field_value_*server-side, or via the field's "Allow field to be populated dynamically" parameter and a URL parameter / JS snippet. - In GTM, create a Data Layer Variable:
- Variable Type: Data Layer Variable
- Data Layer Variable Name:
gforms_fields.input_20(replace20with your field ID) - Name:
DLV - GF fbc
- In your Meta Pixel Lead tag (triggered on
gforms_form_submit), pass{{DLV - GF fbc}}as thefbcuser-data parameter.
The submit event fires before the form navigates away on non-AJAX forms, so GA4 / Meta tags using sendBeacon or keepalive (the GTM default) will reliably deliver.
Step 5: Preview and publish
- Click Preview to test in Tag Assistant
- Submit a Gravity Form on your site — verify in the Preview console that
gforms_form_successappears in the event stream - Once verified, publish your container
Known limitations
- Non-AJAX
gforms_form_successrequires the default "Display Message" confirmation type: The non-AJAX detector looks for<div id="gform_confirmation_message_{formId}">on the confirmation page. If the form is configured to redirect to a different URL on success, that page won't contain the marker — track those with a URL-based GTM trigger on the destination page. AJAX forms always work regardless of confirmation type. gforms_form_submitonly captures hidden fields, not visible ones: To avoid leaking PII (emails, names, phone, free-text answers) into the dataLayer and every downstream GTM tag, onlyinput[type=hidden]user fields are included. Visible inputs are intentionally skipped. If you need a specific visible field's value downstream, extract it in a dedicated GTM Custom JavaScript variable scoped to that field.gforms_field_completenever includes the field value: Same PII reasoning — onlygforms_form_idandgforms_field_idare pushed. Usegforms_form_submitif you need values.- jQuery optional on GF 2.9+: On Gravity Forms 2.9 and newer the listener uses the native
gform/...events and works without jQuery. On older versions (or 2.9 with the legacy submission path) the jQuery handlers are used; if jQuery has been stripped out and the site is also on legacy GF, the listener cannot bind. - No per-form filtering at the listener level: Every form ID pushes the same dataLayer event. Filter in your downstream GTM trigger conditions using
gforms_form_idif you only want to fire for specific forms.
Disclaimer
Provided as-is under the MIT License. Test thoroughly before relying on it for revenue attribution.
Packaged by W.H. Boggs — whboggs.com → Free Meta Ads audit
The code
<!--
Marketing Toolkit — Gravity Forms GTM Listener (Inline) v1.3.1
https://github.com/whboggs/marketing-toolkit
Packaged by W.H. Boggs — https://whboggs.com
→ Free Meta Ads audit: https://whboggs.com/audit
MIT License | Copyright (c) 2026 W.H. Boggs
Self-contained version — no CDN dependency. Paste into a GTM Custom
HTML tag set to fire on All Pages — Page View. Updates do not push
automatically; re-paste this file to upgrade.
-->
<script>
(function () {
window.dataLayer = window.dataLayer || [];
// Legacy jQuery path (pre-2.9) and GF 2.9+ native path can both fire for
// the same submission on sites with the back-compat shim — de-dupe.
var recent = {};
var DEDUPE_MS = 1000;
function pushOnce(event, formId, currentPage) {
if (formId == null) return;
var key = event + ':' + formId + ':' + (currentPage == null ? '' : currentPage);
var now = Date.now();
var last = recent[key];
if (last && now - last < DEDUPE_MS) return;
recent[key] = now;
var payload = { event: event, gforms_form_id: formId };
if (currentPage != null) payload.gforms_current_page = currentPage;
window.dataLayer.push(payload);
}
// GF 2.9+ native CustomEvent: multi-page navigation in new AJAX path
// https://docs.gravityforms.com/gform-ajax-post_page_change/
document.addEventListener('gform/ajax/post_page_change', function (e) {
var d = e.detail || {};
pushOnce('gforms_page_loaded', d.formId, d.pageNumber);
});
// Field completion: blur on a GF input with a non-empty value. Uses
// focusout (bubbles) for delegation. Values are NOT pushed — they may
// contain PII. Dedupe per field on value so re-tabbing the same field
// without editing it doesn't push twice.
var lastFieldValue = {};
var SKIP_TYPES = { hidden: 1, submit: 1, button: 1, reset: 1, file: 1, image: 1 };
document.addEventListener('focusout', function (e) {
var target = e.target;
if (!target || !target.tagName || !target.closest) return;
var tag = target.tagName.toLowerCase();
if (tag !== 'input' && tag !== 'select' && tag !== 'textarea') return;
if (SKIP_TYPES[(target.type || '').toLowerCase()]) return;
if (!target.closest('.gform_wrapper')) return;
var value = (target.value || '').replace(/^\s+|\s+$/g, '');
if (!value) return;
var formId = null;
var fieldId = null;
var fieldEl = target.closest('.gfield');
if (fieldEl && fieldEl.id) {
var m = fieldEl.id.match(/^field_(\d+)_(\d+)$/);
if (m) { formId = m[1]; fieldId = m[2]; }
}
if (!formId) {
var form = target.closest('form');
formId = form && form.getAttribute('data-formid');
}
if (!formId || !fieldId) return;
var key = formId + ':' + fieldId;
if (lastFieldValue[key] === value) return;
lastFieldValue[key] = value;
window.dataLayer.push({
event: 'gforms_field_complete',
gforms_form_id: formId,
gforms_field_id: fieldId
});
});
// GF 2.9+ filter API: AJAX submission completion. data.form is the DOM
// <form> element, so the numeric id is data.form.dataset.formid —
// data.form.id is the HTML id ("gform_5"). The filter also runs for
// validation failures, so only push when submissionResult marks a
// confirmation.
// https://docs.gravityforms.com/gform-ajax-post_ajax_submission/
function ajaxFormId(data) {
if (!data) return null;
var f = data.form;
if (f) {
if (f.dataset && f.dataset.formid) return f.dataset.formid;
if (typeof f.getAttribute === 'function') {
var attr = f.getAttribute('data-formid');
if (attr) return attr;
}
if (typeof f.id === 'string') {
var m = f.id.match(/^gform_(\d+)$/);
if (m) return m[1];
}
}
return data.formId != null ? String(data.formId) : null;
}
function bindNative() {
var utils = window.gform && window.gform.utils;
if (!utils || typeof utils.addFilter !== 'function') return false;
utils.addFilter('gform/ajax/post_ajax_submission', function (data) {
var formId = ajaxFormId(data);
var result = data && data.submissionResult;
if (result && (result.is_confirmation || result.confirmation_message || result.confirmation_redirect)) {
pushOnce('gforms_form_success', formId);
}
return data;
});
return true;
}
// Form submit: capture user-defined hidden fields (fbc, gclid, utm_*).
// Fires on the DOM submit event (capture phase) so values are available
// before any navigation. GF's own internal hiddens (gform_submit, state_*,
// _wp_http_referer, etc.) are skipped — only inputs named input_* (the
// pattern GF assigns to user fields) are captured.
document.addEventListener('submit', function (e) {
var form = e.target;
if (!form || !form.closest || !form.closest('.gform_wrapper')) return;
var formId = form.getAttribute('data-formid')
|| (form.id && form.id.replace(/^gform_/, ''));
if (!formId) return;
var fields = {};
var hiddens = form.querySelectorAll('input[type="hidden"][name^="input_"]');
for (var i = 0; i < hiddens.length; i++) {
fields[hiddens[i].name] = hiddens[i].value;
}
window.dataLayer.push({
event: 'gforms_form_submit',
gforms_form_id: String(formId),
gforms_fields: fields
});
}, true);
// Non-AJAX confirmation: detect the confirmation message on page load.
// GF reloads the page with the form replaced by
// <div id="gform_confirmation_message_{formId}">...</div>. AJAX paths
// still push success via the filter / jQuery handlers below; pushOnce
// dedupes if both fire.
function checkConfirmation() {
var els = document.querySelectorAll('[id^="gform_confirmation_message_"]');
for (var i = 0; i < els.length; i++) {
var m = els[i].id.match(/^gform_confirmation_message_(\d+)$/);
if (m) pushOnce('gforms_form_success', m[1]);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', checkConfirmation, { once: true });
} else {
checkConfirmation();
}
// Legacy jQuery events (pre-2.9; still fire on 2.9 with compat shim)
// https://docs.gravityforms.com/gform_confirmation_loaded/
// https://docs.gravityforms.com/gform_page_loaded/
function bindJQuery() {
var $ = window.jQuery;
if (!$) return false;
$(document).on('gform_confirmation_loaded', function (event, formId) {
pushOnce('gforms_form_success', formId);
});
$(document).on('gform_page_loaded', function (event, formId, currentPage) {
pushOnce('gforms_page_loaded', formId, currentPage);
});
return true;
}
// jQuery / gform.utils may load after this script — poll briefly for each.
// Native addEventListener bindings above queue immediately and don't need
// to wait on anything.
var attempts = 0;
var maxAttempts = 50; // ~5s at 100ms intervals
var jqBound = false;
var nativeBound = false;
(function tryBind() {
if (!jqBound) jqBound = bindJQuery();
if (!nativeBound) nativeBound = bindNative();
if (jqBound && nativeBound) return;
if (++attempts >= maxAttempts) return;
setTimeout(tryBind, 100);
})();
})();
</script>