FreeGoogle Tag Manager recipes

Track WPForms Submissions in Google Tag Manager with this Free GTM Recipe

A free, tested Google Tag Manager recipe that captures WPForms submissions and turns them into a trigger you can use to fire conversions to Google Ads, Google Analytics and more.

Aaron Beashel
Aaron BeashelUpdated July 20267 min read
The WPForms recipe tag inside Google Tag Manager

Google Tag Manager is one of the usual ways to send conversions to Google Ads, Google Analytics, etc when someone submits a WPForms form on your website.

The problem is, it's quite difficult to set up. WPForms saves most submissions over AJAX and reloads the page on others, which means Google Tag Manager's built-in form submission trigger doesn't fire reliably, so you're left having to write custom code to detect the form submission, send an event to the dataLayer, and build a trigger on top of that.

And unless you're a developer or really comfortable in Google Tag Manager, you're going to struggle.

Fortunately though, there's a simpler way.

You can just install this WPForms Conversion Tracking Recipe in your Google Tag Manager account and you'll have everything you need to get basic conversion tracking set up.

What Is a Google Tag Manager Recipe and What's in It?

A recipe is a pre-built bundle of Google Tag Manager assets (a tag, a trigger and a variable) that you import in one go instead of building each piece yourself. You can see all our recipes and how they work in the GTM recipe library.

This recipe detects WPForms submissions, sends a 'Form Submitted' event to Google Tag Manager, and gives you a Trigger you can use to send conversions to Google Analytics, Google Ads, etc. There are three parts to it, and nothing inside is hidden. Here's exactly what each piece contains, so you can see what you're importing before you import it.

Custom HTML Tag

Listens for successful WPForms submissions and pushes a 'wpforms_form_submitted' event into the dataLayer. It fires on All Pages, once per event, and this is the code inside it.

