Blog

Contact Form 7 Conversion Tracking: 3 ways to do it

Learn three different ways to set up conversion tracking for Contact Form 7, and how to work out which one is right for you.

Contact Form 7 Conversion Tracking: 3 ways to do it

Are you pouring money into various ad campaigns without really knowing which of those efforts are the ones actually turning into leads? If that sounds familiar, you are in good company.

When you cannot see which campaigns are producing real leads (rather than just clicks and page visits), your budget quietly keeps flowing toward the channels that are not delivering.

The fix is proper conversion tracking, so a conversion gets logged every time someone submits one of your Contact Form 7 forms, tied back to the campaign that actually brought them to your site.

The tricky part is that there is more than one way to pull this off, and which one suits you depends on your goal. If all you need is to see submissions inside Google Analytics, there is a fast, free path. But the moment you want those same conversions flowing into Google Ads, Meta Ads, and similar platforms, you need something sturdier, and that is exactly where a lot of DIY setups quietly come apart.

In this article, we'll walk through 3 different ways to set up conversion tracking in Contact Form 7 and help you figure out which one fits your situation. Conversion tracking is what we build for a living here at Converly, and Contact Form 7 has a few quirks of its own, so we'll call them out along the way so you know what to watch for.

Method 1: Use the free Contact Form 7 Google Analytics plugin

Good for: Sending form submission events to Google Analytics 4.

If your only goal is to see submissions land in GA4, track your conversion rate, and figure out which channels and pages are driving them, this is genuinely all you need. Google Analytics does not need to know who submitted the form. It just needs to know that a conversion happened.

Unlike some form plugins, Contact Form 7 does not ship its own official Google Analytics add-on, and it does not have premium license tiers that gate features behind a paywall. Instead, the go-to option is a free, open source plugin called Contact Form 7 Google Analytics, maintained by a developer in the WordPress community and available to install straight from the WordPress plugin directory.

The plugin works sitewide the moment you turn it on. There is no need to build a separate feed for every form the way some other tools require. It listens for four possible outcomes on every Contact Form 7 form on your site (invalid, spam, mail sent, and mail failed) and can send an event to Google Analytics for any or all of them. For conversion tracking purposes, the one you actually care about is "mail sent", since that is the only outcome that reflects a real, successful submission.

It plugs into whatever Google Analytics setup you already have running, whether that is the standard gtag.js snippet, Google Tag Manager, or another analytics plugin like MonsterInsights. Here's how to set it up.

Step 1: Install and activate the plugin

From your WordPress dashboard, go to Plugins, then Add New, search for "Contact Form 7 Google Analytics", and install and activate it. There is nothing to configure to get it running. It starts tracking as soon as it is active.

Install the Contact Form 7 Google Analytics plugin from the WordPress plugin directory

Step 2: Choose which events you want sent

Open the plugin's settings and pick which of the four outcomes you want reported to Google Analytics. If you skip this step, it will send all of them by default. For clean conversion data, we would recommend enabling just "mail sent" (or "sent"), since the others reflect submissions that never actually succeeded.

The Contact Form 7 Google Analytics plugin options screen in WordPress

Step 3: Make sure your Google Analytics connection is live

Because the plugin rides on top of your existing analytics setup, double-check that your gtag.js snippet, GA plugin, or Google Tag Manager container is actually installed and firing on the page.

Step 4: Test it, then mark it as a Key Event in GA4

Submit a quick test entry on your form, then open GA4's Realtime report and look for an event called "Contact Form 7" with the action "sent" (or whichever label you chose) showing up in the recent events list. Once you can see it arriving, head to Admin, then Events (or Key Events) in GA4, find that event, and toggle it on as a Key Event. That tells GA4 to treat it as a conversion, so it appears in your acquisition and funnel reports instead of sitting there as an ordinary event.

Pros and cons

Pros:

  • It is free forever. There is no premium tier to unlock and no license to buy, which is not something every form plugin's GA integration can say.
  • It tracks every form on your site automatically the moment you activate it. No per-form feed setup required.
  • It is lightweight and privacy-friendly. The only data it sends is the submission result and the form's name, nothing personal.
  • It is open source, actively maintained, and works alongside popular GA plugins like MonsterInsights if you already use one.

Cons:

  • It sends the event through your browser (via gtag.js or GTM), so ad blockers and privacy-focused browsers like Safari can prevent it the covnersion from ever reaching Google Analytics. There is no server-side option like some paid form plugins offer.
  • It only talks to Google Analytics. It cannot push a conversion to Google Ads, Meta Ads, or anywhere else, so Methods 2 and 3 below are what you need if you are running paid ads.
  • Since the same event category fires for every form on your site, you'll need to filter by the event label (the form's name) if you only want one specific form to count as a conversion.
  • Getting a conversion from Google Analytics into Google Ads is technically possible, but it is a poor substitute for a real Google Ads conversion, since Ads needs far more matching data than Analytics collects (more on that below).

Method 2: Use Google Tag Manager

