Track Wix Forms Submissions in Google Tag Manager with this Free GTM Recipe
A free, tested Google Tag Manager recipe that captures Wix 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 common way to send conversions to Google Ads, Google Analytics, etc when someone submits a form on your Wix site.
The problem is, it's quite difficult to set up. Wix renders and submits its forms with its own JavaScript (and has two different form systems that behave differently), which means Google Tag Manager's built-in form submission trigger doesn't work. This leaves you writing 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 install this Wix 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 Wix 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 Wix Forms submissions and pushes a 'wix_form_submitted' event to the dataLayer. It fires on All Pages, once per event, and this is the code inside it.
(function () {
if (window.__converlyRecipeWixForms) return;
window.__converlyRecipeWixForms = true;
window.dataLayer = window.dataLayer || [];
// How long, after a New-form Submit click, we wait for a success signal
// before giving up (treating it as a validation failure / no-op).
var SUCCESS_WINDOW_MS = 7000;
function fireConversion(formId) {
window.dataLayer.push({
event: 'wix_form_submitted',
form_id: formId || ''
});
}
// ---- Form detection ----
// New Wix Forms carry data-hook markers: the form itself is
// data-hook="form-<uuid>", fields are wrapped in
// data-hook="form-field-...", and the submit control is
// data-hook="submit-button".
function isNewWixForm(root) {
if (!root) return false;
try {
var hook = root.getAttribute ? root.getAttribute('data-hook') : null;
if (hook && hook.indexOf('form-') === 0 && hook.indexOf('form-field-') !== 0) {
return true;
}
if (root.querySelector &&
(root.querySelector('[data-hook^="form-field-"]') ||
root.querySelector('[data-hook="submit-button"]'))) {
return true;
}
} catch (e) {
// fall through
}
return false;
}
// Old Wix Forms render inputs as Wix components: id="input_comp-...".
function isOldWixForm(formEl) {
if (!formEl || !formEl.querySelector) return false;
try {
if (formEl.querySelector('input[id^="input_comp-"]')) return true;
} catch (e) {
// fall through
}
return false;
}
// ---- OLD Wix forms: native submit (Pattern A) ----
// Capture phase so this runs before Wix's own handler can clear/detach
// the form. New forms are explicitly excluded — they have no native
// submit and are handled by the click path below.
function handleSubmit(event) {
try {
var formEl = event.target;
if (!formEl || (formEl.tagName || '').toLowerCase() !== 'form') return;
if (isNewWixForm(formEl)) return; // click path owns new forms
if (!isOldWixForm(formEl)) return;
fireConversion(formEl.id);
} catch (e) {
// Never throw back into Wix's own form handling.
}
}
// ---- NEW Wix forms: submit-button click + confirmed success ----
// Watchers currently in flight, keyed by form root, so duplicate click
// events Wix emits for a single Submit press don't arm two watchers. A
// re-click on a watched root REFRESHES the existing watcher instead of
// being dropped, so a visitor who fails validation, fixes a field, and
// clicks again inside the window gets a fresh verdict.
var activeWatchers = [];
function findWatcher(root) {
for (var w = 0; w < activeWatchers.length; w++) {
if (activeWatchers[w].root === root) return activeWatchers[w];
}
return null;
}
function findNewFormRoot(btn) {
var root = btn.closest ? btn.closest('form') : null;
if (root && isNewWixForm(root)) return root;
if (btn.closest) {
var alt = btn.closest('[data-hook="form-root"]') || btn.closest('[data-hook^="form-"]');
if (alt && isNewWixForm(alt)) return alt;
}
return null;
}
// Sorts a candidate node into one of two evidence tiers:
// 'structural' — a marker Wix's own runtime stamps on success (a
// data-hook or class name containing "success") — sufficient alone.
// 'status' — readable confirmation-ish text (ARIA live region or
// English thank-you wording) — corroboration only, never sufficient
// alone (live regions also announce in-progress states).
// null — not evidence.
function classifySuccessNode(node) {
if (!node || node.nodeType !== 1) return null;
try {
var hook = node.getAttribute ? node.getAttribute('data-hook') : null;
if (hook && /error/i.test(hook)) return null;
if (node.getAttribute && node.getAttribute('aria-busy') === 'true') return null;
var cls = (typeof node.className === 'string' ? node.className : '') || '';
if (/error/i.test(cls)) return null;
if ((hook && /success/i.test(hook)) || /success/i.test(cls)) return 'structural';
var text = (node.textContent || '').trim();
if (!text) return null;
if (/error|required|invalid|try again|please enter|please fill/i.test(text)) return null;
var isSuccessText =
/thank|success|received|in touch|submitted|message sent|got it|appreciate|subscrib/i.test(text);
if (!isSuccessText &&
/^(submitting|sending|loading|processing|please wait|one moment)\b/i.test(text)) {
return null;
}
if (node.getAttribute && node.getAttribute('role') === 'status') return 'status';
if (isSuccessText) return 'status';
} catch (e) {
// fall through
}
return null;
}
// Wix marks failed fields with an "error" data-hook slot and/or
// aria-invalid="true" on the input — visible ones mean the submit was
// rejected.
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;
}
// Ancestor-aware hiding check, capped at the observed container.
function isConcealed(el, stopAt) {
var node = el;
var depth = 0;
while (node && node.nodeType === 1 && depth < 12) {
if (isNodeHidden(node)) return true;
if (node === stopAt) break;
node = node.parentNode;
depth++;
}
return false;
}
function hasVisibleValidationErrors(root) {
if (!root || !root.querySelectorAll) return false;
try {
var nodes = root.querySelectorAll('[data-hook*="error"], [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 watchForSuccess(root, formId, pagehideTrusted) {
var entry = { root: root, refresh: refresh };
activeWatchers.push(entry);
var fired = false;
var timer = null;
var observer = null;
var pagehideArmed = false;
// Corroboration state: a status/text confirmation was seen this
// attempt. Text alone never fires — it only lets the weak structural
// signals (form concealed / removed) confirm.
var statusSeen = false;
// Snapshot the controls that currently hold a value, so we can detect
// Wix clearing the form on success. Values themselves are never read
// for content — only used to test "was non-empty, now empty".
var tracked = [];
function snapshotTracked() {
tracked = [];
try {
var inputs = root.querySelectorAll('input, textarea, select');
for (var i = 0; i < inputs.length; i++) {
var el = inputs[i];
if ((el.type || '') === 'hidden') continue;
if ((el.value || '') !== '') tracked.push(el);
}
} catch (e) {
// fall through
}
}
snapshotTracked();
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(); // no success signal within window — not firing
}, SUCCESS_WINDOW_MS);
}
// A later click on the same root — Wix's duplicate click event for one
// press, or a real re-submit after the visitor corrected a failed
// field. Everything reflects the LATEST attempt.
function refresh(nextFormId, nextPagehideTrusted) {
if (fired) return;
formId = nextFormId;
statusSeen = false;
snapshotTracked();
if (nextPagehideTrusted) armPagehide();
else disarmPagehide();
startTimer();
}
function cleanup() {
if (timer) { clearTimeout(timer); timer = null; }
if (observer) { try { observer.disconnect(); } catch (e) {} observer = null; }
disarmPagehide();
var idx = activeWatchers.indexOf(entry);
if (idx !== -1) activeWatchers.splice(idx, 1);
}
function fire() {
if (fired) return;
fired = true;
fireConversion(formId);
cleanup();
}
function inputsCleared() {
if (!tracked.length) return false;
for (var i = 0; i < tracked.length; i++) {
var el = tracked[i];
if (el && el.isConnected && (el.value || '') !== '') return false;
}
return true; // every previously-filled control is now empty or gone
}
function noteEvidence(node, structuralOnly) {
if (!node || node === scope) return false;
var tier = classifySuccessNode(node);
if (tier === 'structural' && !isConcealed(node, scope)) {
fire();
return true;
}
if (!structuralOnly && tier === 'status') statusSeen = true;
return false;
}
function onMutations(muts) {
if (fired) return;
var removed = !root.isConnected;
if (!removed && inputsCleared()) { fire(); return; } // Wix reset the form
for (var i = 0; i < muts.length; i++) {
var added = muts[i].addedNodes || [];
for (var j = 0; j < added.length; j++) {
var node = added[j];
if (noteEvidence(node, false)) return;
if (node && node.nodeType === 3 && noteEvidence(muts[i].target, false)) return;
}
if (muts[i].type === 'attributes' && noteEvidence(muts[i].target, true)) return;
}
// Removal/concealment alone are too weak to fire — closing a modal or
// an SPA page swap looks the same with no submit behind it. A
// status/text message seen in the same attempt corroborates.
if (statusSeen && (removed || isConcealed(root, scope))) {
fire();
}
}
function onPageHide() {
// A visitor who failed Wix's validation and then left the page also
// produces a pagehide — visible error indicators mean no submit
// happened.
if (hasVisibleValidationErrors(root)) return;
fire();
}
// Scope the observer to the form's container — success messages appear
// here and form removal shows up as a childList change on the parent.
var scope = root.parentNode || document.body;
try {
observer = new MutationObserver(onMutations);
observer.observe(scope, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class', 'hidden', 'aria-hidden'],
});
} catch (e) {
// MutationObserver unavailable — rely on pagehide + timeout only
}
if (pagehideTrusted) armPagehide();
startTimer();
}
function handleClick(event) {
try {
var target = event.target;
if (!target || !target.closest) return;
var btn = target.closest('[data-hook="submit-button"]');
if (!btn) return;
var root = findNewFormRoot(btn);
if (!root) return;
var formId = root.getAttribute ? (root.getAttribute('data-hook') || root.id || '') : (root.id || '');
// A form invalid under native constraint validation at click time
// cannot have submitted, so a later pagehide must not confirm it.
// Structural success signals stay armed regardless — they only ever
// come from Wix itself, after an actual successful submit.
var pagehideTrusted = true;
try {
if (typeof root.checkValidity === 'function' && !root.checkValidity()) {
pagehideTrusted = false;
}
} catch (e) {
// constraint validation unavailable
}
var existing = findWatcher(root);
if (existing) {
existing.refresh(formId, pagehideTrusted);
return;
}
watchForSuccess(root, formId, pagehideTrusted);
} catch (e) {
// Never throw back into Wix's own click handling.
}
}
if (typeof document !== 'undefined' && document.addEventListener) {
document.addEventListener('submit', handleSubmit, true); // OLD forms
document.addEventListener('click', handleClick, true); // NEW forms
}
})();Variable
Captures the ID of the form that was submitted, so you can reference it in any tag. These are its exact settings.
Name
DLV - Wix Form ID
Variable Type
Data Layer Variable
Data Layer Variable Name
form_id
Trigger
Fires when the 'wix_form_submitted' event is received. Use it as the trigger for any conversion tag. These are its exact settings.
Name
CE - Wix Form Submitted
Trigger Type
Custom Event
Event Name
wix_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 Wix 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 Wix 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 else that's already in your container). The custom HTML Tag, the Variable, and the 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 it to fire on the recipe's Trigger (called CE - Wix Form Submitted). Every Wix 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 so that any new Wix form submissions are sent to Google Ads.

