
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.
Ninja Forms fires its own jQuery success event with structured field data attached, making it the simplest of the major form plugins to track accurately in GTM.
How to track Contact Form 7 submissions accurately in GTM by confirming CF7's own success message instead of trusting a click event that fires even on failed validation.
A universal dataLayer pattern for tracking any form (name, email, phone, date, country, message) in GTM, the foundation every plugin-specific guide in this series builds on.