Sign in

Wix — Form Submission Listener (No-Velo / Harmony-compatible)

Wix — Form Submission Listener (No-Velo / Harmony-compatible)

Captures Wix Forms submissions without Velo and pushes a normalized wix_form_submit event to window.dataLayer for GTM. Use this on sites built in the Wix Harmony editor (which has no Velo, Dev Mode, or Custom Elements), or anywhere you can't / don't want to touch site code.

If your site is in the classic Wix Editor or Wix Studio, prefer the Velo listener instead — it uses Wix's documented API and won't silently break when Wix changes their internals.

How it works (and the trade-off)

The script runs entirely in the page window, with two detection layers (both active; whichever fires first wins, the other is suppressed):

  1. DOM layer — when a submit button is clicked (or a native submit event fires), it snapshots the form's field values. It then watches briefly for Wix's success behavior — the fields being cleared or the form being removed/replaced — and pushes on success. Validation failures leave values in place, so they never push. This is the layer that works on Wix Harmony, where the submission HTTP request happens inside Wix's platform Web Worker and page-level network interception can never see it.
  2. Network layer — wraps fetch/XMLHttpRequest/navigator.sendBeacon, watches for Wix's form-submit request (/_api/...submit...), and — only when the server accepts the submission (2xx) — parses the request body and pushes. Richer payload when it works; silent on worker-based stacks like Harmony.

The trade-off: both layers read Wix's private, undocumented behaviors. Wix can change them without notice, at which point the listener goes quiet until re-tuned. Re-verify in GTM Preview after Wix platform updates, and especially before relying on it for revenue attribution.

Before you install: check Wix's built-in lead event

If GTM is connected through Wix's own marketing integration, native Wix forms may already push an automatic lead event to the dataLayer. Open GTM Preview, submit your form, and look for it. If it's there and you don't need field values (it carries none — no email/phone for Meta CAPI matching), trigger your tags off lead and skip this listener entirely.

dataLayer output

Same event name and wix_* keys as the Velo listener, so GTM triggers and variables are interchangeable between the two:

dataLayer eventdataLayer variables
wix_form_submitwix_event_id, wix_email, wix_phone, wix_first_name, wix_last_name, wix_form_id, wix_detection, wix_endpoint, wix_form_data

wix_form_data holds every captured field. Its keys depend on the layer that fired (wix_detection: dom or network): the DOM layer keys by the input's name/aria-label/placeholder; the network layer keys by the request payload's dot-paths (e.g. submission.email). Inspect one submit in GTM Preview to see your form's exact keys, then read any field with a Data Layer Variable like wix_form_data.email. wix_endpoint (network layer only) records which request URL matched, for debugging.

Installation

Pick one of the two placements:

Option A: GTM Custom HTML tag (recommended)

Keeps the script versioned and pausable inside your container.

  1. GTM → TagsNewCustom HTML.
  2. Paste the contents of listener-inline.html.
  3. Triggering: All Pages — Page View.
  4. Name it cHTML - Wix Form Listener (No-Velo) and save.

(GTM itself must be installed on the site — on Harmony: Settings → Custom Code → Head, all pages.)

Option B: Wix Custom Code slot

  1. Wix dashboard → Settings → Custom Code+ Add Custom Code.
  2. Paste the contents of listener-inline.html.
  3. Place in Body - End, load on All pages, load once per visit.

Then: GTM trigger, variables, downstream tags

Identical to the Velo listener — follow its README from Step 3 (Custom Event trigger on wix_form_submit, DLVs, Meta Pixel eventID from wix_event_id for CAPI dedup).

Verify

  1. GTM Preview → submit a real form on the live/preview site.
  2. Confirm wix_form_submit appears with your field values in wix_form_data.