Good for: Sending Enhanced Conversions to Google Ads.

If you are spending money on Google Ads, simply counting submissions (as in Method 1) will not get you very far. Google Ads wants to match every conversion to the specific person who clicked your ad, and it can only do that when you send lead details (like their name, email, and phone number) with the conversion. Google calls this Enhanced Conversions.

That means a bare "a conversion happened" event is not enough for Google Ads. You need to capture the visitor's name and email when they submit the form, hash them with SHA-256 (as Google Ads requires), and pass them in.

Google Tag Manager is one way to build that, and here are the steps.

Step 1: Add a Custom HTML tag with a capture script

In GTM, create a new Custom HTML tag, paste in the snippet below, and set it to fire on 'All Pages'.

<!-- Converly GTM Recipe — Contact Form 7 submission listener (Method 2: with PII) This is the RICHER snippet used by the blog post's "Method 2: GTM with Enhanced-Conversions data". It captures the lead's email, first name, last name and phone alongside the submission event, which is what turns on Enhanced Conversions in Google Ads and lifts Meta's Event Match Quality. It shares its detection core with the event-only recipe snippet (blog-snippet.html): the exact same wpcf7mailsent success signal and the exact same form_id read, so the two can never drift on WHEN they fire or WHICH form fired. The only difference is that this one also reads the field values and pushes them. Source: ported from cdn/v1/modules/contact-form-7.js (the real Converly detection module), fire path + PII-identification pass. No Converly internals, no API keys — this runs standalone inside a GTM Custom HTML tag. Verified against: a real Contact Form 7 install (see HOW-IT-WORKS.md → "Live-verified"). Fires only on CF7's native DOM CustomEvent 'wpcf7mailsent'. Caveat: only fires for CF7's AJAX submission mode (the default since 5.4). Legacy non-AJAX (full page reload) setups never dispatch this event and cannot be tracked from the browser. Last verified: 2026-07-14 --> <!-- STEP 1 — Custom HTML tag. Create one GTM tag with this content, firing trigger: All Pages, tag firing option: Once per event. --> <script> (function () { 'use strict'; // Install once, even if GTM injects the tag more than once. if (window.__converlyCf7Method2Installed) return; window.__converlyCf7Method2Installed = true; window.dataLayer = window.dataLayer || []; var DEDUPE_WINDOW_MS = 2000; // ============================================================ // 1. Sensitive-field guard // ============================================================ // Passwords, card numbers, CVV / CVC, one-time codes, SSN and PIN are never // lead identifiers. They must never reach the dataLayer — not their value, // not even their field name. This check runs BEFORE a field is ever read or // stored, and it looks at the field type, its autocomplete hint, its name, // its aria-label, its placeholder AND its visible <label> text, so a masked // secret (a "Card Number" box under a generic field name) is still caught. var SENSITIVE_AUTOCOMPLETE = { 'current-password': true, 'new-password': true, 'one-time-code': true, 'cc-number': true, 'cc-csc': true, 'cc-exp': true, 'cc-exp-month': true, 'cc-exp-year': true, 'cc-name': true, 'cc-given-name': true, 'cc-family-name': true, 'cc-additional-name': true, 'cc-type': true }; var SENSITIVE_NAME_SUBSTRINGS = [ 'password', 'passwd', 'passcode', 'cardnum', 'ccnumber', 'creditcard', 'onetimecode', 'securitycode', 'socialsecurity' ]; var SENSITIVE_NAME_TOKENS = { otp: true, cvv: true, cvc: true, ssn: true, pwd: true, pin: true }; function normaliseKey(value) { return String(value || '').toLowerCase().replace(/[^a-z0-9]/g, ''); } function isSensitiveName(name) { if (!name) return false; var lower = String(name).toLowerCase(); var norm = normaliseKey(lower); for (var i = 0; i < SENSITIVE_NAME_SUBSTRINGS.length; i++) { if (norm.indexOf(SENSITIVE_NAME_SUBSTRINGS[i]) !== -1) return true; } // Token match: split on non-alphanumerics so "cvv" in "card_cvv" is caught // but "province" (which merely contains "vv"... it doesn't, but e.g. a name // that merely contains "pin" as a substring like "shipping") is NOT. var parts = lower.split(/[^a-z0-9]+/); for (var p = 0; p < parts.length; p++) { if (SENSITIVE_NAME_TOKENS[parts[p]] === true) return true; } return false; } function isSensitiveField(type, autocomplete, name) { if (type === 'password') return true; if (autocomplete) { var tokens = autocomplete.split(/\s+/); for (var t = 0; t < tokens.length; t++) { if (SENSITIVE_AUTOCOMPLETE[tokens[t]] === true) return true; } } if (isSensitiveName(name)) return true; return false; } // Resolves a field's human-visible <label> text (wrapping label, label[for], // or aria-labelledby). Fully defensive: returns '' on any failure so it can // never throw into form handling. function labelTextFor(el) { try { if (el.closest) { var wrap = el.closest('label'); if (wrap && wrap.textContent) return wrap.textContent; } var doc = el.ownerDocument; if (!doc) return ''; if (el.id && doc.getElementsByTagName) { var labels = doc.getElementsByTagName('label'); for (var i = 0; i < labels.length; i++) { var lf = labels[i].getAttribute && labels[i].getAttribute('for'); if (lf && lf === el.id && labels[i].textContent) { return labels[i].textContent; } } } var lb = el.getAttribute ? (el.getAttribute('aria-labelledby') || '') : ''; if (lb && doc.getElementById) { var ids = lb.split(/\s+/); var txt = ''; for (var k = 0; k < ids.length; k++) { var n = doc.getElementById(ids[k]); if (n && n.textContent) txt += ' ' + n.textContent; } if (txt) return txt; } } catch (e) { // DOM API unsupported — no label text available } return ''; } // Single gate used by BOTH the value-storing path and the value-reading path. function isSensitiveEl(el, type, name) { var ariaLabel = el.getAttribute ? (el.getAttribute('aria-label') || '') : ''; var placeholder = el.placeholder || ''; var labelText = labelTextFor(el); return isSensitiveField(type, (el.autocomplete || '').toLowerCase(), name) || isSensitiveName(ariaLabel) || isSensitiveName(placeholder) || isSensitiveName(labelText); } // ============================================================ // 2. Field-value pre-capture store — THE Contact Form 7 quirk // ============================================================ // Contact Form 7 resets the form the instant a submission succeeds. Verified // live on CF7 6.1.6: the fields are still filled while the wpcf7mailsent // event's own handlers run, but CF7 wipes them immediately afterwards — they // are already blank by the very next tick. So anything but a synchronous, // in-handler read (a deferred GTM tag, an async callback, a Data Layer // Variable evaluated when a later tag fires) reads empty strings. We remove // the dependence on read timing entirely: listen for input / change on each // field, stash the latest value as the visitor types (keyed by form), and // read from that stash when the success event fires. // fieldStore[formKey] = { 'your-email': 'a@b.com', ... } var fieldStore = {}; var instrumentedForms = {}; function getFormKey(formEl) { var wpcf7Id = formEl.getAttribute('data-wpcf7-id'); if (wpcf7Id) return 'cf7_' + wpcf7Id; var id = formEl.id || formEl.getAttribute('name') || ''; if (id) return 'cf7_' + id; if (!formEl.getAttribute('data-converly-cf7-key')) { formEl.setAttribute( 'data-converly-cf7-key', 'cf7_' + new Date().getTime() + '_' + String(Math.random()).slice(2, 8) ); } return formEl.getAttribute('data-converly-cf7-key'); } function attachFieldListener(el, formKey) { var tag = (el.tagName || '').toLowerCase(); if (tag !== 'input' && tag !== 'textarea' && tag !== 'select') return; var type = (el.type || 'text').toLowerCase(); if (type === 'submit' || type === 'button' || type === 'reset' || type === 'image' || type === 'file' || type === 'hidden') { return; } var name = el.name || el.id || ''; if (!name) return; // Never store a secret. if (isSensitiveEl(el, type, name)) return; function store() { if (type === 'checkbox' || type === 'radio') { fieldStore[formKey][name] = el.checked ? (el.value || 'on') : ''; } else { fieldStore[formKey][name] = el.value || ''; } } el.addEventListener('input', store); if (tag === 'select' || type === 'checkbox' || type === 'radio') { el.addEventListener('change', store); } } function instrumentForm(formEl) { var key = getFormKey(formEl); if (instrumentedForms[key]) return; instrumentedForms[key] = true; fieldStore[key] = {}; var elements = formEl.elements; for (var i = 0; i < elements.length; i++) { attachFieldListener(elements[i], key); } } // ============================================================ // 3. Walk the form's fields, reading from the pre-capture store // ============================================================ function walkForm(formEl) { var fields = []; if (!formEl || !formEl.elements) return fields; var store = fieldStore[getFormKey(formEl)] || {}; for (var i = 0; i < formEl.elements.length; i++) { var el = formEl.elements[i]; var tag = (el.tagName || '').toLowerCase(); if (tag !== 'input' && tag !== 'textarea' && tag !== 'select') continue; var type = (el.type || 'text').toLowerCase(); if (type === 'submit' || type === 'button' || type === 'reset' || type === 'image' || type === 'file' || type === 'hidden') { continue; } var name = el.name || el.id || ''; if (!name) continue; // Never read a secret. if (isSensitiveEl(el, type, name)) continue; var value = (store[name] !== undefined) ? store[name] : (el.value || ''); // Read the field's visible label (used as an identification signal below). var label = ''; try { if (el.id) { var explicit = formEl.querySelector('label[for="' + el.id + '"]'); if (explicit) label = explicit.textContent.trim(); } if (!label && el.closest) { var parentLabel = el.closest('label'); if (parentLabel) { // Strip child controls so we keep the label text, not the input value. var clone = parentLabel.cloneNode(true); var kids = clone.querySelectorAll('span, input, textarea, select'); for (var j = 0; j < kids.length; j++) { kids[j].parentNode.removeChild(kids[j]); } label = clone.textContent.trim(); } } } catch (e) { // .closest not available in very old browsers — non-fatal } fields.push({ name: name, type: type, autocomplete: (el.autocomplete || '').toLowerCase(), value: value, label: label }); } return fields; } // ============================================================ // 4. Identify which fields are email / phone / first / last name // ============================================================ // Layered signals, strongest wins: // 100 explicit input type (type=email, type=tel) // 90 autocomplete attribute // 80 visible <label> text // 50 field name / id substring function scoreField(field) { var scores = { email: 0, phone: 0, firstName: 0, lastName: 0, fullName: 0 }; var type = field.type; var ac = field.autocomplete; var key = normaliseKey(field.name); var labelKey = normaliseKey(field.label); if (type === 'email') scores.email = 100; if (type === 'tel') scores.phone = 100; if (ac === 'email') scores.email = Math.max(scores.email, 90); if (ac === 'tel' || ac === 'tel-national' || ac === 'tel-local') { scores.phone = Math.max(scores.phone, 90); } if (ac === 'given-name') scores.firstName = Math.max(scores.firstName, 90); if (ac === 'family-name') scores.lastName = Math.max(scores.lastName, 90); if (ac === 'name') scores.fullName = Math.max(scores.fullName, 90); if (labelKey.indexOf('email') !== -1) { scores.email = Math.max(scores.email, 80); } if (labelKey.indexOf('phone') !== -1 || labelKey.indexOf('mobile') !== -1 || labelKey === 'tel' || labelKey.indexOf('telephone') !== -1) { scores.phone = Math.max(scores.phone, 80); } if (labelKey.indexOf('firstname') !== -1 || labelKey === 'first') { scores.firstName = Math.max(scores.firstName, 80); } if (labelKey.indexOf('lastname') !== -1 || labelKey === 'last' || labelKey.indexOf('surname') !== -1) { scores.lastName = Math.max(scores.lastName, 80); } if (scores.firstName === 0 && scores.lastName === 0 && (labelKey === 'name' || labelKey === 'fullname' || labelKey === 'yourname')) { scores.fullName = Math.max(scores.fullName, 80); } if (key.indexOf('email') !== -1) { scores.email = Math.max(scores.email, 50); } if (key.indexOf('phone') !== -1 || key.indexOf('mobile') !== -1 || key === 'tel' || key.indexOf('telephone') !== -1) { scores.phone = Math.max(scores.phone, 50); } if (key === 'firstname' || key === 'first' || key === 'fname' || key.indexOf('firstname') !== -1 || key.indexOf('givenname') !== -1) { scores.firstName = Math.max(scores.firstName, 50); } if (key === 'lastname' || key === 'last' || key === 'lname' || key === 'familyname' || key.indexOf('lastname') !== -1 || key.indexOf('surname') !== -1 || key.indexOf('familyname') !== -1) { scores.lastName = Math.max(scores.lastName, 50); } if (scores.firstName === 0 && scores.lastName === 0 && (key === 'name' || key === 'fullname' || key === 'yourname')) { scores.fullName = Math.max(scores.fullName, 50); } return scores; } function identify(fields) { var best = { email: { value: undefined, score: 0 }, phone: { value: undefined, score: 0 }, firstName: { value: undefined, score: 0 }, lastName: { value: undefined, score: 0 }, fullName: { value: undefined, score: 0 } }; for (var i = 0; i < fields.length; i++) { var field = fields[i]; var trimmed = String(field.value || '').trim(); if (!trimmed) continue; var scores = scoreField(field); for (var category in scores) { if (scores[category] > best[category].score) { best[category] = { value: trimmed, score: scores[category] }; } } } var result = {}; if (best.email.value) result.email = best.email.value; if (best.phone.value) result.phone = best.phone.value; if (best.firstName.value) result.firstName = best.firstName.value; if (best.lastName.value) result.lastName = best.lastName.value; // Full-name fallback: a single "Your Name" field splits on the first space. if (best.fullName.value && !result.firstName && !result.lastName) { var parts = best.fullName.value.split(/\s+/); result.firstName = parts[0]; if (parts.length > 1) result.lastName = parts.slice(1).join(' '); } return result; } // ============================================================ // 5. Fire — reuse the EXACT success signal + form_id logic from // the event-only recipe so the two never drift. // ============================================================ function readFormId(formEl) { // Modern Contact Form 7 (verified against 6.1.6 on a live site) puts // data-wpcf7-id on the WRAPPING <div class="wpcf7">, not on the <form> // element itself. Check the form first (older/other CF7 setups may still // stamp it there) and fall back to the wrapper. var formId = ''; try { if (formEl && formEl.getAttribute) { formId = formEl.getAttribute('data-wpcf7-id') || ''; } if (!formId && formEl && formEl.closest) { var wrap = formEl.closest('.wpcf7'); if (wrap && wrap.getAttribute) { formId = wrap.getAttribute('data-wpcf7-id') || ''; } } } catch (e) { // .closest unsupported in very old browsers — non-fatal, form_id stays '' } return formId; } function fireConversion(formEl) { // Dedupe seatbelt: caching / optimisation plugins can load CF7's script // twice, producing duplicate wpcf7mailsent dispatches for one submission. if (formEl.getAttribute('data-converly-processing') === 'true') return; formEl.setAttribute('data-converly-processing', 'true'); setTimeout(function () { try { formEl.removeAttribute('data-converly-processing'); } catch (e) {} }, DEDUPE_WINDOW_MS); var user = identify(walkForm(formEl)); // Clear this form's store now that we have read it. fieldStore[getFormKey(formEl)] = {}; var payload = { event: 'contact_form_7_submitted', form_id: readFormId(formEl) }; if (user.email) payload.email = user.email; if (user.firstName) payload.first_name = user.firstName; if (user.lastName) payload.last_name = user.lastName; if (user.phone) payload.phone = user.phone; window.dataLayer.push(payload); } // Fire path — Contact Form 7's own native DOM CustomEvent, dispatched on the // <form> element once an AJAX submission passes CF7's server-side validation // and spam checks, then bubbles to document. This is the ONLY path this // recipe tracks; a failed or spam-filtered submission fires wpcf7invalid / // wpcf7mailfailed instead and is correctly never counted. document.addEventListener('wpcf7mailsent', function (event) { var formEl = event && event.target; if (!formEl || (formEl.tagName || '').toLowerCase() !== 'form') return; fireConversion(formEl); }, false); // ============================================================ // 6. Instrument CF7 forms for pre-capture (now + as they appear) // ============================================================ // Attach the input listeners from step 2 to every CF7 form on the page, and // watch for forms added later (AJAX page transitions, modals). function scanAndInstrument() { var forms = document.querySelectorAll('form.wpcf7-form'); for (var i = 0; i < forms.length; i++) { instrumentForm(forms[i]); } } function setupObserver() { if (typeof MutationObserver === 'undefined') return; var observer = new MutationObserver(function (mutations) { for (var i = 0; i < mutations.length; i++) { var added = mutations[i].addedNodes; if (!added) continue; for (var j = 0; j < added.length; j++) { var node = added[j]; if (node.nodeType !== 1) continue; if ((node.classList && node.classList.contains('wpcf7-form')) || (node.querySelector && node.querySelector('form.wpcf7-form'))) { scanAndInstrument(); return; } } } }); observer.observe(document.body || document.documentElement, { childList: true, subtree: true }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { scanAndInstrument(); setupObserver(); }); } else { scanAndInstrument(); setupObserver(); } })(); </script> <!-- STEP 2 — Data Layer Variables. Create one for each value you want to use: Name: DLV - Contact Form 7 ID | Data Layer Variable Name: form_id Name: DLV - Email | Data Layer Variable Name: email Name: DLV - First Name | Data Layer Variable Name: first_name Name: DLV - Last Name | Data Layer Variable Name: last_name Name: DLV - Phone | Data Layer Variable Name: phone --> <!-- STEP 3 — Trigger. Create one Custom Event trigger: Name: Contact Form 7 Submission Event name: contact_form_7_submitted (does not match regex) --> <!-- STEP 4 — attach the trigger from Step 3 to your destination tag (Google Ads Enhanced Conversions, Meta Pixel, etc.) and map the Data Layer Variables from Step 2 into that tag's user-data / customer-data fields. That mapping is destination-specific and isn't included in this recipe. -->

