Wix — Form Submission Listener
Wix — Form Submission Listener
Captures Wix Forms submissions on the front end and pushes a normalized
event to window.dataLayer, so GTM Custom Event triggers can fire downstream
tags (Meta Pixel Lead, GA4 conversion, Meta CAPI, etc.).
What it does
Subscribes to the Velo onWixFormSubmitted event for one or more Wix Forms,
normalizes the submitted fields, and pushes a wix_form_submit event to the
GTM dataLayer. It only fires after the server accepts the submission, so
failed or invalid submits never generate an event.
| Source | dataLayer event | dataLayer variables |
|---|---|---|
Wix Form onWixFormSubmitted (server-accepted submit) | wix_form_submit | wix_event_id, wix_email, wix_phone, wix_first_name, wix_last_name, wix_full_name, wix_form_id, wix_form_data |
wix_form_data is the full submission keyed by each field's label, so any
custom field is reachable via a dot-notation Data Layer Variable
(e.g. wix_form_data.Company).
Why there are two files
Velo frontend code runs in a sandbox where window.dataLayer is frequently
undefined — you can't reliably push to it from page code. So the work is
split:
listener.js— Velo page/site code. Listens for form submissions and builds the normalized payload.datalayer-bridge.js— a Wix Custom Element. Custom Elements run in the real page window (where GTM lives), so this one actually performs thedataLayer.push. The listener hands it the payload via adata-payloadattribute.
Prerequisites
- Classic Wix Editor or Wix Studio — NOT Wix Harmony. The Wix Harmony
editor (launched Jan 2026) has no Velo, no Dev Mode, and no Custom Elements,
so this listener cannot run there. On a Harmony site, use the
no-Velo listener instead, or check
whether Wix's built-in GTM integration already pushes its automatic
leadevent on form submit (no field values). - GTM installed on the Wix site. Add it via Settings → Custom Code (or
Wix's Marketing Integrations), firing on all pages in the
<head>. The bridge pushes to whateverdataLayerGTM created. - A site plan / setup that allows Velo (Dev Mode) and Custom Elements.
Installation
This is a one-time setup. The listener lives in masterPage.js, so it
runs on every page automatically (Step 2); the Custom Element only needs to
exist on pages that have forms — the listener no-ops on pages where it
isn't found (Step 1 covers site-wide placement options). To add a new form
later, you only append its ID to FORM_SELECTORS.
Step 1: Add the Custom Element (the bridge)
- In the Wix Editor, Add Elements (+) → Embed & Social → Custom Element.
- Select the element and click Choose Source. Two options:
- Velo file — with Dev Mode on, copy
datalayer-bridge.jsinto your site'spublic/custom-elements/folder (it must be in that exact directory to appear in the picker), then select it. Recommended — the code is pinned and can't change under you. - Server URL — paste this file's jsDelivr URL:
https://cdn.jsdelivr.net/gh/whboggs/marketing-toolkit/wix/form-submission-listener/datalayer-bridge.js(updates automatically as the toolkit improves, with the usual third-party-hosting caveat).
- Velo file — with Dev Mode on, copy
- Set Tag Name to
wix-datalayer-bridge— it must exactly match the name in the file'scustomElements.define(). - In the Properties & Events panel, give the element the ID
dataLayerBridge(matchesBRIDGE_SELECTORinlistener.js). It renders nothing, so its size and position don't matter. - Decide where it lives:
- Only some pages have forms? Just place the element on those pages (or only the one page). Nothing else needed.
- Site-wide, classic Wix Editor: right-click the element → Show on All Pages. The toggle is missing/disabled while the element sits inside a container, section, or the footer — drag it to page level first. Alternatively, place it in the footer, which appears on every page by construction.
- Site-wide, Wix Studio: there is no per-element "show on all pages" — place the element inside a Global Section (e.g. your global footer).
Step 2: Add the listener (Velo) — once, site-wide
- Turn on Dev Mode / Velo.
- Paste
listener.jsintomasterPage.js(Velo's Site Code, under Public & Backend). This runs on every page, so one paste covers every form on the site — no per-page code. (If you truly only ever want a single page tracked, you can paste it into that page's code panel instead, butmasterPage.jsis the recommended default.) - Edit the CONFIG block at the top:
FORM_SELECTORS— the ID of each Wix Form to listen to (e.g.['#wixForms1']). Click a form in the Editor to read its ID.BRIDGE_SELECTOR— leave as'#dataLayerBridge'unless you renamed the Custom Element.
Step 3: Create the GTM trigger
In GTM, go to Triggers → New:
- Trigger Type: Custom Event
- Event name:
wix_form_submit - Name:
Custom Event - Wix Form Submit
Step 4: Create dataLayer variables
For each value you want downstream, Variables → User-Defined → New:
- Variable Type: Data Layer Variable
- Data Layer Variable Name:
wix_email(orwix_phone,wix_form_data.Company, …) - Name:
DLV - Wix Email
Step 5: Fire a downstream tag
Example — a Meta Pixel Lead event on every Wix form submit:
- Trigger:
Custom Event - Wix Form Submit - Tag: Meta Pixel Lead. Pass
{{DLV - Wix Email}}/{{DLV - Wix Phone}}as user-data, and use{{DLV - Wix Event ID}}(wix_event_id) as the PixeleventIDso a Meta CAPI copy of the same event dedupes correctly.
To fire only for a specific form, add a trigger condition on wix_form_id.
Step 6: Preview and publish
- Preview the Wix site and open GTM's Preview / Tag Assistant.
- Submit a Wix form — confirm
wix_form_submitappears in the event stream with the expected variables. - Publish the Wix site and the GTM container.
Known limitations
- Wix Forms only.
onWixFormSubmittedfires for native Wix Forms elements. Custom HTML forms, third-party embeds (Typeform, JotForm, etc.), and the Wix Bookings/Stores checkouts do not trigger it. - Field-value payload includes PII. Unlike the Gravity Forms listener, this one intentionally forwards submitted values (email/phone are the point of a lead event). That means names, emails, and any free-text answers land in the dataLayer and every downstream GTM tag — only forward what your tags need, and keep PII out of analytics tags that shouldn't receive it.
- Identity detection is best-effort.
wix_email/wix_phoneare detected by value pattern; names by field label containing "first"/"last"/"name". Odd field labels may not map — read the raw value fromwix_form_datainstead. - Requires the Custom Element bridge. Pushing to
window.dataLayerstraight from Velo is unreliable; if you skipdatalayer-bridge.js, nothing reaches GTM. - No server-side submission details. The frontend event has field
names/values only — not the Wix contact ID or server submission timestamp.
Use a Velo backend
onFormSubmithandler if you need those.
FAQ
Why can't the bridge just be a GTM Custom HTML tag? It's only an HTML element.
Because the element's real job isn't hosting HTML — it's being addressable
from Velo. Velo code runs in a sandboxed worker with no DOM access; the only
things in the page window it can talk to are elements registered in Wix's own
element tree, via $w(). That is exactly the channel the listener uses:
$w('#dataLayerBridge').setAttribute('data-payload', …).
A GTM Custom HTML tag injects a script into the page, but that script is not
in Wix's element tree — $w() can't see it, so Velo has no way to hand it the
form data. The Custom Element is less "the thing that pushes to dataLayer" and
more "the only door between Velo's sandbox and the page window"; the push is
just what it does when data comes through the door.
Could the bridge go somewhere else in Wix — like Settings → Custom Code?
No. Scripts added via Settings → Custom Code run in the page window, but
they aren't in $w() scope either, so Velo can't reach them — same problem as
a GTM tag. Velo's only other page-window channel is the HTML iframe embed
(postMessage), but Wix hosts those iframes on a different origin, so the
iframe can't touch the parent page's dataLayer — and it would still be an
on-page element anyway. If Velo is doing the listening, a Custom Element is
the mechanism.
(Wix's wixWindowFrontend.trackEvent("CustomEvent", …) used to be a
no-element path to the dataLayer when GTM was installed through Wix's
marketing integrations, but Wix has deprecated it and it only emits legacy
Universal-Analytics-style events — not something to build new tracking on.)
Does this have to use Velo at all? Can't I do it all in one GTM tag?
You can avoid Velo and the Custom Element entirely by doing everything in a
single GTM Custom HTML tag (fire on All Pages) that detects the submission
by intercepting Wix's form-submit network request — that exists as the
no-Velo listener in this toolkit. The
trade-off is reliability: it reads Wix's private, undocumented submission
endpoint and payload shape, which Wix changes without notice — so it can go
blind after a Wix update. This listener uses the documented
onWixFormSubmitted API instead — which gives clean, structured field data —
and that is why it needs the two-piece (Velo + Custom Element) setup.
Do I have to add this to every page that has a form?
The code, no — the listener lives in masterPage.js and runs everywhere
automatically. The Custom Element must exist on each page where a tracked form
lives: either place it there directly, or make it site-wide once (classic
Editor: Show on All Pages or the footer; Wix Studio: a Global Section —
see Step 1). Adding a new form later is just one more ID in FORM_SELECTORS.
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
/*!
* Wix dataLayer Bridge — Custom Element
* Loaded from https://github.com/whboggs/marketing-toolkit
*
* Companion to listener.js. Velo frontend code can't reliably push to
* window.dataLayer, but a Wix Custom Element runs in the real page window
* (the same context GTM loads in), so it can. The Velo listener sets a
* `data-payload` attribute on this element; the element parses it and
* pushes to the GTM dataLayer.
*
* Created by whboggs — https://whboggs.com — Get in touch for a free tracking audit.
*
* MIT License — Copyright (c) 2026 W.H. Boggs
* https://github.com/whboggs/marketing-toolkit/blob/main/LICENSE
*
* SETUP (Wix Editor):
* 1. Add Elements (+) > Embed & Social > Custom Element.
* 2. Choose Source > "Velo file": put this file in public/custom-elements/
* and select it. (Or "Server URL": paste this file's jsDelivr URL.)
* 3. Set the Tag Name to: wix-datalayer-bridge
* 4. Give the element the ID used in listener.js (default: dataLayerBridge).
* It renders nothing, so position/size don't matter.
* 5. The element must exist on each page with a tracked form. Site-wide:
* classic Editor = right-click > Show on All Pages (or the footer);
* Wix Studio = place it in a Global Section. See README for details.
*/
class WixDataLayerBridge extends HTMLElement {
static get observedAttributes() {
return ['data-payload'];
}
connectedCallback() {
// Renders nothing — it's a transport, not UI.
this.style.display = 'none';
}
attributeChangedCallback(name, oldValue, newValue) {
if (name !== 'data-payload' || !newValue || newValue === oldValue) return;
var payload;
try {
payload = JSON.parse(newValue);
} catch (err) {
return;
}
if (!payload) return;
window.dataLayer = window.dataLayer || [];
window.dataLayer.push(payload);
}
}
if (!customElements.get('wix-datalayer-bridge')) {
customElements.define('wix-datalayer-bridge', WixDataLayerBridge);
}
/*!
* Wix Form Submission Listener (Velo)
* Loaded from https://github.com/whboggs/marketing-toolkit
*
* Captures Wix Forms submissions in Velo page/site code and forwards a
* normalized payload to GTM's dataLayer for GTM / Meta CAPI tracking.
*
* Velo frontend code cannot reliably reach window.dataLayer (it runs
* sandboxed and dataLayer is frequently undefined there), so the push
* itself is handled by the companion Custom Element in datalayer-bridge.js,
* which runs in the real page window. This file only listens + normalizes.
*
* Created by whboggs — https://whboggs.com — Get in touch for a free tracking audit.
*
* MIT License — Copyright (c) 2026 W.H. Boggs
* https://github.com/whboggs/marketing-toolkit/blob/main/LICENSE
*
* WHERE TO PASTE
* - Recommended: masterPage.js (Velo "Site Code / Public & Backend >
* masterPage.js"). Runs on every page, so one paste covers
* every form on the site — no per-page code.
* - Single page: that page's code panel (Velo "Page Code"), only if you
* want to track a form on one page and nowhere else.
*/
// ---------------------------------------------------------------------------
// CONFIG — edit these to match your site
// ---------------------------------------------------------------------------
// Element IDs of the Wix Form(s) to listen to. In the Editor, click a form
// and read its ID from the top-left of the Properties & Events panel.
var FORM_SELECTORS = ['#wixForms1'];
// Element ID of the Custom Element added from datalayer-bridge.js.
var BRIDGE_SELECTOR = '#dataLayerBridge';
// The dataLayer event name your GTM Custom Event trigger listens for.
var DATALAYER_EVENT = 'wix_form_submit';
// ---------------------------------------------------------------------------
// Listener
// ---------------------------------------------------------------------------
$w.onReady(function () {
FORM_SELECTORS.forEach(function (selector) {
var form;
try {
form = $w(selector);
} catch (err) {
// Selector isn't on this page — skip it silently.
return;
}
if (!form || typeof form.onWixFormSubmitted !== 'function') return;
// onWixFormSubmitted fires only after the server accepts the submission,
// so we never push a dataLayer event for a failed / invalid submit.
form.onWixFormSubmitted(function (event) {
var payload = buildPayload(selector, (event && event.fields) || []);
forwardToDataLayer(payload);
});
});
});
// ---------------------------------------------------------------------------
// Normalization — mirrors the GHL bridge so downstream GTM tags stay uniform
// ---------------------------------------------------------------------------
function buildPayload(formSelector, fields) {
var formData = {};
var email, phone, firstName, lastName, fullName;
fields.forEach(function (f) {
if (!f) return;
var label = f.fieldName || f.id || 'field';
var value = f.fieldValue;
// Full form dump, keyed by field label — custom fields are read
// downstream via wix_form_data.{label} dot-notation DLVs.
formData[label] = value;
var hay = (String(f.fieldName || '') + ' ' + String(f.id || '')).toLowerCase();
if (!email && looksLikeEmail(value)) email = String(value).trim().toLowerCase();
if (!phone && looksLikePhone(value)) phone = String(value).replace(/\D/g, '');
if (!firstName && /first/.test(hay) && /name/.test(hay)) firstName = value;
if (!lastName && /last/.test(hay) && /name/.test(hay)) lastName = value;
if (!fullName && /name/.test(hay) &&
!/first|last|user|company|file|business/.test(hay)) fullName = value;
});
return {
event: DATALAYER_EVENT,
wix_event_id: generateEventId(), // Meta Pixel + CAPI dedup key
// Identity fields — best-effort detection across arbitrary form layouts
wix_email: email || undefined,
wix_phone: phone || undefined,
wix_first_name: firstName || undefined,
wix_last_name: lastName || undefined,
wix_full_name: fullName || undefined,
// Context
wix_form_id: formSelector.replace(/^#/, ''),
// Full payload — access any field via wix_form_data.{label}
wix_form_data: formData
};
}
function looksLikeEmail(v) {
return typeof v === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim());
}
function looksLikePhone(v) {
if (typeof v !== 'string') return false;
if (!/^[\d\s()+\-.]+$/.test(v.trim())) return false;
var digits = v.replace(/\D/g, '');
return digits.length >= 7 && digits.length <= 15;
}
function generateEventId() {
return String(Date.now()) + '_' + Math.random().toString(36).substring(2, 15);
}
// ---------------------------------------------------------------------------
// Hand-off to the Custom Element (which runs in the page window and can
// actually reach window.dataLayer). Data is passed via an attribute; because
// every payload carries a unique wix_event_id the value always changes, so
// attributeChangedCallback fires on every submit.
// ---------------------------------------------------------------------------
function forwardToDataLayer(payload) {
var bridge;
try {
bridge = $w(BRIDGE_SELECTOR);
} catch (err) {
return;
}
if (!bridge || typeof bridge.setAttribute !== 'function') return;
bridge.setAttribute('data-payload', JSON.stringify(payload));
}