Growth-stage B2B SaaS

Robbie Maltby

AI-native go-to-market consultant

Most tracking problems are form problems

In brief: Iframe form embeds run in a separate browsing context, so the page’s analytics often can’t see the submission and click IDs don’t automatically make it into the CRM. There are two ways I normally deal with this: bridge the iframe back to the parent page with postMessage, or build the form in first-party markup and submit it to the marketing automation platform, with UTMs and click IDs carried through hidden fields.

Most tracking problems I fix turn out to be implementation problems.

A form is embedded in an iframe and the submission never reaches the page’s tracking. A conversion fires from a thank-you page and gets counted twice. A landing page is too slow.

That’s what I mean by marketing engineering. I build the forms, tracking and landing pages in code so conversions are measured properly and the pages stay fast.

One of the most common examples is the demo form.

It’s often an iframe from the marketing automation platform. GA4 sees the pageview but not the submission because it happened inside another document. So conversions get counted from visits to /thank-you instead. That could be the person who submitted the form, the same person refreshing, somebody opening a forwarded link or a crawler.

At the same time, the GCLID from the landing-page URL may never make it onto the lead record. Six weeks later the deal closes and there’s nothing reliable tying it back to the paid click.

The iframe itself isn’t unusual. It’s often just the default embed. The problem starts when that form is also feeding campaign optimisation, lead scoring and CRM reporting.

At that point, how the form is built matters just as much as the campaign around it.

Why iframe embeds break measurement

There are three separate problems.

Separate browsing context. An iframe is its own document. When the frame is cross-origin, the browser’s same-origin policy restricts what scripts on the parent page can read from it.1 So GTM running on the page can’t simply listen for a submit event inside the form.

The visitor can therefore view the page, complete the form and leave, while the parent page’s analytics never receives the event you actually care about.

Third-party storage. A vendor form loaded from another domain operates in a third-party context. Safari has blocked third-party cookies by default since 2020,2 and browser privacy controls have continued moving in the same direction.

That makes any attribution or visitor history the vendor is trying to maintain inside the frame less dependable than first-party storage on your own site.

Click IDs stay on the parent page. A gclid, fbclid or li_fat_id normally arrives in the landing-page URL. The embedded form doesn’t automatically inherit that URL. Unless you deliberately pass those values into the form, they never reach the lead record.

That matters later. The offline conversion loop depends on being able to connect a CRM outcome back to the original click.

Fix one: bridge the iframe with postMessage

Sometimes the iframe has to stay. It might be tied into an existing template, controlled by another team, or simply not worth replacing yet.

In that case, the usual fix is a small script inside the iframe that sends the submission event back to the parent page with postMessage.3 The parent page then fires the analytics event.

Your own page can’t inject that code into the vendor’s frame, so the sending side has to live somewhere the vendor executes after a confirmed submission: a thank-you view rendered inside the frame, a completion callback the vendor exposes, or a script slot in a template the vendor controls. Which of those exists depends on the platform.

Inside the frame, after a confirmed successful submission:

window.parent.postMessage(
  { event: 'form_submit', formId: 'demo-request' },
  'https://www.example.com'
);

On the parent page:

window.addEventListener('message', (e) => {
  if (e.origin !== 'https://go.example-map.com') return;

  if (e.data && e.data.event === 'form_submit') {
    window.dataLayer.push({
      event: 'form_submit',
      form_id: e.data.formId,
      event_id: crypto.randomUUID()
    });
  }
});

The origin checks matter. The sender should target the specific parent origin rather than *, and the receiver should verify e.origin before trusting the message. MDN recommends both when using postMessage across origins.3

I also give the successful submission an event_id. If the same conversion is later sent through a browser pixel and a server-side integration, that ID gives the receiving platform something to use for deduplication.

This gets the form event back onto the parent page. It doesn’t, by itself, solve the attribution fields. If the GCLID still never reaches the CRM record, you can count the submission accurately but you still can’t connect the eventual opportunity back to the ad click.

Fix two: build the form on your own site

Where I control the site, I prefer to build the form in first-party markup.