Here's what the snippet does:

  • Detects form submissions the moment they succeed. Contact Form 7 submits in the background without reloading the page, so it never fires the browser's normal "form submitted" event. Instead, it fires its own success signal, called wpcf7mailsent, when the server accepts the submission. This code snippet listens for that success signal.
  • Captures the name, email and phone as the visitor types. The code watches the form's fields and pulls out the email address, the phone number, and the person's name. Contact Form 7 gives its fields readable names like your-email and your-name, which helps, but the code does not rely on that alone. It also looks at the field's type (an email field, a phone field) and reads the visible label sitting next to it, so it still identifies the right fields on a form with unusual naming.
  • Holds the details in a temporary spot until the form succeeds. Contact Form 7 clears the data from the form fields before it sends the success signal, so anything that tries to read the fields at that moment reads empty boxes. To get around it, the code quietly records each value as it is typed and holds it in a small holding area, labelled with that form's ID, ready for the moment it is needed.
  • Sends the data once Contact Form 7 confirms success. The code waits for the wpcf7mailsent success signal, which Contact Form 7 fires only after it has run its own validation and spam checks and shown the "Thank you for your message. It has been sent." confirmation. When that signal arrives, the code takes the lead's details it stashed earlier and posts them to the dataLayer as a contact_form_7_submitted event, along with the form's ID. If the submission is rejected (maybe it was flagged as spam, or the visitor entered an invalid email), that success signal never comes, so nothing is sent. This means a failed or invalid submission can never be counted as a conversion.