The Tag's CodeJavaScript · 324 lines
(function () {
  if (window.__converlyRecipeWpforms) return;
  window.__converlyRecipeWpforms = true;
  // Only run in real http(s) documents. about:blank / srcdoc iframes inherit
  // the parent's origin, so they SHARE sessionStorage — a copy of this script
  // running in one would consume the deferred native-mode marker and, because
  // its pathname can never equal the form page's path, fabricate a fire via
  // the redirect heuristic. Observed live on parkhousesoftware.com (an
  // about:blank iframe stole the marker during verification, 2026-07-05).
  if (!/^https?:$/.test(window.location.protocol)) return;
  window.dataLayer = window.dataLayer || [];

  var NATIVE_WINDOW_MS = 10000;
  var DEFERRED_MARKER_KEY = '__converlyRecipeWpformsNativePending';
  var DEFERRED_MARKER_TTL_MS = 60000;

  function isWPForm(formEl) {
    if (!formEl) return false;
    try {
      if (formEl.closest && formEl.closest('.wpforms-container')) return true;
    } catch (e) {
      // .closest not available — fall back to ID check
    }
    var id = formEl.id || '';
    return /^wpforms-form-\d+$/.test(id);
  }

  function parseFormId(formEl) {
    var id = (formEl && formEl.id) || '';
    var m = /^wpforms-form-(\d+)$/.exec(id);
    return m ? m[1] : '';
  }

  function fireConversion(formId) {
    window.dataLayer.push({
      event: 'wpforms_form_submitted',
      form_id: formId || ''
    });
  }

  // ---- Shared validation-marker check (used by both fire paths) ----

  function isNodeHidden(el) {
    try {
      if (el.hidden) return true;
      if (el.getAttribute && el.getAttribute('aria-hidden') === 'true') return true;
      var view = el.ownerDocument && el.ownerDocument.defaultView;
      if (view && view.getComputedStyle) {
        var cs = view.getComputedStyle(el);
        if (cs && (cs.display === 'none' || cs.visibility === 'hidden')) return true;
      }
    } catch (e) {
      // fall through
    }
    return false;
  }

  function hasVisibleValidationErrors(scopeEl) {
    if (!scopeEl || !scopeEl.querySelectorAll) return false;
    try {
      var nodes = scopeEl.querySelectorAll(
        '.wpforms-error, .wpforms-error-container, [aria-invalid="true"]'
      );
      for (var i = 0; i < nodes.length; i++) {
        var node = nodes[i];
        if (isNodeHidden(node)) continue;
        if (node.getAttribute && node.getAttribute('aria-invalid') === 'true') return true;
        if ((node.textContent || '').trim()) return true;
      }
    } catch (e) {
      // fall through
    }
    return false;
  }

  function hasServerRejectionMarkers() {
    try {
      var wrappers = document.querySelectorAll('.wpforms-container');
      for (var i = 0; i < wrappers.length; i++) {
        if (hasVisibleValidationErrors(wrappers[i])) return true;
      }
    } catch (e) {
      // fall through
    }
    return false;
  }

  function hasConfirmationMessage() {
    try {
      var nodes = document.querySelectorAll(
        '.wpforms-confirmation-container-full, .wpforms-confirmation-container, div[id^="wpforms-confirmation-"]'
      );
      for (var i = 0; i < nodes.length; i++) {
        if (isNodeHidden(nodes[i])) continue;
        if ((nodes[i].textContent || '').trim()) return true;
      }
    } catch (e) {
      // fall through
    }
    return false;
  }

  // ---- Fire path 1: AJAX success event ----

  var activeWatchers = [];

  function findWatcher(formEl) {
    for (var w = 0; w < activeWatchers.length; w++) {
      if (activeWatchers[w].form === formEl) return activeWatchers[w];
    }
    return null;
  }

  // An AJAX success disarms any pagehide watcher armed for this form — the
  // 2s dedup flag self-clears but the native watcher would otherwise stay
  // armed for the full 10s window and could re-fire on a later navigation.
  function disarmWatcher(formEl) {
    var existing = findWatcher(formEl);
    if (existing && typeof existing.cleanup === 'function') existing.cleanup();
  }

  var successHooked = false;

  function hookWpformsSuccess() {
    if (successHooked) return true;
    if (typeof jQuery === 'undefined' && typeof $ === 'undefined') return false;

    var jQ = typeof jQuery !== 'undefined' ? jQuery : $;
    jQ(document).on('wpformsAjaxSubmitSuccess', function (event) {
      var formEl = event.target;
      if (!formEl) return;

      if (formEl.getAttribute('data-converly-processing') === 'true') return;
      formEl.setAttribute('data-converly-processing', 'true');
      setTimeout(function () { formEl.removeAttribute('data-converly-processing'); }, 2000);

      disarmWatcher(formEl);
      fireConversion(parseFormId(formEl));
    });
    successHooked = true;
    return true;
  }

  // ---- Fire path 2: native (non-AJAX) pagehide watcher + deferred verdict ----

  function writeDeferredMarker(formId) {
    try {
      window.sessionStorage.setItem(DEFERRED_MARKER_KEY, JSON.stringify({
        t: Date.now(),
        path: window.location.pathname,
        formId: formId
      }));
      return true;
    } catch (e) {
      return false;
    }
  }

  function takeDeferredMarker() {
    try {
      var raw = window.sessionStorage.getItem(DEFERRED_MARKER_KEY);
      if (!raw) return null;
      window.sessionStorage.removeItem(DEFERRED_MARKER_KEY);
      var data = JSON.parse(raw);
      if (!data || typeof data !== 'object') return null;
      if (!data.t || Date.now() - data.t > DEFERRED_MARKER_TTL_MS) return null;
      return data;
    } catch (e) {
      try { window.sessionStorage.removeItem(DEFERRED_MARKER_KEY); } catch (e2) {}
      return null;
    }
  }

  // Runs once at module init on every page load. Consumes the marker the
  // previous page wrote at pagehide (if any) and reads the verdict from the
  // markup WPForms rendered into THIS page. Rejection is checked first: the
  // rejected-POST re-render always carries error markers, so a missed fire
  // here degrades to under-counting, never fabricating.
  function processDeferredVerdict() {
    var marker = takeDeferredMarker();
    if (!marker) return;

    if (hasServerRejectionMarkers()) return;

    if (hasConfirmationMessage()) {
      fireConversion(marker.formId);
      return;
    }
    // No WPForms markup either way. A success configured to redirect lands
    // on a different path with no confirmation container — treat a changed
    // pathname as that redirect. A rejection can't look like this: the
    // rejected POST always re-renders the form WITH error markers.
    if (marker.path && window.location.pathname !== marker.path) {
      fireConversion(marker.formId);
    }
    // Same page, no markers either way — ambiguous, do not fire.
  }

  function watchForSuccess(formEl, formId, pagehideTrusted) {
    var entry = { form: formEl, refresh: refresh, cleanup: cleanup };
    activeWatchers.push(entry);

    var fired = false;
    var timer = null;
    var pagehideArmed = false;

    function onPageHide() {
      if (fired) return;
      // Click-then-leave guard: a visitor who submitted an invalid form,
      // got blocked, then navigated away produces a pagehide too. Visible
      // error markers mean the submit never went through — do not fire.
      if (hasVisibleValidationErrors(formEl)) return;
      fired = true;
      if (formEl.getAttribute('data-converly-processing') !== 'true') {
        writeDeferredMarker(formId);
      }
      cleanup();
    }

    function armPagehide() {
      if (pagehideArmed) return;
      try {
        window.addEventListener('pagehide', onPageHide, true);
        pagehideArmed = true;
      } catch (e) {
        // fall through
      }
    }

    function disarmPagehide() {
      if (!pagehideArmed) return;
      try { window.removeEventListener('pagehide', onPageHide, true); } catch (e) {}
      pagehideArmed = false;
    }

    function startTimer() {
      if (timer) clearTimeout(timer);
      // No navigation within the window → either AJAX already fired (page
      // stayed) or the submit was abandoned/rejected. Either way: clean up,
      // do not fire.
      timer = setTimeout(function () {
        if (!fired) cleanup();
      }, NATIVE_WINDOW_MS);
    }

    function cleanup() {
      if (timer) { clearTimeout(timer); timer = null; }
      disarmPagehide();
      var idx = activeWatchers.indexOf(entry);
      if (idx !== -1) activeWatchers.splice(idx, 1);
    }

    // A later submit on the same form refreshes the snapshot + verdict +
    // timer — a visitor who failed validation, fixed it, and re-submitted
    // inside the window is a fresh attempt.
    function refresh(nextFormId, nextPagehideTrusted) {
      if (fired) return;
      formId = nextFormId;
      if (nextPagehideTrusted) armPagehide();
      else disarmPagehide();
      startTimer();
    }

    // Only arm pagehide when the form was client-valid at submit time. An
    // invalid form cannot have submitted, so its pagehide must not confirm.
    if (pagehideTrusted) armPagehide();
    startTimer();
  }

  // Bubble-through-capture submit handler: arms the native watcher on every
  // WPForms submit attempt. This fires even when WPForms preventDefaults the
  // submit (the event still dispatches) — this is the moment we snapshot,
  // NOT the moment we fire. Capture phase is required: WPForms' own submit
  // handler stops the event's propagation before it reaches a bubble-phase
  // document listener (verified on a live form in the production module).
  function armNativeWatcher(event) {
    var formEl = event.target;
    if (!formEl || (formEl.tagName || '').toLowerCase() !== 'form') return;
    if (!isWPForm(formEl)) return;

    var formId = parseFormId(formEl);

    // Client-validity gate: only trust the pagehide path when the form is
    // valid (or checkValidity is unavailable). An invalid form cannot have
    // submitted, so its navigation must not be read as a success.
    var pagehideTrusted = true;
    try {
      if (typeof formEl.checkValidity === 'function' && formEl.checkValidity() === false) {
        pagehideTrusted = false;
      }
    } catch (e) {
      // constraint validation unavailable — the visible-error check at
      // pagehide time still guards this path
    }

    var existing = findWatcher(formEl);
    if (existing) {
      existing.refresh(formId, pagehideTrusted);
      return;
    }

    watchForSuccess(formEl, formId, pagehideTrusted);
  }

  document.addEventListener('submit', armNativeWatcher, true);

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', function () {
      processDeferredVerdict();
    });
  } else {
    processDeferredVerdict();
  }

  hookWpformsSuccess();
  if (!successHooked) {
    document.addEventListener('DOMContentLoaded', function () {
      hookWpformsSuccess();
    });
    window.addEventListener('load', function () {
      hookWpformsSuccess();
    });
  }
})();

