Track Formidable Forms Submissions in Google Tag Manager with this Free GTM Recipe
A free, tested Google Tag Manager recipe that captures Formidable Forms submissions and turns them into a trigger you can use to fire conversions to Google Ads, Google Analytics and more.


Google Tag Manager is a fairly common way to send conversions to Google Ads, Google Analytics, etc when someone submits a Formidable Form on your website.
The problem is, it's quite difficult to set up. Formidable Forms submits via AJAX so Google Tag Manager's built-in form submission trigger doesn't work, which means you need 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 very experienced with Google Tag Manager, you're going to struggle.
Fortunately though, there is a simple solution.
You can simply install this Formidable Forms 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 successful Formidable Forms 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 Formidable Forms submissions and pushes a 'formidable_forms_submitted' event to the dataLayer. It fires on All Pages, once per event, and this is the code inside it.
(function () {
if (window.__converlyRecipeFormidableForms) return;
window.__converlyRecipeFormidableForms = true;
window.dataLayer = window.dataLayer || [];
var SUCCESS_WINDOW_MS = 10000;
var DEFERRED_MARKER_KEY = '__converlyRecipeFormidableNativePending';
var DEFERRED_MARKER_TTL_MS = 60000;
function isFormidableForm(formEl) {
if (!formEl) return false;
try {
if (formEl.closest && formEl.closest('.frm_forms')) return true;
} catch (e) {
// .closest not available — fall back to class check
}
var cls = ' ' + (formEl.className || '') + ' ';
return cls.indexOf(' frm-show-form ') !== -1;
}
function fireConversion(formId, formEl) {
// Both AJAX events (and the native path) can resolve to the same
// submission — dedupe with a short-lived flag on the form when it's
// resolvable. If it isn't resolvable we still fire (favors counting a
// real submission over silently dropping it).
if (!formEl && formId) formEl = document.getElementById('form_' + formId);
if (formEl) {
if (formEl.getAttribute('data-converly-processing') === 'true') return;
formEl.setAttribute('data-converly-processing', 'true');
setTimeout(function () { formEl.removeAttribute('data-converly-processing'); }, 2000);
disarmWatcher(formEl);
}
window.dataLayer.push({
event: 'formidable_forms_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;
}
// Formidable's standard error markers: .frm_error (per-field message),
// .frm_error_style (form-level rejection box), .frm_blank_field (state
// class on a required-but-blank field), [aria-invalid="true"].
function hasVisibleValidationErrors(scopeEl) {
if (!scopeEl || !scopeEl.querySelectorAll) return false;
try {
var nodes = scopeEl.querySelectorAll(
'.frm_error, .frm_error_style, .frm_blank_field, [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;
var cls = ' ' + (typeof node.className === 'string' ? node.className : '') + ' ';
if (cls.indexOf(' frm_blank_field ') !== -1) return true;
if ((node.textContent || '').trim()) return true;
}
} catch (e) {
// fall through
}
return false;
}
function hasServerRejectionMarkers() {
try {
var wrappers = document.querySelectorAll('.frm_forms');
for (var i = 0; i < wrappers.length; i++) {
if (hasVisibleValidationErrors(wrappers[i])) return true;
}
} catch (e) {
// fall through
}
return false;
}
function hasSuccessMessage() {
try {
var nodes = document.querySelectorAll('.frm_message');
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 events ----
var activeWatchers = [];
function findWatcher(formEl) {
for (var w = 0; w < activeWatchers.length; w++) {
if (activeWatchers[w].form === formEl) return activeWatchers[w];
}
return null;
}
function disarmWatcher(formEl) {
var existing = findWatcher(formEl);
if (existing && typeof existing.cleanup === 'function') existing.cleanup();
}
function handleAjaxSuccessEvent(event) {
var formId = '';
try {
if (event.detail && event.detail.frmVars && event.detail.frmVars.formID) {
formId = event.detail.frmVars.formID;
}
} catch (e) {
// detail not available
}
var formEl = formId ? document.getElementById('form_' + formId) : null;
if (!formEl) {
// Fallback: the first form inside the first .frm_forms wrapper.
try {
var wrappers = document.querySelectorAll('.frm_forms');
for (var i = 0; i < wrappers.length; i++) {
var f = wrappers[i].querySelector('form');
if (f) { formEl = f; break; }
}
} catch (e) {
// querySelectorAll unavailable — give up
}
}
fireConversion(formId, formEl);
}
// ---- 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 Formidable 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 (hasSuccessMessage()) {
fireConversion(marker.formId, null);
return;
}
// No Formidable markup either way. A success configured to redirect
// lands on a different path with no .frm_message — 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, null);
}
// Same page, no markers either way — ambiguous, do not fire.
}
function armNativeWatcher(formEl, formId, pagehideTrusted) {
var existing = findWatcher(formEl);
if (existing) {
existing.refresh(formId, pagehideTrusted);
return;
}
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 blocked by Formidable's own
// validation who then navigates away must not count.
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);
timer = setTimeout(function () {
if (!fired) cleanup();
}, SUCCESS_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: the visitor corrected a failed
// field and re-submitted. Refresh everything to the latest attempt.
function refresh(nextFormId, nextPagehideTrusted) {
if (fired) return;
formId = nextFormId;
if (nextPagehideTrusted) armPagehide();
else disarmPagehide();
startTimer();
}
if (pagehideTrusted) armPagehide();
startTimer();
}
// Bubble-phase submit listener — arms the native watcher on every
// Formidable submit attempt. This fires even when Formidable
// preventDefaults it (the event still dispatches) — this is the moment
// we snapshot, NOT the moment we fire.
function handleNativeSubmit(event) {
var formEl = event.target;
if (!formEl || (formEl.tagName || '').toLowerCase() !== 'form') return;
if (!isFormidableForm(formEl)) return;
var formId = '';
var m = /^form_(\d+)$/.exec(formEl.id || '');
if (m) formId = m[1];
// Client-validity gate: trust the pagehide path when the form is valid
// (or checkValidity is unavailable). NOTE per the source module:
// Formidable's required-ness is often enforced only server-side, so a
// form can pass checkValidity() while still blank — the pagehide-time
// visible-error check is the real safety net here, not this gate alone.
var pagehideTrusted = true;
try {
if (typeof formEl.checkValidity === 'function' && formEl.checkValidity() === false) {
pagehideTrusted = false;
}
} catch (e) {
// constraint validation unavailable — pagehide's visible-error check
// still guards this path
}
armNativeWatcher(formEl, formId, pagehideTrusted);
}
document.addEventListener('frmFormComplete', handleAjaxSuccessEvent, true);
document.addEventListener('frmBeforeFormRedirect', handleAjaxSuccessEvent, true);
document.addEventListener('submit', handleNativeSubmit, false);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
processDeferredVerdict();
});
} else {
processDeferredVerdict();
}
})();Variable
Captures the ID of the form that was submitted, ready to reference in any tag. These are its exact settings.
Name
DLV - Formidable Form ID
Variable Type
Data Layer Variable
Data Layer Variable Name
form_id
Trigger
Fires when the 'formidable_forms_submitted' event is received. Use it as the trigger for any conversion tag. These are its exact settings.
Name
CE - Formidable Forms Submitted
Trigger Type
Custom Event
Event Name
formidable_forms_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 Formidable Forms detection code
Why It's the Best Recipe Out There
It is built from the same detection code we use in our own conversion tracking product, Converly, just adapted to run inside Google Tag Manager.
How to Use the Formidable Forms GTM Recipe
Install the Recipe
Download the recipe using the form above, then import it into your Google Tag Manager container. In GTM, head to Admin, choose Import Container, select the downloaded file, 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.

