Browser tags miss leads. Ad blockers, consent banners, people closing the tab right after submitting, and thank-you pages that never load all mean that the generate_lead event in GA4 is often lower than the number of rows in your CRM. If you optimise paid campaigns on that number, you are optimising on a sample with a bias you cannot see.
A small, honest fix is to send the lead event from your server, at the moment the form submission is actually stored, using the GA4 Measurement Protocol. This post shows a minimal version and the pitfalls that make people give up on it.
What you need
- A GA4 web data stream, its Measurement ID (
G-XXXXXXX) and a Measurement Protocol API secret (Admin → Data streams → your stream → Measurement Protocol API secrets). - The visitor's GA client ID, captured in the browser and sent along with the form. Without it, GA4 cannot join the server event to the session that produced it.
Capture the client ID in a hidden field:
<input type="hidden" name="ga_client_id" id="ga_client_id">
<script>
gtag('get', 'G-XXXXXXX', 'client_id', (id) => {
document.getElementById('ga_client_id').value = id;
});
</script>
Send the event when the lead is saved
Node.js 18+ (built-in fetch), called right after your form handler writes the lead to the database:
const MP_URL = 'https://www.google-analytics.com/mp/collect';
export async function sendLeadEvent({ clientId, leadId, formName, value = 0 }) {
if (!clientId) return { skipped: 'no client id' }; // never invent one
const url = `${MP_URL}?measurement_id=${process.env.GA4_MEASUREMENT_ID}` +
`&api_secret=${process.env.GA4_API_SECRET}`;
const body = {
client_id: clientId,
events: [{
name: 'generate_lead',
params: {
form_name: formName,
lead_id: leadId, // lets you de-duplicate and reconcile with the CRM
value, currency: 'AED',
engagement_time_msec: 1, // makes the event count toward active users
},
}],
};
const res = await fetch(url, { method: 'POST', body: JSON.stringify(body) });
return { status: res.status };
}
Keep the API secret in an environment variable on the server, never in front-end code.
Validate before you trust it
The production endpoint returns 204 even for malformed payloads, so a "successful" request proves nothing. During development, post the same body to the debug endpoint, which returns a list of validation messages:
https://www.google-analytics.com/debug/mp/collect?measurement_id=...&api_secret=...
An empty validationMessages array is what you want. After that, confirm in GA4 DebugView, and then in the standard reports a day later.
Pitfalls
-
Double counting. If the browser tag also fires
generate_lead, you will count most leads twice. Pick one source for the event. A clean split: the browser keepsform_startandform_submit, and only the server sendsgenerate_lead. -
Consent. Server-side does not mean consent-free. If a visitor declined analytics, do not send the event. Store the consent state with the lead and check it before calling
sendLeadEvent. -
Timestamps. If you queue events and send them later, set
timestamp_micros. GA4 accepts events up to 72 hours in the past. - Spam. Send the event after your spam filter, not before. Otherwise the bot traffic you just blocked becomes "conversions".
The payoff
Once the event comes from the same place as the CRM row, the two numbers finally reconcile, apart from consent-declined leads, which you can count separately. That is the number worth importing into Google Ads or feeding to bidding. It is a small piece of the measurement work we do in performance marketing: fix what is counted before spending more on what is counted.
References
- Google for Developers: Measurement Protocol (GA4)
- Google for Developers: Validating events
- Google Analytics Help: Recommended events: generate_lead
Top comments (0)