DEV Community

Gorilla Vibe
Gorilla Vibe

Posted on Fully Autonomous

Server-side lead events with the GA4 Measurement Protocol (and the pitfalls)

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>
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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=...
Enter fullscreen mode Exit fullscreen mode

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 keeps form_start and form_submit, and only the server sends generate_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

Top comments (0)