Many other Contact Form 7 snippets you find online get critical details wrong. Here's why the above snippet is the best:

It fires on the correct event

A lot of snippets listen for the form's submit event, or for Contact Form 7's wpcf7submit event. Both of those can fire on a submission that then fails validation or gets flagged as spam. Fire your Google Ads conversion there and you start counting rejected form fills as leads, which quietly inflates your numbers and teaches Google to chase the wrong people. This listener waits for wpcf7mailsent, the event Contact Form 7 fires only after a genuine, server-confirmed success.

It captures the data that Contact Form 7 wipes

Contact Form 7 clears every field in the form before it fires its success event. So a snippet that tries to read the email and name at success time reads empty boxes and captures nothing. This listener gets around that by capturing each field's value as the visitor types, holding it safely, and reading from that held copy the moment the success event fires. That is the difference between a snippet that looks like it works and one that actually passes real names and emails through for Enhanced Conversions.

It handles the real-world edge cases

There are many real-world edge cases that most other snippets (which haven't been as rigorously tested) miss. This script handles the following scenarios out of the box

  • Multiple Contact Form 7 forms on one page
  • Forms that aren't loaded with the initial page load (maybe because they are in a popup that is only triggered on a button click, for example)
  • Duplicate success events from caching or optimisation plugins, deduped so you count once.
  • Sensitive fields like passwords, card numbers, CVV, one-time codes, SSN and PIN are dropped before anything reaches the data layer, so you don't run into any privacy concerns

It has been battle-tested

This is not a snippet we threw together for this article. It is a version of the code we run inside Converly (our dedicated conversion tracking tool) to detect Contact Form 7 submissions and capture the data. It has been through many rounds of iteration to handle the edge cases above, and it has been tested across customer sites, which probably makes it one of the most tested and best-developed snippets for capturing Contact Form 7 submissions anywhere on the internet.

Step 2: Create Data Layer Variables for the captured data

Before going further, it helps to understand what the dataLayer actually is, since every remaining step depends on it.

Picture the dataLayer as a messageboard sitting between your website and Google Tag Manager. Your site drops notes onto it (someone just submitted a form, and here is their email and name), and GTM watches for those notes as they arrive. It is essentially a running feed of everything your site chooses to tell GTM.

That is exactly what the Step 1 script does. The moment a form is submitted successfully, it drops a note called contact_form_7_submitted onto the dataLayer, carrying the email, first_name, and last_name values it just captured.

Here is the catch, though. GTM can see that note land, but it will not automatically pull the individual values out for you. You have to tell it which ones to grab, and that is exactly what a Data Layer Variable does.

To set this up, go to the Variables section in Google Tag Manager, click New under User-Defined Variables, choose Data Layer Variable as the type, and give it a name.

Creating a Data Layer Variable named last_name in Google Tag Manager

You'll need three of these in total.

Variable 1:

  • Name: Email
  • Variable Type: Data Layer Variable
  • Data Layer Variable Name: email

Variable 2:

  • Name: First Name
  • Variable Type: Data Layer Variable
  • Data Layer Variable Name: first_name

Variable 3:

  • Name: Last Name
  • Variable Type: Data Layer Variable
  • Data Layer Variable Name: last_name

Step 3: Create a Custom Event trigger

Unfortunately, getting the event and the lead data into the dataLayer does not do anything on its own. GTM needs to be told to watch for it and treat it as the signal to fire your conversion tag. That signal is called a trigger.

To create it, head to the Triggers section in GTM, click New, choose the Custom Event type, and set the event name to contact_form_7_submitted, matching exactly what the script pushes. This is the trigger your Google Ads tag will fire on in the next step.

A Custom Event trigger in Google Tag Manager set to the contact_form_7_submitted event

Step 4: Turn on Enhanced Conversions in Google Ads

Head over to your Google Ads account, then go to Goals, then Conversions, then Settings, and switch on Enhanced Conversions.

When it asks how you would like to implement it, choose Google Tag Manager. That grants Google Ads permission to receive and use the customer data you're about to send it.

Turn on Enhanced Conversions in your Google Ads conversion settings and choose Google Tag Manager

Step 5: Create your Google Ads conversion tag and map the data

Back in GTM, open the Tags section, click New, choose Google Ads User Provided Data Event as the tag type, and map the variables you built earlier to the fields it asks for (Email, Phone, and so on).

The Google Ads User-provided Data Event tag set up in Google Tag Manager

Then set it to fire on the Custom Event trigger you built in Step 3.

The Google Ads tag set to fire on the Contact Form 7 custom event trigger

Once that's done, publish your GTM container. To check it is actually working, submit a test entry with GTM's Preview mode running and confirm the contact_form_7_submitted event shows up with the email and name filled in.

Pros and cons

Pros:

  • It's free, and it uses Google Tag Manager, which you may already have installed.
  • It captures the email and name, which is the minimum Google Ads Enhanced Conversions needs to start matching conversions back to ad clicks.
  • Like Method 1, it only counts genuine, completed submissions, since it is tied to Contact Form 7's own success event rather than a click or a page load.

Cons:

  • There are plenty of moving parts. Between the GTM tag, the trigger, the variables, the Google Ads settings, and the conversion tag itself, there is a lot that can be misconfigured, and a wrong mapping tends to fail quietly.
  • You own and maintain it. A Contact Form 7 update, a renamed field, or an unusual field type can quietly break the capture, and you will not get an error, just an empty conversion.
  • Sensitive data is a real risk if you go off script. Some tutorials show pushing every field in event.detail.inputs into the dataLayer at once. Do not do that, since the moment a file upload or another sensitive field lands there, any other script on the page can read it. (This is exactly why the snippet above only ever reads email and name.)
  • Forms that redirect to a separate thank you page rather than showing the confirmation inline can lose the conversion if the redirect fires before the dataLayer push has time to land.
  • It still runs in the browser, so ad blockers and privacy-focused browsers can stop it from firing.
  • Even set up perfectly, it misses the signals Google values most, since it only captures what is in the form, not click identifiers like the Google click ID (GCLID), the visitor's IP address, or their user agent. A browser cannot always see these on its own (it has no way to read its own visitor's IP address without asking a server), and they are exactly the signals that lift your Enhanced Conversions match quality. A dataLayer setup on its own cannot easily gather or send them.

