GTM postMessage Bridge and Listener
postMessage Bridge for iframes in GTM — Sandboxed, Parameterized, and Client-ID Aware
dataLayer events across an iframe boundary using window.postMessage(), implemented with GTM Sandboxed JavaScript Custom Templates rather than hand-rolled inline JavaScript wherever possible. The JSON can be downloaded from Google Drive below.
A third-party iframe embedded on your page — a Pardot form, a HubSpot form, a scheduling widget, a payment step — runs its own separate browsing context with its own GTM container and its own dataLayer. Nothing that happens inside that iframe is visible to the parent page's GTM container, and nothing the parent page knows is visible to the iframe. They are two completely isolated tag-management setups that happen to share a rectangle of screen space. If a visitor clicks "Submit" inside that embedded form, your parent-site GA4 property never hears about it unless something deliberately carries that event across the origin boundary.
window.postMessage() is the browser API built for exactly this: two windows in different origins exchanging plain-text messages, with each side choosing which origins it trusts. Simo Ahava published the original GTM implementation of this pattern years ago, and I'm using it successfully in a live Pardot implementation today:
▶ Video walkthrough · How Do I Use the postMessage Method With Cross-Site iframes? — Simo Ahava
His article remains the clearest explanation of the underlying mechanics — why evt.origin checking matters, what evt.source buys you, and why a sender/receiver pair of GTM tags is the right shape for the problem. If you haven't read it, start there.
Why I Built a Variant of This
Inspired by Simo's original and earlier work, I reworked the implementation with three goals:
- Less reliance on Custom HTML / hand-written JavaScript. Nearly all of the sender and receiver logic now lives in GTM Sandboxed JavaScript Custom Templates instead of inline
<script>tags. - More reliance on Sandboxed JavaScript. Template code executes inside GTM's own sandbox and is not subject to CSP
script-srcSHA-256 hashing — it's already covered by thewww.googletagmanager.comallowance your site needs regardless. - Fully parameterized configuration. Target origin, namespace, event allow/exclude lists, the iframe's element ID, and the wrap key are all template fields — not literal values buried in a script body. Porting the recipe to a new site or a new iframe means editing template fields, not rewriting JavaScript.
Decreasing reliance on Custom HTML shrinks — but does not eliminate — the CSP SHA-256 surface. Two small Custom HTML tags remain, one on each side: a Sender Bootstrap on the child/iframe site that performs the actual window.top.postMessage() call, and a Bootstrap Listener on the parent site that registers the single window.addEventListener('message', …) handler. Both exist only because sandboxed JavaScript's access_globals permission cannot reach predefined browser globals like top, window, or parent directly — a Custom Template is not allowed to call window.top.postMessage() itself. Both scripts' literal text is fixed and configuration-free — they read everything they need from plain globals written by their companion Custom Template — so you compute each SHA-256 hash once and it stays valid indefinitely, no matter how many times you reconfigure origins, namespaces, or allow-lists.
Passing the Client ID
This recipe also carries the parent page's GA4 client_id into the iframe, via the _ga query-string parameter on the iframe's src. This uses the URL-decoration mechanism from an earlier recipe of mine (linked below) — the same permitted-host, same-config-object approach, just aimed at an <iframe src> instead of an outbound <a href>.
Passing the client ID is principally for use with the GTM configuration settings inside the iframe's own GTM container. Reading that value back out on the child side gives the child site's GA4 config tag the option to use the parent's client_id instead of generating its own — which gives the receiving property the opportunity to associate the iframe's traffic with the same pseudo_user_id as the parent site. It is not the same session, and it is not full cross-domain session stitching — but it is enough to connect the embedded-content activity back to the same underlying visitor identity, where the receiving side chooses to use it.
Parent Site vs. Child Site — One Download, Two Destinations
Everything in this export is labeled either Parent Site or Child Site. Elements marked Parent Site belong in the GTM container installed on the page that hosts the <iframe>. Elements marked Child Site — along with the shared Google Tag configuration settings that set client_id from the URL query string — belong in the GTM container installed inside the iframe's own page.
I've put both halves into a single container JSON purely for convenience of downloading and importing in one pass. To actually run both halves inside one shared container (rather than importing into two separate containers), do two things:
- Add a hostname condition to the Parent Site triggers so they fire only when
{{Page Hostname}}equals your parent site's host. - Add a hostname condition to the Child Site triggers so they fire only when
{{Page Hostname}}equals your child/iframe site's host.
Without one of those two approaches, the Parent-Site and Child-Site logic will both attempt to evaluate on every page load in whichever container you import them into — which is harmless (the sender finds no matching dataLayer events on a page with no iframe events firing, and vice versa) but wasteful and confusing to debug in GTM Preview mode.
What's Inside the Container
The exported container (gtm-postmessage-bridge-listener.json) is organized into five folders, twelve tags, six triggers, and fourteen variables. Two Sandboxed JavaScript Custom Templates — postMessage Bridge - Sender Configuration and postMessage Bridge - Receiver — do the real work, supported by two small Custom HTML bootstrap tags and the UTM/URL-decoration components carried over from earlier recipes.
Folders
| Folder | Side | Contains |
|---|---|---|
postMessage Bridge Sender | Child Site | The Sender Configuration template tag, the Sender Bootstrap Custom HTML tag, the diagnostic tag, the all-events custom trigger, the required (unattached) Click trigger, and the referral-host constant. |
UTM & URL Processing | Child Site | Writes UTM query-string values to session cookies on the child page, plus the _ga query-string reader/validator that feeds the GA4 config tag's client_id override. |
Analytics | Shared | GA4 initialization tag, Browser Client ID variable, Measurement Stream ID constant, and the Google Tag Shared Configuration Settings variable used by the child-site GA4 config tag. |
postMessage Bridge Receiver | Parent Site | The Receiver template tag, the Bootstrap Listener Custom HTML tag, the diagnostic tag, the Initialization trigger, and the custom event trigger that catches the bridged iframe.gtm.click event. |
UTM & URL Processing | Parent Site | The URL Decoration Config, its "set global" tag, the DOM listener/scanner tag, the target-host constant, the UTM cookie-pair reader, and the trigger pair that decorates the iframe's src with UTM values and the GA4 client_id. |
Tags
| Tag | Side | Fires On | Purpose |
|---|---|---|---|
postMessage Bridge - Sender Configuration | Child | Custom Event — regex .* (all events) | Scans new dataLayer entries since it last ran, drops anything in excludeEvents (defaults to gtm.js, gtm.dom, gtm.load), keeps only events in eventAllowlist (gtm.click as exported), wraps each surviving event under the namespace key, and stages the result on window.__gtmPmOutbox / window.__gtmPmTargetOrigin. A per-target cursor in template storage stops it from re-sending events it already staged. |
postMessage Bridge - Sender Bootstrap | Child | Same trigger, sequenced to run immediately after Sender Configuration (set as its setup tag) | The one piece of unavoidable Custom HTML on this side. Reads window.__gtmPmOutbox and window.__gtmPmTargetOrigin and calls window.top.postMessage(JSON.stringify(item), origin) for each staged item, because sandboxed JS cannot reach window.top directly. |
Diagnostic - Is postMessage Alive | Child | Same trigger | Off by default. Note in the tag: "diagnostic tag. turn it on to see in the dataLayer if the postMessage processing is alive." Logs outbox contents, target origin, and whether the page is actually running inside an iframe. |
Write UTMs to session cookies | Child | Initialization | Same UTM-cookie template used in the earlier URL-decoration recipe, scoped to {{Page Hostname}}, session-duration by default. |
Google Analytics Initialization | Shared | Initialization | Standard GA4 config tag, routed to {{Measurement Stream ID}} with the Shared Configuration Settings variable applied (see below). |
postMessage Bridge - Receiver | Parent | Initialization | Writes window.__gtmPmConfig (allowed origins + iframe element ID) and defines window.__gtmPmReceiver(), which validates the origin and namespace prefix, de-duplicates an exact repeat of the previous payload, and pushes the unwrapped event to the parent's dataLayer. Note on this tag: "note the importance of correctly coding the https://{{postMessage Target Host}}, the tag element you are tracking - iframe, and the kinds of events you want to receive from the iframe - the gtm.click events. these must all be correct for the solution to work." |
postMessage Bridge - Bootstrap Listener | Parent | Initialization, sequenced after Receiver | The parent-side Custom HTML counterpart. Registers a single window.addEventListener('message', …) handler that checks the message's origin against __gtmPmConfig.allowedOrigins, optionally confirms evt.source matches the specific iframe element's contentWindow, and hands off to window.__gtmPmReceiver(). |
Diagnostic - Is postMessage Alive | Parent | All Pages | Off by default. Note in the tag: "diagnostic tag. turn it on to see in the dataLayer if the postMessage processing is alive." Logs every raw incoming message, polls for the receiver config/function to appear, and watches dataLayer.push for anything landing with an iframe.-prefixed event name. |
Set URL Decoration Config Global | Parent | Setup tag only | Writes {{URL Decoration Config}} to window.gtmUrlDecoratorConfig before the listener below runs. Carried over from the URL-decoration recipe. |
URL Decoration Listener | Parent | DOM Ready | Scans the DOM for elements (including <iframe src>) pointing at a permitted host and, if found, pushes urlDecorationTargetFound to the dataLayer. This is what makes the iframe's src a decoration target in the first place — it must find your iframe on the page. |
The Decorate Target URLs tag and the Populate form … EXAMPLE tags are also present as reused/example scaffolding: the former is the same tag documented in the URL-decoration recipe (it appends the UTM cookie pairs and the GA4 client_id pair onto the iframe's src); the latter is a paused, illustrative Custom HTML tag on both sides showing one way a downstream form could read the passed-through _ga/utm_campaign cookie values into hidden fields. Neither needs to be enabled for the bridge itself to work.
Triggers
| Trigger | Side | Type | Notes |
|---|---|---|---|
postMessage Bridge - Custom Event - All Events (regex .*) | Child | Custom Event, {{_event}} matches RegEx .* | Deliberately unfiltered at the trigger level. Filtering by event name (the exclude list and the allow-list) happens inside the Sender Configuration template instead, so you never have to edit a trigger's regex to change which events get bridged — you edit a tag field. |
Click - Necessary to Create dataLayer gtm.click | Child | Click (unattached to any tag) | Note in the trigger: "if you are sending gtm.click from the iframe to the parent site, you must have at least the trigger for gtm.click in the iframe container - even if it is not attached to a tag. the absence of the trigger means the absence of the gtm.click event in the dataLayer and resultingly no iframe.gtm.click will be sent by postMessage." GTM only pushes gtm.click into the dataLayer at all when at least one Click trigger exists in the container — this trigger exists purely to satisfy that requirement. |
DOM Ready - All Pages | Parent | DOM Ready | As exported, unconditional — fires on every parent page. Change this — see below. |
Click from iframe - iframe.gtm.click | Parent | Custom Event, {{_event}} equals iframe.gtm.click | Note in the trigger: "the custom event sent by postMessage from the iframe. note that the components tie back to the 'iframe' and 'gtm.click' configuration parameters in the child / iframe. you now have the full payload of the iframe gtm.click in the parent dataLayer. this can be altered for anything captured in the child / iframe based on the event_name in the child / iframe dataLayer." This is the hook point for any downstream parent-side GA4 event tag you want to fire off the bridged click. |
URL decoration target found | Parent | Custom Event, equals urlDecorationTargetFound | Fires the decorator tag only once the DOM scan has actually found a qualifying element on the current page. |
Window Loaded | Shared | Window Loaded | Utility trigger present from the Analytics folder scaffolding; not wired to a tag in this export. |
Key Variables
| Variable | Side | Role & Notes |
|---|---|---|
Referral Host - iframe Parent Site | Child | Constant, default drewspen.blogspot.com. Note: "enter the host name of the parent / referral site here." Feeds the Sender Configuration tag's targetOrigin field as https://{{this}}. |
postMessage Target Host | Parent | Constant, default postmessage-drewspen.blogspot.com. Note: "the host name of the iframe source. note that not only this must match, but the iframe id must also be configured correctly to match in the tag configuration." Feeds the Receiver tag's allowedOrigins field and the URL Decoration Config's permittedHosts list. |
Obtain _ga client id from URL query string | Child | Reads the _ga query-string parameter off the child page's own URL. Its format-value fallback substitutes {{Browser Client ID}} when the parameter is missing. |
URL Query String _ga Fix | Child | Note: "necessary so that if the _ga value is blank or empty it is converted to a true undefined. a blank or empty will be used to 'erase' the client_id. an undefined will allow for GA to generate a value." A Regex Table validates the resolved value against the GA4 client-ID shape (NNNNNNNNNN.NNNNNNNNNN) before it's trusted; anything malformed falls through to undefined. |
Google Tag Shared Configuration Settings | Child | Sets the GA4 config tag's client_id parameter to {{URL Query String _ga Fix}}, send_page_view to false, and debug_mode to the Preview-only wrapper variable. This is the actual mechanism that lets the child iframe's GA4 hits carry the parent's client ID. |
Root Domain / Root Domain - old | Shared | Note: "Root domain of .blogspot.com did not work for Google Blogger. I did not want to do .blogger.com, so changed to page URL host name." Same Blogger-specific decision documented in the earlier UTM/URL-decoration recipes — cookies are scoped to {{Page Hostname}}, not a computed eTLD+1. |
Debug Mode (Preview Only) | Shared | Note: "Wraps the built-in {{Debug Mode}} variable so the debug_mode config parameter is OMITTED (undefined) on normal traffic rather than explicitly sent. Some GA4/gtag implementations key off the parameter's mere presence, not its value, and omitting it avoids sending a needless param on every hit." |
How the Full Round Trip Works
Outbound (parent → child) and inbound (child → parent) run independently, but together they form a loop:
1. Outbound: decorating the iframe's src (Parent Site)
This reuses the URL-decoration recipe wholesale, aimed at the iframe element instead of an outbound link. On DOM Ready, the listener scans the page; if it finds an <iframe> whose src host matches {{postMessage Target Host}}, it fires the decorator, which appends the parent's UTM cookie values and its GA4 client_id (as _ga) onto the iframe's src before — or as — it loads.
2. Child side picks up the client ID
The child page's own GA4 config tag reads that _ga query parameter (with the validation and fallback described above) and uses it as its own client_id, instead of letting gtag.js generate a fresh one. The two properties now share a client_id, and by extension a pseudo_user_id — though not a session.
3. Sender: staging events inside the iframe
The postMessage Bridge - Custom Event - All Events trigger fires the Sender Configuration tag on every dataLayer push. It walks any dataLayer entries it hasn't seen yet, drops gtm.js/gtm.dom/gtm.load, keeps only gtm.click (as configured), tags each surviving event with the page URL and title, and stages the wrapped result — plus the target origin — on plain window globals. Its setup tag, Sender Bootstrap, runs immediately after and does the one thing the sandbox can't: window.top.postMessage(JSON.stringify(item), 'https://drewspen.blogspot.com').
4. Receiver: unwrapping events on the parent
At Initialization — before most other tags — the Receiver tag writes window.__gtmPmConfig (the allowed origins and the tracked iframe's element ID) and defines window.__gtmPmReceiver(). Its setup tag, Bootstrap Listener, registers the single message event listener for the whole page. When a message arrives, the listener confirms the origin is allowed and, if an iframe element ID is configured, that evt.source really is that specific iframe's contentWindow — then calls the receiver function, which checks the origin again, confirms the event name starts with the configured namespace, drops an exact repeat of the previous payload, and pushes the unwrapped event (iframe.gtm.click, wrapped under postMessageData) into the parent's own dataLayer.
5. Downstream on the parent
The Click from iframe - iframe.gtm.click Custom Event trigger is now listening for exactly that event name. Attach any GA4 event tag (or additional trigger conditions on the bridged payload's fields) to that trigger to turn "someone clicked inside the embedded form" into a first-class parent-site GA4 event.
What You Need to Change Before Using This
Change this — Host names
Two Constant variables carry the placeholder host names in this export: Referral Host - iframe Parent Site (Child, default drewspen.blogspot.com) must be the real host of the page that will embed your iframe. postMessage Target Host (Parent, default postmessage-drewspen.blogspot.com) must be the real host the iframe's src points at. Both origin checks are exact, case-insensitive hostname comparisons — no wildcards, no path matching.
Change this — The tracked iframe element
The Receiver tag's iframeElementId field ships as iframe-form-tracking. This must exactly match the id attribute on the actual <iframe> element in your parent page's HTML — if it doesn't, the receiver's evt.source check will silently reject every message. Leave it blank only if you're comfortable accepting messages from any frame at the allowed origin, regardless of which element it came from.
Change this — Which events get bridged
The Sender Configuration tag's eventAllowlist ships as gtm.click only. Add other dataLayer event names (comma-separated) if you also want to bridge, for example, a custom form_submit event the child container pushes. The Receiver's own eventAllowlist field is a second, independent gate on the parent side — both allow-lists have to agree for an event to land in the parent's dataLayer.
Change this — Trigger scoping (fire only where your iframe actually is)
As exported, DOM Ready - All Pages (Parent) has no conditions and fires on every page, and the Child-side Custom Event - All Events trigger is unconditional inside whatever container it's imported into. Neither is a correctness problem by itself — the decorator only acts when the DOM scan finds something, and the sender only stages events that match its allow-list — but both are worth tightening. On the parent, add a Page Path or Page URL condition to DOM Ready - All Pages so the DOM scan only runs on the specific template(s) that actually embed your tracked iframe. On the child, if you're running a shared container per the two-hostname approach described above, add the child hostname condition to the same trigger. Scope both sides to the pages where your target iframes are actually found, rather than letting either side evaluate everywhere.
Built On Two Earlier Recipes
Part of this solution reuses components from two earlier posts on this blog, unchanged in mechanism:
- GTM URL Decoration to Transfer Attribution — the permitted-hosts, config-global, DOM-scan-then-decorate pattern used here to decorate the iframe's
src. - UTM & URL Query String 2 Cookies — the session-cookie writer that keeps UTM values available on the parent page beyond the original landing page, so they're still there to decorate the iframe with on a later page view.
Diagnostics
Both sides ship a paused Diagnostic - Is postMessage Alive tag. Enable the child-side one to confirm the outbox is being staged and the target origin is set correctly; enable the parent-side one to confirm the listener is registered, watch every raw incoming message (regardless of origin), and see bridged events land in the dataLayer in real time. Turn both back off before publishing — they're verbose console.log tags meant for GTM Preview mode only.
How to Import
- Download the JSON from the Google Drive link above.
- In GTM, go to Admin → Import Container.
- Upload
gtm-postmessage-bridge-listener.json. - Choose Merge (not Overwrite) to preserve your existing container setup, and pick a target Workspace.
- If importing into two separate containers (recommended): import the whole file into both, then delete the Parent Site-labeled items from the child container and the Child Site-labeled items from the parent container.
- If importing into one shared container: keep everything, but add the hostname trigger conditions described in "Parent Site vs. Child Site" above.
- Update
Referral Host - iframe Parent SiteandpostMessage Target Hostwith your real hostnames. - Update the Receiver tag's
iframeElementIdto match your actual<iframe id="…">. - Update
Measurement Stream IDwith your real GA4 Measurement ID on both sides. - Scope the
DOM Ready - All Pagestrigger (and, if applicable, the child-side custom event trigger) to the pages that actually contain your tracked iframe. - Confirm consent settings — the Sender Configuration and Sender Bootstrap tags require
functionality_storageas exported; adjust to match your CMP setup. - Open GTM Preview mode on both the parent page and the child/iframe page. Turn on both diagnostic tags temporarily. Click something inside the iframe and confirm:
gtm.clickfires in the child dataLayer → the outbox stages an item →postMessagefires → the parent's listener receives it →iframe.gtm.clicklands in the parent dataLayer. - Turn the diagnostic tags back off and publish.
The full source — container JSON and documentation — is also published on GitHub. If you extend the recipe (additional bridged event types, a different namespace per iframe, or support for more than one tracked iframe on the same parent page), open a pull request or issue.
⬇ Download GTM Container JSON
Comments
Post a Comment