Variable

Captures the ID of the form that was submitted, ready to reference in any tag. These are its exact settings.

Name

DLV - WPForms Form ID

Variable Type

Data Layer Variable

Data Layer Variable Name

form_id

Trigger

Fires when the 'wpforms_form_submitted' event is received. Use it as the trigger for any conversion tag. These are its exact settings.

Name

CE - WPForms Submitted

Trigger Type

Custom Event

Event Name

wpforms_form_submitted

Download This Recipe

Get the ready-to-import file and upload it into Google Tag Manager. Everything shown above (the tag, the variable and the trigger) comes pre-built inside.

Built from Converly's own WPForms detection code

Why It's the Best Recipe Out There

It uses the same detection code we run in our own conversion tracking product, Converly, just adapted to work inside Google Tag Manager.

Only fires after WPForms confirms the submission was accepted, so a visitor who fails validation (because they left a required field blank for instance) is never counted as a conversion.
Tracks both of WPForms' submission modes, including the page reload mode that most tracking scripts silently miss, even though it is WPForms' per-form default setting.
Live-verified on 5 July 2026 against a real WPForms site in its page-reload mode, firing exactly once on a genuine submission. Its AJAX mode couldn't be exercised on our test form, so that path rests on our production detection code.
Has been used to detect thousands of WPForms submissions by our customers.

How to Use the WPForms GTM Recipe

1

Install the Recipe