That last point is what pushes a lot of people toward Method 3.

Method 3: Use Converly

Best for: Sending proper, server-side conversions to Google Ads, Meta Ads, LinkedIn Ads, GA4, and more, without wrestling with complicated Google Tag Manager configuration.

Converly is a dedicated conversion tracking tool built to make it simple to send proper, server-side conversions to Google Ads, Meta Ads, LinkedIn Ads, and more, every time someone submits a Contact Form 7 form on your site.

Converly flow builder connecting Contact Form 7 to Google Analytics, Google Ads, and Meta Ads

As you can see above, setting it up is genuinely simple. You choose a trigger (like a Contact Form 7 submission on your site) and then add one or more actions (send a conversion to Google Ads, send a conversion to Meta Ads, send an event to GA4, and so on). That's the entire setup. No custom code, and no wrangling Google Tag Manager triggers and variables.

You then install Converly on your website (by adding the code or installing the WordPress plugin), and it takes care of the rest. It listens for Contact Form 7 submissions, including ones that redirect to a thank you page, pulls out the name, email, and phone number, and sends it to Converly's own servers, which then relay it to Google Ads, Meta Ads, and beyond. Because that happens server-side, it cannot be blocked by ad blockers or privacy restrictions in browsers like Safari.

Here's the part that really matters if you're running paid campaigns. Alongside the name and email, Converly also captures the Google and Meta click IDs (GCLID and fbclid), Meta's browser cookies, the visitor's IP address, their user agent, and more, and sends all of it over to Google Ads, Meta Ads, and the rest with every conversion. These are precisely the signals Google and Meta reward, and having all of them is what takes your match quality from weak to strong (on Meta, that can take your Event Match Quality score from around 3 up to a 9 or 10).