The visible form lives on the same origin as the landing page. On submit, the data is sent to whatever system needs the record. Pardot provides form handlers for this kind of setup,4 and HubSpot exposes form submission endpoints.5 Marketo gives you Forms 2.0 and API options, although the exact implementation depends on how much of its own form runtime you keep versus what you handle server-side.6

The important part is that the form the visitor interacts with belongs to the page.

If I build it myself, I control the markup, validation, autocomplete attributes, error states and the event emitted after a successful submission. I also take responsibility for some of the things the vendor embed was handling for me.

Spam is the obvious one. A honeypot, server-side validation, rate limiting and, where needed, a low-friction challenge such as Cloudflare Turnstile are enough for most B2B forms I build.7

Failed submissions need proper handling too. If the marketing automation endpoint fails, the visitor should get an explicit error and a retry path. I don’t want a conversion event firing because somebody clicked Submit. It should fire because the system confirmed that the submission succeeded.

Whether the browser can actually see that confirmation depends on the endpoint. Some accept a direct browser post and return a response the page can read. Others answer with a redirect, or with nothing the page’s JavaScript is allowed to inspect. Direct submission is fine for the first kind. When I want the conversion event to fire only after the platform has accepted the record, I post to a same-origin endpoint of my own, usually a small serverless function. It forwards the submission to the marketing automation platform, checks the upstream result, and returns a confirmed success or a usable error to the browser. The dataLayer event fires on that response, and the server-side validation and rate limiting live in the same place.

Once that’s in place, the form event is happening in the same page as the attribution data and the analytics stack. That removes a surprising amount of workaround code.

Hidden fields: carrying attribution into the CRM

This is the part I find missing most often.

On the visitor’s first pageview, a wee first-party script reads the campaign parameters from the URL:

  • utm_source
  • utm_medium
  • utm_campaign
  • utm_content
  • utm_term
  • gclid
  • fbclid
  • li_fat_id

I normally store the landing path, referrer and timestamp too.

For first-touch attribution, those initial values are written once and left alone. Last-touch values update when the visitor returns through another tagged campaign.

The storage layer also has to respect the site’s consent model. First-party does not automatically mean consent-free. Where storing marketing attribution identifiers needs consent, the script reads the parameters on arrival but only writes them to storage once the relevant consent state is available.

When the person eventually submits a form, the stored values are copied into hidden fields and submitted alongside the visible fields. They then become ordinary fields on the lead or contact record.

That gives me the connection I need between website activity and what happens later in the CRM.

It supports the offline conversion imports that send qualified stages and pipeline back to the ad platform.8 It gives CRM reporting a source field that doesn’t disappear when the browser session does. And it lets me compare which messages brought in which types of account.

When an audit finds “paid drives no pipeline,” the absence of this script is the first thing I check, because pipeline that can’t identify its click looks identical to no pipeline.

I normally keep the visible form fairly short, usually three or four fields when the buying motion allows it, and let the hidden fields carry the attribution data. Progressive profiling just means asking for less upfront. Get the few fields you need on the first form, then collect more later if the person comes back. On a custom form it’s a separate feature to implement. The platform’s native form may provide it automatically. First-party markup usually means building that logic myself, through the platform’s supported APIs or my own rules for recognising a returning visitor.

The exact number of visible fields isn’t a law. The point is to separate what the person needs to type from what the system already knows.

Stop using the thank-you page as the conversion event

A thank-you page can be useful. I just don’t use its pageview as proof that a form was successfully submitted.

Pageview counting can overstate conversions because the page can be refreshed, revisited, bookmarked, shared or prefetched. Those visits are indistinguishable from the original conversion if all you have is the URL.

It can also create attribution problems. A redirect moves the visitor away from the page where the submission happened, and some implementations make things worse by passing email addresses or other personal information through the query string.

The cleaner setup is to fire the conversion event when the form receives a successful response:

window.dataLayer.push({
  event: 'form_submit',
  form_id: 'demo-request',
  event_id: crypto.randomUUID()
});

The event can carry whatever non-sensitive parameters the analytics and advertising setup genuinely needs.

I still use thank-you pages for the visitor: confirmation, next steps, a calendar link, perhaps an audience exclusion. I just don’t ask the pageview to prove that the conversion happened.