What This Enables You to Do (and What It Doesn't)
This recipe fires an event in Google Tag Manager whenever a successful Wix 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 Wix Forms submissions and pushes an event called 'wix_form_submitted' into your dataLayer automatically.
Tells You Which Form Fired
Captures an ID for the form that was submitted, which means you can set it up to fire different conversion Tags when different forms are submitted.
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
Everything happens in the visitor's browser, so ad blockers can stop the tag loading and browser privacy restrictions can limit tracking to as little as a day.
Doesn't capture click IDs or other 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).
Doesn't capture the lead's details
The script only fires a generic 'Form Submitted' event and does not capture the lead's name, email or phone number. Ad platforms like Google and Meta require these for Enhanced Conversions (Google) and a high match score (Meta), and without them, the conversions won't do much.
No Logging or Notifications
You can test it with GTM's Tag Assistant when you first set it up, and that will help you confirm it's working, but unless you check every week, you have no way of knowing if it breaks (you'll just see 0 conversions in your ad platform and they'll start bidding wildly to make up for the lost conversions).
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, ChatGPT 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 Wix Forms form being submitted) and then select your actions (like sending a conversion to Google Ads). No custom code, no complex configuration.
Sends Data Server-Side
Converly sends conversions directly from its servers to Google, Meta, LinkedIn, etc., so ad blockers and privacy restrictions can't get in the way.
Sends More Data
Converly 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, gives you an Event Match Quality (EMQ) score of 9 or 10 in Meta, and gives you a 100% match rate in platforms like ChatGPT Ads.
Full Conversion Log & Email Notifications
See every conversion, what was captured, what was sent to each platform and whether it was received. You can even set up email alerts so you get notified if anything breaks.
Supports Multiple Tools & Destinations
Converly detects submissions across 80+ form, scheduling, and chat tools and sends them to 20+ destinations, including Google Ads, Meta, LinkedIn, ChatGPT, Microsoft, and TikTok. So if you ever add a new tool or start advertising on a new platform, setting up conversion tracking is 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.