Converly's conversion record showing the marketing attribution, click IDs, and browser data captured with each Contact Form 7 lead

Converly also gives you a full Conversion Log where you can see every lead that came through Contact Form 7, exactly what data was captured, what was sent on to your ad platforms, and whether it was received successfully.

Pros and cons

Pros:

  • Simple to set up. You just pick your trigger (like a Contact Form 7 submission) and then pick your actions (like sending a conversion to Google Ads). There's no custom code to be written, no pulling information from the dataLayer, etc.
  • Handles Contact Form 7's quirks automatically, so nothing silently breaks when the plugin updates or you add a new form.
  • Captures the click IDs, cookie IDs, IP address, and user agent and sends it to the ad platform, so it has a much better chance of matching the conversion back to the original ad click.
  • Sends the conversion server-side, so ad blockers and privacy browsers cannot stop your conversions.
  • Supports conditional logic, so you can fire different conversions for different forms on your site (say, only sending a conversion when your Quote Request form is submitted, and not your newsletter signup).
  • You connect your Google Ads, Meta Ads, etc to Converly in 1 click and then simply pick the conversion you want to fire, with no digging around for account IDs or conversion IDs.
  • Gives you a full conversion log showing every Contact Form 7 lead, what data was captured, and whether it made it to your ad platforms successfully.
  • Comes with support from a team who will actually help you get set up and troubleshoot problems, something you will not get from Google if you're relying on GTM.