Download the recipe using the form above, then import it into your Google Tag Manager container. In GTM, go to Admin, choose Import Container, select the file you just downloaded, and pick the Merge option so it doesn't overwrite anything already in your container. The listener, variable and trigger are added for you automatically.

Importing the recipe container in Google Tag Manager
2

Configure Your Tags

With the recipe installed, you can use its Trigger for your conversion tags. Here are two common examples.

Example 1: Google Analytics

In Google Tag Manager, go to Tags and click New. Pick "Google Analytics: GA4 Event" as the tag type, choose your GA4 configuration tag (or enter your Measurement ID), and give the event a name like generate_lead. Then set its firing trigger to the recipe's trigger, CE - WPForms Submitted, and save. Every WPForms submission now sends that event to Google Analytics.

A GA4 event tag using the recipe's trigger

Example 2: Google Ads

First, create a conversion action in your Google Ads account (under Goals, then Conversions) so you have a Conversion ID and Conversion Label to work with. Back in Google Tag Manager, add a new "Google Ads Conversion Tracking" tag, paste in those two values, and set its firing trigger to CE - WPForms Submitted. Save and publish, and each of your WPForms submissions will trigger a conversion to be sent to Google Ads.

A Google Ads conversion tag using the recipe's trigger

What This Enables You to Do (and What It Doesn't)

This recipe fires an event in Google Tag Manager whenever a successful WPForms submission is detected. You can use it as a trigger to send conversions to Google Ads, Google Analytics and other tags.

What it does

Sends an Event to GTM

Detects successful WPForms submissions and pushes an event named 'wpforms_form_submitted' into your dataLayer automatically.

Tells You Which Form Fired

Captures the ID of the form that was submitted and passes it along with the 'wpforms_form_submitted' event, so a site running several forms can fire different tags for different forms.

Creates a Trigger in GTM

Gives you a 'Trigger' in Google Tag Manager that you can attach to any of your conversion tags.

What it doesn't do

Send the conversion server-side

It all fires client-side, so ad blockers can stop the tag loading and browser privacy limits can cut tracking down to as little as a day.

No Click IDs or Identifiers

It doesn't capture the Google click ID, Facebook click ID, Meta pixel cookies, IP address or user agent, which ad platforms need for Enhanced Conversions (Google) or a proper Event Match Quality score (Meta).

No Personal Details

The script only fires an event. It doesn't capture the lead's name, email or phone number, all of which Google and Meta rely on for enhanced conversions and a high match score.

No Logging or Notifications

You can test it with Tag Assistant when you install it, but unless you check every week you have no way of knowing if it quietly breaks.

The Proper Way to Send Conversions to Google Ads, Meta Ads & More

This recipe is fine if you only send events to Google Analytics, which doesn't accept personal information or IP addresses. But to get a strong Event Match Quality (EMQ) score in Meta, or use Enhanced Conversions in Google, you need to send the data server-side, along with the lead's name and email. That's what Converly does.

A Converly workflow sending WPForms conversions to ad platforms

Here's why Converly is the best way to send conversions to Google Ads, Meta Ads, Microsoft Ads, LinkedIn Ads, etc.

Easy to set up

Converly gives you a simple drag-and-drop builder for putting together a Conversion Flow. Just pick a trigger (like a WPForms form being submitted) and then pick your actions (like sending a conversion to Google Ads). No custom code, no complex configuration.

Sends Data Server-Side

Converly sends conversions straight from its servers to Google, Meta and the rest, rather than through the visitor's browser, so ad blockers and privacy restrictions can't get in the way.

Sends More Data

It captures the lead's name, email and phone along with the Google and Facebook click IDs, IP address and user agent, and sends them with every conversion. That unlocks Enhanced Conversions in Google Ads, or gets you an Event Match Quality (EMQ) score of 9 or 10 in Meta.

Full Conversion Log & Email Notifications

See every conversion, what was captured, what was sent to each platform and whether it landed. Get email alerts so tracking can't break without you knowing.

Supports Multiple Tools & Destinations

Detects submissions across 80+ form, scheduling and chat tools and forwards them to 20+ destinations including Google Ads, Meta, LinkedIn, Microsoft and TikTok. So the day you add a new tool or start advertising somewhere new, it's just a few clicks.

Provides Free Support

Our team has 20 years in conversion tracking and analytics and is on hand to help whenever you need it. With Google Tag Manager, you're on your own.

Start Your Free Trial

Start a 14-day free trial and track your conversions the proper way. No code, no complicated configuration.

About the author

Aaron Beashel
Aaron Beashel

Aaron is the founder of Converly. With over 15 years of experience in digital marketing and SaaS, he's passionate about helping businesses track and optimise their ad conversions.

Start your free trial

Easily send conversions to your ad platforms and analytics tools.
No code required.