
Author Name
Emtiaz Hossain
WPForms has two distinct submission modes that behave completely differently for tracking purposes: AJAX forms that submit without a page reload, and non-AJAX forms that either redirect or reload the page on submit. Using the wrong pattern for the wrong mode means tags that either never fire or fire on every failed attempt.
WPForms fires no page reload for AJAX-enabled forms, so tracking has to hook into the form's own submit event directly, on the client, at the moment of submission.
<script>
var form = document.querySelector("#wpforms-form-5499") // Replace with your form ID
form.addEventListener('submit', function(event) {
var formData = new FormData(form);
var formDataObject = {};
formData.forEach(function(value, key) {
formDataObject[key] = value;
});
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'wpform_submission_v2',
formData: formDataObject
});
});
</script>This uses the native FormData API to grab every field on the form generically, no need to hardcode each field's selector individually the way the CF7 pattern does. That makes it faster to set up, but it also means the tag fires on the submit attempt itself, not on confirmed success, worth knowing if the form has server-side validation that can still reject a submission after this event fires client-side.
Non-AJAX WPForms submissions post the page directly, which opens up a different, more precise problem: multiple forms on the same site sharing overlapping ID prefixes, or validation that needs to be checked before the tag counts the submission as real.
<script>
(function() {
var forms = document.querySelectorAll('form[id^="wpforms-form-5530"]:not(.wpforms-ajax-form)');
forms.forEach(function(form) {
var formId = form.getAttribute('data-formid');
form.addEventListener('submit', function(event) {
event.preventDefault();
var form = this;
var formData = new FormData(this);
var wpFormData = {};
var errorRequired = false;
formData.forEach(function (value, key) {
if(key) {
var inputField = form.querySelector('[name="'+key+'"]');
if(inputField) {
var isRequiredField = inputField.classList.contains('wpforms-field-required');
if(isRequiredField) {
if(inputField.tagName === 'SELECT') {
var selectedOptionVal = inputField.options[inputField.selectedIndex].text;
if(!selectedOptionVal) {
errorRequired = true;
}
} else if((inputField.getAttribute('type') === 'email') && (!value || !value.includes('@'))) {
errorRequired = true;
}
else if(!value) {
errorRequired = true;
}
}
}
var formattedKey = key.replace(/\[|\]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
wpFormData[formattedKey] = value;
}
});
var requiredFieldSets = form.querySelectorAll('ul.wpforms-field-required');
requiredFieldSets.forEach(function(fieldSet){
if(fieldSet.querySelector('input[type="radio"]') || fieldSet.querySelector('input[type="checkbox"]')) {
if(!fieldSet.querySelector('input:checked')) {
errorRequired = true;
}
}
});
if(!errorRequired) {
window.dataLayer = window.dataLayer || [];
delete wpFormData['wpforms_nonce'];
dataLayer.push({event: 'wpform_submission_v2', formId: formId, inputs: wpFormData});
}
});
});
})();
</script>Three things this version does that the AJAX version doesn't need to:
Targets forms by ID prefix, not an exact match. form[id^="wpforms-form-5530"] catches every form starting with that ID, useful when WPForms renders slight ID variations across embeds of the same form on multiple pages, while :not(.wpforms-ajax-form) explicitly excludes any AJAX-mode instance so the two patterns don't double-fire on the same form.
Runs its own required-field validation before pushing. Non-AJAX forms redirect on native submit, so there's no server response to check afterward the way the CF7 pattern polls for one. Instead, this walks every field marked wpforms-field-required (including radio and checkbox groups, which need their own check since FormData only captures checked values) and sets an errorRequired flag if anything required is missing or invalid, blocking the dataLayer push entirely if validation would have failed.
Sanitizes field keys before pushing. WPForms names fields with bracket notation like wpforms[fields][1], this strips brackets and collapses underscores into clean, GTM-variable-friendly key names, and explicitly deletes the CSRF nonce field so it never ends up in reporting data.
wpforms-ajax-form class) and use the matching pattern.wpform_submission_v2, reading fields from either formData (AJAX mode) or inputs (non-AJAX mode), the two patterns push slightly different object shapes.Discussed this on LinkedIn
Found this helpful?
Drop a comment on the LinkedIn post – I read every reply and love connecting with tracking folks.
Book a free 30-minute call. I will review your current tracking setup, show you where conversions are leaking, and tell you exactly what I would fix.
A full GA4/GTM/Google Ads audit on a UK Shopify store spending £18,000+/month found duplicate conversion tags inflating reported purchases 2x, a dropped GCLID at checkout, and misconfigured variables silently degrading Enhanced Conversions. Here's the audit, the fixes, and the migration off a legacy GTM setup before Shopify's deprecation deadline.
How a UK marketing agency's LinkedIn Insight Tag upgrade turned into a full conversion audit, catching duplicate tags and stale triggers across eight lead-gen conversions.
Duplicate form conversions in GA4 are usually a reload firing the same tag twice, not a real second submission. Here's the Custom JavaScript variable that catches it, plus other common causes to rule out.