Cons:

  • It's a paid tool once your free trial ends. Plans start at $19 per month, though most people make that back quickly, both in the time saved versus a manual setup and in the lower cost per lead that comes from having proper conversion data flowing into Google Ads and Meta Ads.

What not to do

We've covered the three best ways to track Contact Form 7 conversions and when each one makes sense, but it's just as useful to know which approaches to steer clear of, since some of the most common DIY methods quietly produce bad data. Here are the ones to avoid.

Tracking thank you page visits

This is the most common DIY approach we see, and the one we'd steer you away from the most. The idea is to redirect people to a dedicated thank you page after they submit, then count visits to that page as conversions. It sounds tidy, but there are a lot of ways it goes wrong in practice.

  • It fires for people who never submitted anything. A thank you page is just a URL, and a URL can be reached without a person ever completing your form. People bookmark it, the browser history or autocomplete might accidentally send them there, get sent the link by a colleague, or find it indexed in Google. Your own team hits it while testing. Every one of those gets logged as a conversion, even though no form was ever submitted.
  • It can double-count real submissions. Refresh the thank you page and you get a second conversion. Hit back and revisit it, or find it again later in your history, and you get more false conversions. A single lead can end up being counted multiple times, which inflates your numbers and feeds Google and Meta's smart bidding incorrect data to optimize against.
  • It doesn't send the data the ad platforms actually need. When you track a page visit, you have no idea who actually submitted the form, so you cannot pass along the name, email, phone, GCLID, fbclid, and so on that Google Ads Enhanced Conversions or Meta's Conversions API need. That means even though you're technically sending conversions to these platforms, they're essentially useless because they don't carry the data the ad platforms need to match the conversion back to the original ad click.