Page speed is part of acquisition economics

Forms are one part of the reason I like having control over the page itself. Performance is another.

Google’s Core Web Vitals give the useful thresholds, with Interaction to Next Paint replacing First Input Delay as a Core Web Vital in March 2024.9

Slow pages cost conversions, especially once you’re paying to send traffic to them. The Google-commissioned Deloitte/fifty-five study Milliseconds Make Millions looked at 37 brands across 30 million sessions. A 0.1-second improvement was associated with an 8.4% increase in retail conversions and 10.1% in travel, while lead-generation pages saw an 8.3% improvement in bounce rate.10

Those are consumer verticals, so carry the direction rather than the decimals into B2B.

For paid acquisition, the practical point is simpler. If I’m paying heavily for each visit, I don’t want the landing page spending unnecessary time loading plugins, trackers and JavaScript the campaign doesn’t need.

The build choice affects how much control I have over that.

ApproachBuild speedForm & tracking controlPerformance ceilingWho editsCharacteristic failure
WordPress + page builderFastPlugins and embeds, partial controlMedium-low; plugin JS accretesMarketingPlugin drift, iframe embeds return
Webflow / FramerFastCustom code allowed, form logic constrainedGoodMarketing/designComplex forms push you back to embeds
MAP-hosted landing pagesFastestNative forms, template lock-inLowMarketingSubdomain split breaks cross-domain tracking
Static in code (Astro et al.) + GitSlower first buildTotal: same-origin forms, dataLayer contract in the repoHighest; zero client JS by defaultThe operatorRequires an operator who ships code

I build on the last row.

This site is Astro, deployed statically. The forms are same-origin, and the event contract sits in the repository next to the components that emit those events. Most pages arrive as HTML rather than waiting for a client-side application to construct them.

That suits the way I work because I can change the campaign, page, form and tracking myself. It would be a worse operating model for a marketing team that needs every page edit to go through engineering.

The characteristic failure in that row is real. It only works when somebody on the marketing side can actually ship code.

The page and the tracking are part of the campaign

I treat the landing page, form and measurement setup as campaign work rather than implementation that happens afterwards.

The message determines what the page needs to say. The campaign determines which attribution values need to survive. The form has to carry those values into the CRM. And the CRM outcome has to make its way back to the ad platform if I want bidding to learn from pipeline rather than form fills.

Those pieces are easy to separate organisationally, but they still depend on one another technically.

That’s the main reason I like owning the chain. If I change the campaign objective, I can change the form event. If the attribution model needs another field, I can add it to the form and CRM mapping. If a landing page is slow, I can remove the thing making it slow rather than opening a ticket with another team.

If your demo form is an iframe today, that’s where I’d start. Replace or bridge that one form, capture the attribution fields properly, and make the successful submission the conversion event. You can do that without rebuilding the rest of the site, and it gives every downstream report a better starting point.

Tell me what you’re building and I’ll pick it up when capacity opens.

Sources

  1. MDN Web Docs, “Same-origin policy.” developer.mozilla.org (opens in new tab) ↩

  2. WebKit, “Full Third-Party Cookie Blocking and More,” March 2020. webkit.org (opens in new tab) ↩

  3. MDN Web Docs, “Window: postMessage() method.” developer.mozilla.org (opens in new tab) ↩ ↩2

  4. Salesforce, “Form Handlers” (Account Engagement / Pardot). help.salesforce.com (opens in new tab) ↩

  5. HubSpot Developers, “Submit data to a form (unauthenticated).” developers.hubspot.com (opens in new tab) ↩

  6. Adobe Marketo Engage, “Forms API Reference” (Forms 2.0). experienceleague.adobe.com (opens in new tab) ↩

  7. Cloudflare, Turnstile documentation. developers.cloudflare.com (opens in new tab) ↩

  8. Google Ads Help, “About offline conversion imports.” support.google.com (opens in new tab) ↩

  9. web.dev, “Interaction to Next Paint becomes a Core Web Vital on March 12,” 2024. web.dev (opens in new tab) ↩

  10. web.dev / Deloitte / fifty-five, “Milliseconds Make Millions,” 2020. web.dev (opens in new tab) ↩

← All writing