Growth-stage B2B SaaS

Robbie Maltby

One operator, whole engine

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.

Here’s the failure I find most often, and the one founders least expect, because everything on the surface looks fine.

The demo form on the site is an iframe from the marketing automation platform. GA4 records the pageview and then nothing, because the submission happened inside a different document. Conversions are counted by visits to /thank-you, which might be the prospect who submitted the form, the same prospect refreshing, somebody opening a forwarded link, or a crawler.

Meanwhile, the GCLID Google added to the landing-page URL never made it onto the lead record. A deal closes six weeks later and there’s nothing reliable to connect it back to the click that paid for it.

None of this means the site was badly built. The embed was the default, and defaults win. But once forms start feeding campaign optimisation, scoring and CRM reporting, how they are built becomes part of the marketing system. That’s why engineering sits alongside positioning, demand generation, lifecycle automation and attribution in the engine.

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, window.postMessage gives the frame and parent page a browser-supported way to communicate across origins.3 The form tells the parent page that a successful submission happened, and the parent page 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, where a returning visitor is asked for something new instead of the same fields again, is a separate feature to implement on a custom form. 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

There’s also reasonable evidence that speed affects user behaviour. 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 operating guides