The underlying problem is that a thank you page visit is a stand-in for a conversion, not the conversion itself. All three methods above fire on Contact Form 7's actual confirmed submission, so they stay tied to what genuinely happened.

Relying on GTM's built-in Form Submission trigger

When people first set up tracking in Google Tag Manager, the obvious move is to use its built-in Form Submission trigger, which listens for the browser's native ‘form submit’ event. On Contact Form 7, this causes more harm than good.

Contact Form 7 fires the browser's native ‘form submit’ event when the submit button is clicked, but if a required field is empty, an email address is malformed, or spam protection kicks in, the form submission is actually rejected. So when the visitor fixes the issue and submits again, that gets counted a second time. And if a spam bot submits your form 25 times, then you've got 25 conversions.

You need to use Contact Form 7's own wpcf7mailsent success event instead, which only fires once the form was successfully submitted, exactly like Converly does under the hood.

Using Google Analytics's automatic form tracking (Enhanced Measurement)

GA4 ships with a built-in Enhanced Measurement feature that claims to track form submissions automatically, with no setup required.

For Contact Form 7, this falls short in practice. Because Contact Form 7 submits via AJAX rather than a standard page load, it doesn't reliably pick up the form submit event. In fact, Enhanced Measurement has been widely reported to pick up a form_start event when someone begins filling out the form, but not the form_submit event that it should actually be grabbing. In other words, it can look like it's working but it's actually massively overcounting conversions, because it's counting when someone first interacts with the form, not if they actually submit it or not.

If you want actual form submissions recorded as conversions in GA4, use the plugin in Method 1 or use Converly.

Firing the conversion on the submit button click

Some setups we've come across fire the conversion when someone clicks the Submit button, usually with a click trigger in GTM pointed at the button itself. The problem is a click is not a submission. The visitor might have left a required field blank, mistyped their email, tripped spam protection, or hit any number of other snags. Every one of those clicks still counts as a conversion, even though the form submission was rejected. When they fix the error and click submit again, that's a second conversion for the same lead.

This is particularly bad if you're sending conversions to ad platforms like Google Ads or Meta Ads. If you send inflated conversion numbers back to Google Ads or Meta, then you're training their smart bidding to chase people who were never actually leads (which will result in fewer actual leads and a higher cost per lead because the platforms can't optimize your ad delivery).

So which method should I use?

Here's the quick version:

  • If you only need submissions showing up in Google Analytics, Method 1 (the free Contact Form 7 Google Analytics plugin) is the cleanest, simplest option, and it costs nothing.
  • If you're running Google Ads, Meta Ads, LinkedIn Ads, or similar, and you want the best conversion tracking with the least setup effort, use a tool like Converly.
  • If you only need conversions in Google Ads, don't mind some fiddly configuration, and don't have budget for a paid tool, Method 2 (Enhanced Conversions through Google Tag Manager) is probably your best bet. Just remember it only sends name, email, and phone, and misses the other signals Google uses for matching, like the GCLID, IP address, and user agent.

Wrap up

Getting conversion tracking right is really important, particularly if you're sending conversions to Google Ads, Meta Ads, and the like.

In fact, Google states that advertisers who set up proper server-side conversion tracking get an average 19% increase in conversions. Meta says that they get an average 23% increase in conversions on their platform.

And with tools like Converly, it's super easy to do. You just pick a trigger (like when a Contact Form 7 form is submitted), choose where you want the conversions sent, and add one snippet to your site.

Best of all, Converly is free to get started, and setup usually takes less than 10 minutes. Start your 14-day free trial today.

About the author

Aaron Beashel
Aaron Beashel

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.

Start your free trial

Easily send conversions to your ad platforms and analytics tools.
No code required.