Sign in

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 hookdataLayer eventdataLayer variables
submit on a GF form (any submission method)gforms_form_submitgforms_form_id, gforms_fields
gform_confirmation_loaded (jQuery, ≤2.8 + 2.9 compat)gforms_form_successgforms_form_id
gform/ajax/post_ajax_submission (native filter, 2.9+)gforms_form_successgforms_form_id
gform_confirmation_message_{id} element on page load (non-AJAX)gforms_form_successgforms_form_id
gform_page_loaded (jQuery, ≤2.8 + 2.9 compat)gforms_page_loadedgforms_form_id, gforms_current_page
gform/ajax/post_page_change (native event, 2.9+)gforms_page_loadedgforms_form_id, gforms_current_page
focusout on a GF input with a non-empty valuegforms_field_completegforms_form_id, gforms_field_id

Installation

Step 1: Create the Custom HTML tag

In GTM, go to TagsNew:

  1. Click Tag Configuration → choose Custom HTML
  2. Paste the contents of listener-inline.html into the HTML field
  3. Click Triggering → choose All Pages — Page View
  4. Name the tag: cHTML - Gravity Forms Listener
  5. Save

Step 2: Create dataLayer triggers per event

For form success, in GTM go to TriggersNew:

  • 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 VariablesUser-Defined VariablesNew:

  • 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:

  1. In Gravity Forms, add a Hidden field to your form. Note its field ID (e.g., field 20).
  2. Populate it with the visitor's fbc using gform_field_value_* server-side, or via the field's "Allow field to be populated dynamically" parameter and a URL parameter / JS snippet.
  3. In GTM, create a Data Layer Variable:
    • Variable Type: Data Layer Variable
    • Data Layer Variable Name: gforms_fields.input_20 (replace 20 with your field ID)
    • Name: DLV - GF fbc
  4. In your Meta Pixel Lead tag (triggered on gforms_form_submit), pass {{DLV - GF fbc}} as the fbc user-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

  1. Click Preview to test in Tag Assistant
  2. Submit a Gravity Form on your site — verify in the Preview console that gforms_form_success appears in the event stream
  3. Once verified, publish your container

Known limitations

  • Non-AJAX gforms_form_success requires 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_submit only captures hidden fields, not visible ones: To avoid leaking PII (emails, names, phone, free-text answers) into the dataLayer and every downstream GTM tag, only input[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_complete never includes the field value: Same PII reasoning — only gforms_form_id and gforms_field_id are pushed. Use gforms_form_submit if 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_id if 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. Boggswhboggs.com → Free Meta Ads audit

The code

listener-inline.html
html
<!--
  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>