Configure Your Tags
Now that the recipe is installed, you can use it as a Trigger for your conversion tags. Here are two common examples.
Example 1: Google Analytics
Create a GA4 event tag and set the trigger to be the one this recipe gives you (called CE - Formidable Forms Submitted). Every Formidable Forms submission now sends an event to Google Analytics.

Example 2: Google Ads
Add your Google Ads conversion tag and set it to fire on the recipe's trigger (called CE - Formidable Forms Submitted). This will send a conversion to Google Ads whenever a Formidable Form is submitted on your site.

What This Enables You to Do (and What It Doesn't)
This recipe fires an event in Google Tag Manager whenever a successful Formidable Forms submission is detected. You can use that as a trigger to send conversions to Google Ads, Google Analytics and other tags.
What it does
Sends an Event to GTM
Detects successful Formidable Forms submissions and pushes an event called 'formidable_forms_submitted' into your dataLayer automatically.
Tells You Which Form Fired
Captures the ID of the form that was submitted, so a site with several forms can fire different tags for different forms.
Creates a Trigger in GTM
Gives you a 'Trigger' in Google Tag Manager which you can use as the trigger for any of your conversion tags.
What it doesn't do
Doesn't send the conversion server-side
Conversions are sent through the visitor's browser, so ad blockers can stop the conversions from ever reaching your ad platforms and analytics tools, while browser privacy restrictions can limit tracking to as little as a day (so if someone clicks your ad on Monday and converts on Thursday the conversion can't be mapped back to the ad they clicked).
No Click IDs or Identifiers
It does not 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 does not capture the lead's name, email or phone number, all of which Google and Meta require for enhanced conversions and a high match score.
No Logging or Notifications
You can test it when you first set it up, but unless you check every week, you have no way of knowing if it breaks (until you see 0 conversions in your accounts 2 weeks later).
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 does not accept personal information or IP addresses. But to get a good Event Match Quality (EMQ) score in Meta, or use Enhanced Conversions in Google, you need to send the data server-side, together with the lead's name and email. That is what Converly does.

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 you can use to build a Conversion Flow. Simply select a trigger (like a Formidable Forms form being submitted) and then select your actions (like sending a conversion to Google Ads). You can even connect AI tools like Claude or ChatGPT, tell them to 'Setup conversion tracking in Formidable Forms' and they will do it all for you!
Sends Data Server-Side
Converly sends conversions directly from its servers to Google, Meta, ChatGPT, etc. so ad blockers and privacy restrictions can't get in the way.
Sends More Data
It captures the lead's name, email and phone plus the Google and Facebook click IDs, IP address and user agent, and sends them with every conversion. That enables Enhanced Conversions in Google Ads, or gives 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 was received. Get email alerts so tracking can't break without you knowing.
Supports Multiple Tools & Destinations
Detects submissions across 100+ form, scheduling and chat tools and sends to 20+ destinations including Google Ads, Meta, ChatGPT, LinkedIn, Microsoft and TikTok. So if you ever add a new tool or start advertising on a new platform, it's just a few clicks.
Provides Free Support
Our team has 20 years of experience in conversion tracking and analytics and is here 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 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.