If nothing fires: debug mode

  1. In GTM Preview's Tags Fired, first confirm the Custom HTML tag itself fired on the page.
  2. Load the page with ?wix_listener_debug=1 appended to the URL (it can sit alongside GTM's own debug param: ?gtm_debug=...&wix_listener_debug=1) or run window.wixFormListenerDebug = true in the DevTools console. Then submit the form and read the [wix-form-listener] console lines — every click, snapshot, request, and push (or the reason for a skip) is logged:
    • "snapshot armed" but no "success behavior detected" → the DOM layer saw the click but the form never cleared/disappeared. Check whether the form shows a different success behavior (e.g. inline message with values kept) and report it.
    • A request logged but skipped → the log line says why (endpoint didn't match, body unparseable, non-2xx). If the endpoint didn't match, edit ENDPOINT_RE at the top of the script.
    • Nothing logged at all around the submit → the form probably lives in an iframe, where neither layer can see it. Confirm with DevTools → Elements: if the form is inside an <iframe>, this listener can't reach it from the parent page.

Known limitations

  • Fragile by nature. Undocumented endpoints and payload shapes; a Wix update can silently stop events. The Velo listener doesn't have this problem — use it where Velo exists.
  • Parsed body shapes: JSON, form-encoded, FormData, URLSearchParams, and JSON Blobs (including via sendBeacon). Multipart file-upload bodies aren't parsed.
  • The network layer can't see worker-side requests. On Wix Harmony the submission request runs inside Wix's platform Web Worker, so only the DOM layer fires there (wix_detection: dom). That's expected, not a bug.
  • DOM-layer edge cases. The success signal is "fields cleared or form removed within ~12s of the submit click". A page navigation right after a failed submit click could count as "form removed" and cause a false push; forms configured to keep values visible after success would be missed. Both are rare — verify your form's behavior once in GTM Preview.
  • Password fields are never captured. All other field types are.
  • Iframe-hosted forms are invisible to both layers.
  • Field paths are site-specific. wix_form_data mirrors whatever Wix's request looks like on your site — always confirm paths in GTM Preview before wiring DLVs.
  • Identity detection is best-effort. Email/phone by value pattern, first/last name by key name. Anything missed is still in wix_form_data.
  • PII lands in the dataLayer. Same caveat as the Velo listener: forward only what each downstream tag needs.
  • Same-page dedupe only. Duplicate requests within 3s are suppressed; there's no cross-pageview/localStorage dedupe.

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
<script>
/*!
 * Wix Form Submission Listener — No-Velo / Harmony-compatible (v2.1)
 * Loaded from https://github.com/whboggs/marketing-toolkit
 *
 * Detects Wix Forms submissions and pushes a normalized wix_form_submit
 * event to the GTM dataLayer. Runs entirely in the page window — no Velo,
 * no Custom Element — so it works in editors without Dev Mode (Wix Harmony).
 *
 * TWO DETECTION LAYERS (both run; first one to fire wins):
 *   1. DOM layer — snapshots field values when a submit button is clicked,
 *      then pushes when the form shows success behavior (Wix clears the
 *      fields or removes the form). This is the layer that works on Wix
 *      Harmony, where the submission HTTP request happens inside Wix's
 *      platform Web Worker and page-level network interception can't see it.
 *   2. Network layer — wraps fetch/XHR/sendBeacon and parses the form-submit
 *      request (/_api/...submit...) on a 2xx response. Richer payload when
 *      it works; silent on worker-based stacks like Harmony.
 *
 * WARNING: this reads Wix's private, undocumented behaviors, which Wix can
 * change without notice. Prefer the Velo listener
 * (../form-submission-listener/) on editors that support it, and re-verify
 * this one in GTM Preview after Wix platform updates.
 *
 * DEBUG MODE: add ?wix_listener_debug=1 to the page URL (works alongside
 * gtm_debug: ...?gtm_debug=123&wix_listener_debug=1) or set
 * window.wixFormListenerDebug = true in the console. Everything the listener
 * sees is logged with a [wix-form-listener] prefix, including why each
 * request/click was or wasn't treated as a form submit.
 *
 * 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 (either one, not both):
 *   - GTM Custom HTML tag, All Pages — Page View (recommended: pausable,
 *     versioned with your container), or
 *   - Wix dashboard > Settings > Custom Code > Body End, all pages.
 */
(function () {
  'use strict';

  // Network layer: Wix form submits POST to /_api/ services whose paths have
  // changed across Forms versions — match broadly on submit/submission and
  // let the payload guard reject non-form requests.
  var ENDPOINT_RE = /\/_api\/[^?#]*(submit|submission)/i;

  var DATALAYER_EVENT = 'wix_form_submit';

  // DOM layer: how long after a submit click we watch for success behavior,
  // and how often we check.
  var PENDING_TTL_MS = 12000;
  var POLL_MS = 400;

  // De-dupe within and across layers.
  var recent = {};
  var DEDUPE_MS = 3000;

  // ---------------------------------------------------------------------
  // Shared helpers
  // ---------------------------------------------------------------------

  function debugOn() {
    try {
      return window.wixFormListenerDebug === true ||
        /[?&]wix_listener_debug=1/.test(window.location.search);
    } catch (err) { return false; }
  }

  function debug() {
    if (!debugOn()) return;
    try {
      var args = ['[wix-form-listener]'].concat([].slice.call(arguments));
      console.log.apply(console, args);
    } catch (err) {}
  }

  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);
  }

  // Build the dataLayer event from a flat {key: value} field map.
  // Returns null when there's nothing user-entered (payload guard).
  function buildEvent(flat, formId, detection, source) {
    var email, phone, firstName, lastName;
    var stringValues = [];
    for (var key in flat) {
      var v = flat[key];
      if (typeof v !== 'string' || !v) continue;
      stringValues.push(v);
      var lk = key.toLowerCase();
      if (!email && looksLikeEmail(v)) email = v.trim().toLowerCase();
      if (!phone && looksLikePhone(v)) phone = v.replace(/\D/g, '');
      if (!firstName && /first[_.\-\s]?name/.test(lk)) firstName = v;
      if (!lastName && /last[_.\-\s]?name/.test(lk)) lastName = v;
    }
    if (!stringValues.length) return null;

    return {
      _dedupeKey: stringValues.sort().join('|'),
      event: DATALAYER_EVENT,
      wix_event_id: generateEventId(), // Meta Pixel + CAPI dedup key
      wix_email: email || undefined,
      wix_phone: phone || undefined,
      wix_first_name: firstName || undefined,
      wix_last_name: lastName || undefined,
      wix_form_id: formId || undefined,
      wix_detection: detection, // 'dom' | 'network' — which layer caught it
      wix_endpoint: source || undefined,
      wix_form_data: flat
    };
  }

  function pushEvent(evt) {
    if (!evt) return false;
    var key = evt._dedupeKey;
    delete evt._dedupeKey;
    var now = Date.now();
    if (recent[key] && now - recent[key] < DEDUPE_MS) {
      debug('duplicate submission suppressed', evt.wix_detection);
      return false;
    }
    recent[key] = now;
    debug('PUSHING', evt);
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push(evt);
    return true;
  }

  // ---------------------------------------------------------------------
  // Layer 1: DOM detection (works on Wix Harmony / worker-based stacks)
  //
  // On a submit-button click (or native submit event), snapshot the
  // container's field values. Then poll briefly: Wix signals success by
  // clearing the fields or removing/replacing the form. Validation failures
  // leave values in place, so they never push.
  // ---------------------------------------------------------------------

  var pendings = [];
  var pollTimer = null;

  function fieldLabel(el, idx) {
    return el.name || el.getAttribute('aria-label') || el.placeholder ||
      el.id || ('field_' + idx);
  }

  function snapshotContainer(container) {
    var els = container.querySelectorAll('input, select, textarea');
    var fields = {};
    var tracked = []; // visible text-entry fields we watch for "cleared"
    for (var i = 0; i < els.length; i++) {
      var el = els[i];
      var type = (el.type || '').toLowerCase();
      if (type === 'password' || type === 'submit' || type === 'button' ||
          type === 'reset' || type === 'file' || type === 'image') continue;
      var value;
      if (type === 'checkbox' || type === 'radio') {
        if (!el.checked) continue;
        value = el.value || 'checked';
      } else {
        value = el.value;
      }
      if (typeof value !== 'string') continue;
      value = value.replace(/^\s+|\s+$/g, '');
      if (!value) continue;
      fields[fieldLabel(el, i)] = value;
      if (type !== 'hidden' && type !== 'checkbox' && type !== 'radio' &&
          el.tagName.toLowerCase() !== 'select') {
        tracked.push({ el: el, value: value });
      }
    }
    return { fields: fields, tracked: tracked };
  }

  // The form container is the nearest ancestor that contains at least one
  // field element — no Wix-specific selectors, so it works whether or not
  // Wix renders a real <form> (form-app widgets often don't).
  function findContainer(fromEl) {
    if (!fromEl || !fromEl.closest) return null;
    var form = fromEl.closest('form');
    if (form) return form;
    var node = fromEl.parentElement;
    var depth = 0;
    while (node && node !== document.body && depth < 15) {
      if (node.querySelector &&
          node.querySelector('input:not([type="hidden"]), textarea, select')) {
        return node;
      }
      node = node.parentElement;
      depth++;
    }
    return null;
  }

  function armDomDetection(container, trigger) {
    var snap = snapshotContainer(container);
    if (!snap.tracked.length) {
      debug('dom: ' + trigger + ' seen but no user-entered field values — not armed');
      return;
    }
    // Re-arm replaces any pending for the same container (fresh values).
    for (var i = pendings.length - 1; i >= 0; i--) {
      if (pendings[i].container === container) pendings.splice(i, 1);
    }
    pendings.push({
      container: container,
      snap: snap,
      formId: container.id || container.getAttribute('data-form-id') || undefined,
      expires: Date.now() + PENDING_TTL_MS
    });
    debug('dom: ' + trigger + ' — snapshot armed, watching for success', snap.fields);
    if (!pollTimer) pollTimer = setInterval(pollPendings, POLL_MS);
  }

  function pollPendings() {
    var now = Date.now();
    for (var i = pendings.length - 1; i >= 0; i--) {
      var p = pendings[i];
      if (now > p.expires) {
        debug('dom: watch window expired without success behavior');
        pendings.splice(i, 1);
        continue;
      }
      var removed = !document.documentElement.contains(p.container);
      var cleared = false;
      if (!removed) {
        cleared = true;
        for (var j = 0; j < p.snap.tracked.length; j++) {
          var t = p.snap.tracked[j];
          if ((t.el.value || '').replace(/^\s+|\s+$/g, '') !== '') { cleared = false; break; }
        }
      }
      if (removed || cleared) {
        debug('dom: success behavior detected (' + (removed ? 'form removed' : 'fields cleared') + ')');
        pushEvent(buildEvent(p.snap.fields, p.formId, 'dom', undefined));
        pendings.splice(i, 1);
      }
    }
    if (!pendings.length && pollTimer) {
      clearInterval(pollTimer);
      pollTimer = null;
    }
  }

  // Native submit events (dispatched even when React preventDefaults).
  document.addEventListener('submit', function (e) {
    try {
      if (e.target && e.target.tagName === 'FORM') armDomDetection(e.target, 'submit event');
    } catch (err) {}
  }, true);

  // Clicks on any button-like element (covers JS-only forms with no <form>
  // submit dispatch). No type="submit" filter — Wix renders JS-handled
  // buttons as type="button" — and no harm arming on non-submit buttons:
  // nothing pushes unless the form then shows success behavior.
  // composedPath()[0] sees through open shadow roots, where e.target is
  // retargeted to the shadow host.
  document.addEventListener('click', function (e) {
    try {
      var el = (e.composedPath && e.composedPath()[0]) || e.target;
      if (!el || !el.closest) return;
      var btn = el.closest('button, input[type="submit"], [role="button"]');
      if (!btn) {
        return;
      }
      var container = findContainer(btn);
      if (!container) {
        debug('dom: click on button-like element but no field container found',
          btn.tagName, btn.getAttribute && btn.getAttribute('type'),
          (btn.textContent || '').trim().slice(0, 40));
        return;
      }
      armDomDetection(container, 'button click');
    } catch (err) {}
  }, true);

  // Enter key in a field — JS-managed forms may submit without a click or
  // a native submit event.
  document.addEventListener('keydown', function (e) {
    try {
      if (e.key !== 'Enter') return;
      var el = (e.composedPath && e.composedPath()[0]) || e.target;
      if (!el || !el.tagName) return;
      var tag = el.tagName.toLowerCase();
      if (tag !== 'input' && tag !== 'select') return;
      var container = findContainer(el);
      if (container) armDomDetection(container, 'enter key');
    } catch (err) {}
  }, true);

  // ---------------------------------------------------------------------
  // Layer 2: network interception (fetch / XHR / sendBeacon)
  // ---------------------------------------------------------------------

  // Normalize any request body shape to a plain object (or null).
  // Returns a Promise — Blob bodies (common with sendBeacon) read async.
  function bodyToObject(body) {
    try {
      if (!body) return Promise.resolve(null);
      if (typeof body === 'string') return Promise.resolve(parseText(body));
      if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
        return Promise.resolve(entriesToObject(body));
      }
      if (typeof FormData !== 'undefined' && body instanceof FormData) {
        return Promise.resolve(entriesToObject(body));
      }
      if (typeof Blob !== 'undefined' && body instanceof Blob && typeof body.text === 'function') {
        return body.text().then(parseText, function () { return null; });
      }
    } catch (err) {}
    return Promise.resolve(null);
  }

  function parseText(text) {
    if (!text) return null;
    try { return JSON.parse(text); } catch (err) {}
    // Not JSON — try form-encoded (a=1&b=2)
    if (/^[^=&\s]+=[^&]*(&|$)/.test(text)) {
      try { return entriesToObject(new URLSearchParams(text)); } catch (err) {}
    }
    return null;
  }

  function entriesToObject(iterable) {
    var out = {};
    var any = false;
    iterable.forEach(function (value, key) {
      if (typeof value === 'string') { out[key] = value; any = true; }
    });
    return any ? out : null;
  }

  // Flatten the request payload to dot-path keys ("submission.email"),
  // keeping primitive leaves only. GTM DLVs read wix_form_data.{path}.
  function flatten(obj, prefix, out, depth) {
    if (!obj || depth > 6) return out;
    for (var k in obj) {
      if (!Object.prototype.hasOwnProperty.call(obj, k)) continue;
      var v = obj[k];
      if (v === null || v === undefined) continue;
      var path = prefix ? prefix + '.' + k : k;
      var t = typeof v;
      if (t === 'string' || t === 'number' || t === 'boolean') {
        out[path] = v;
      } else if (t === 'object') {
        flatten(v, path, out, depth + 1);
      }
    }
    return out;
  }

  function handleNetworkSubmit(url, payload, status) {
    if (status < 200 || status >= 300) {
      debug('network: matched endpoint but non-2xx status', status, url);
      return;
    }
    if (!payload || typeof payload !== 'object') {
      debug('network: matched endpoint but request body was empty/unparseable', url);
      return;
    }
    var flat = flatten(payload, '', {}, 0);
    var formId = (typeof payload.formId === 'string' && payload.formId) || undefined;
    var evt = buildEvent(flat, formId, 'network', url.split('?')[0]);
    if (!evt) {
      debug('network: matched endpoint but payload had no string values — skipped', url, payload);
      return;
    }
    if (pushEvent(evt)) {
      // The network layer saw the real submission — the DOM layer would be
      // a duplicate for the same submit, so disarm it.
      pendings.length = 0;
    }
  }

  function inspect(kind, url, body, status) {
    if (!url) return;
    debug(kind, status !== undefined ? status : '(no status)', url);
    if (!ENDPOINT_RE.test(url)) return;
    bodyToObject(body).then(function (payload) {
      try { handleNetworkSubmit(url, payload, status); } catch (err) {}
    });
  }

  // --- fetch ---
  if (typeof window.fetch === 'function') {
    var origFetch = window.fetch;
    window.fetch = function (input, init) {
      var url = '';
      var body = null;
      var clonedTextPromise = null;
      try {
        if (typeof input === 'string') url = input;
        else if (input && typeof input.url === 'string') url = input.url;
        if (url && ENDPOINT_RE.test(url)) {
          if (init && init.body) body = init.body;
          else if (input && typeof input.clone === 'function') {
            // Request object with a body stream — read a clone, never the
            // original, so the real request is untouched.
            clonedTextPromise = input.clone().text().catch(function () { return null; });
          }
        }
      } catch (err) { /* never break the site's fetch */ }

      var result = origFetch.apply(this, arguments);
      result.then(function (res) {
        if (clonedTextPromise) {
          clonedTextPromise.then(function (text) {
            inspect('fetch', url, text, res.status);
          });
        } else {
          inspect('fetch', url, body, res.status);
        }
      }).catch(function () { /* request failure — nothing to track */ });
      return result;
    };
  }

  // --- XHR ---
  if (window.XMLHttpRequest && window.XMLHttpRequest.prototype) {
    var proto = window.XMLHttpRequest.prototype;
    var origOpen = proto.open;
    var origSend = proto.send;
    proto.open = function (method, url) {
      try { this.__wixFormUrl = typeof url === 'string' ? url : String(url || ''); } catch (err) {}
      return origOpen.apply(this, arguments);
    };
    proto.send = function (body) {
      var xhr = this;
      try {
        if (xhr.__wixFormUrl) {
          xhr.addEventListener('load', function () {
            inspect('xhr', xhr.__wixFormUrl, body, xhr.status);
          });
        }
      } catch (err) { /* never break the site's XHR */ }
      return origSend.apply(this, arguments);
    };
  }

  // --- sendBeacon ---
  // Beacons return only "queued" (true/false), never a response status —
  // treat a queued beacon to a matching endpoint as accepted.
  if (window.navigator && typeof window.navigator.sendBeacon === 'function') {
    var origBeacon = window.navigator.sendBeacon;
    window.navigator.sendBeacon = function (url, data) {
      var queued = origBeacon.apply(this, arguments);
      try {
        var u = typeof url === 'string' ? url : String(url || '');
        inspect('beacon', u, data, queued ? 200 : 0);
      } catch (err) { /* never break the site's beacons */ }
      return queued;
    };
  }

  debug('listener v2.1 installed — DOM layer + network layer, endpoint pattern:', String(ENDPOINT_RE));
})();
</script>