Skip to content
ceaksan

Tracking Data Between iframe and Parent: postMessage, Consent and Payment Scenarios

Use window.postMessage to move consent, payment and widget events from iframe to parent safely. Origin validation pitfalls, CMP/Stripe/Shopify nuances, race conditions and replay protection.

Sep 14, 2025 8 min read Updated: May 15, 2026
TL;DR

An event that fires inside an iframe (consent granted, payment completed, widget submit) does not reach the tracking script on the parent because of the same-origin policy. The backbone of the solution is window.postMessage plus three pieces: bounding the receiver with targetOrigin, validating the sender with event.origin exact match plus event.source reference check, and validating the payload with an envelope schema. Not every CMP uses postMessage (Cookiebot runs at script level), Stripe Elements does not expose postMessage directly (SDK abstraction), and the Shopify Custom Pixel sandbox does not grant direct access to window.parent (it is wrapped behind a browser API). For payment, eventId deduplication is critical for replay protection.

The Event Inside the iframe Never Reaches the Parent

The field pattern is familiar: a CMP other than Cookiebot runs inside an iframe, the user grants consent, but GA4 still ships events with gcs=G100. The trickier variant is when GA4 or GTM is loaded as a Shopify Custom Pixel and the pixel settings have Customer privacy → Permission: Not required selected. That setting lets the pixel run unconditionally (Shopify docs: “The pixel will always run”); unless consent control is written by hand inside the pixel code, GA4 Enhanced Measurement auto events (page_view, scroll, click, form_start, form_submit, file_download, video_start) keep sending data out of the sandbox. The user may have rejected on the storefront side; the sandbox does not know that because the consent state was never carried across the iframe boundary. The result: the storefront consent preference conflicts with the consent argument (or the absence of it) on events leaving the pixel. Payment returns from an İyzico 3DS iframe, the purchase event never fires. The script you wrote inside a Shopify Custom Pixel cannot see the real URL on the storefront. A booking is made inside a Calendly embed, no event lands in the GA4 dataLayer. The A/B testing framework’s iframe variant fires, nothing reaches the dataLayer.

Most of these share the same technical root: the browser same-origin policy blocks direct DOM access between windows on different origins. The origin definition is the scheme + host + port triple; if even one differs, the two windows cannot read each other’s DOM1. The backbone of the solution is window.postMessage, an API designed to cross the cross-origin boundary in a controlled way2.

The Shopify Custom Pixel exception is a separate category. The sandbox is deliberately closed to window.parent, so the postMessage flow does not produce a solution from there (§7 in detail). The solution stays inside the pixel: handling consent arguments by hand inside the pixel code, selecting Customer privacy → Permission: Required, or letting the CMP emit its own event into the Shopify pixel (most popular CMPs do this). The rest of this post is built on the scenarios where postMessage does the job; the Shopify pixel consent flow is the subject of another post.

This post explains how to set up postMessage safely in tracking practice. The reflection of the origin concept on the URL is covered in URL anatomy for tracking. This post deepens origin validation through concrete iframe scenarios.

postMessage Basics

The API has three parts: sender, receiver, message.

// Sender (from inside the iframe to the parent)
window.parent.postMessage(
  {
    type: "tracking.consent.update",
    version: "1.0",
    eventId: crypto.randomUUID(),
    payload: { ad_storage: "granted", analytics_storage: "granted" },
  },
  "https://shop.example.com",
);

// Receiver (parent)
const ALLOWED_ORIGIN = "https://cmp.example.com";
const seenEvents = new Set();

window.addEventListener("message", (event) => {
  if (event.origin !== ALLOWED_ORIGIN) return;
  if (event.source !== document.getElementById("cmp-frame").contentWindow)
    return;
  if (!event.data || typeof event.data !== "object") return;
  if (event.data.type !== "tracking.consent.update") return;
  if (seenEvents.has(event.data.eventId)) return;
  seenEvents.add(event.data.eventId);

  handleConsent(event.data.payload);
});

Three validation layers:

  1. event.origin exact match. Check which origin it came from. Do not use endsWith or includes. If you need subdomain wildcards, use an allow-list + new URL(event.origin).hostname pattern.
  2. event.source reference check. Another iframe on the same origin can also send a message; on a multi-iframe page there is a spoof risk. Test equality against the contentWindow of the frame you expect.
  3. Payload schema validation. The message object must be serializable (structured clone algorithm)3. Function, DOM node and WeakMap cannot be sent. Object, Array, Map, Set, ArrayBuffer, Blob and File can; circular references are supported.

Using '*' for the targetOrigin parameter is not categorically forbidden but it is risky when sensitive data is involved: the message can be delivered to other origins as well. It is acceptable for public, non-sensitive events (e.g. a widget ready signal); in every case the receiver side must validate strictly2.

Origin Validation Pitfalls

The origin concept was introduced in the URL anatomy for tracking post; the pitfalls met in practice with postMessage:

endsWith/includes attack. If you write event.origin.endsWith(".example.com"), evil-example.com passes the check. The correct pattern:

const ALLOWED_HOSTS = new Set(["shop.example.com", "checkout.example.com"]);

const url = new URL(event.origin);
if (url.protocol !== "https:") return;
if (!ALLOWED_HOSTS.has(url.hostname)) return;

window.origin overwrite risk. On modern browsers the window.origin global can be manipulated through prototype pollution via Object.defineProperty. Prefer window.location.origin or a hard-coded constant.

IDN/Punycode. xn--exmple-cua.com and examplé.com are two representations of the same domain. The browser delivers event.origin in Punycode (xn--) form; keep the allow-list in Punycode form and do not compare with the human-readable form.

null origin. It appears in these cases: a sandbox iframe (no allow-same-origin flag), a file:// URL, a data: URI, some browser extension contexts. In a tracking flow null origin must be rejected; it is not a legitimate source.

Schema Validation and Replay Protection

The receiver can meet multiple scripts from the same origin (CMP iframe + analytics script + third-party widget). Type namespace separation is mandatory:

const HANDLERS = {
  "tracking.consent.update": handleConsent,
  "tracking.payment.complete": handlePurchase,
  "tracking.form.submit": handleFormSubmit,
};

const handler = HANDLERS[event.data.type];
if (!handler) return;

Replay protection. The same payment.complete event can arrive twice due to network latency or the user’s back/forward navigation. A dedup buffer on eventId is critical; in e-commerce especially, duplicate purchase events directly break attribution and revenue reports.

Sender authentication. In most tracking scenarios type + payload schema is enough. For payment or fintech flows, add HMAC over a shared secret or a JWT-style signature between parent and iframe. For widget or general analytics events, a signature is overkill; the validation cost outweighs the data.

Envelope convention:

{
  type: "tracking.payment.complete",
  version: "1.2",
  eventId: "uuid-v4",
  timestamp: 1747300000000,
  nonce: "random-string",
  payload: { /* event-specific */ }
}

The first widespread assumption to correct: “CMPs run inside an iframe” is not true. There are three different patterns in practice.

Script-level CMPs (Cookiebot, Iubenda). They run in the same window as the parent page, and call gtag('consent', 'update', ...) and dataLayer.push directly. No postMessage is required; consent state is read and written within the same JS context4.

Hybrid (OneTrust). The standard deployment is script-level. In AMP, in-app webview or some sandbox scenarios the iframe + postMessage flow kicks in: the { sentinel: 'amp', type: 'send-consent-data', payload: {...} } format.

Custom embedded CMP. A consent UI you build yourself or a third-party one hosted inside an iframe; postMessage is mandatory.

The race condition is critical for the custom flow. When the CMP inside the iframe sends the consent.update message, the Consent Mode v2 listener on the parent may not be loaded yet; tracking scripts fire with the default denied state and the consent the user granted is lost.

The Handshake pattern:

// Parent: send the ready signal immediately
const cmpFrame = document.getElementById("cmp-frame");
cmpFrame.addEventListener("load", () => {
  cmpFrame.contentWindow.postMessage(
    { type: "tracking.parent.ready" },
    "https://cmp.example.com",
  );
});

// Iframe: queue messages until the parent is ready
const messageQueue = [];
let parentReady = false;

window.addEventListener("message", (event) => {
  if (event.origin !== PARENT_ORIGIN) return;
  if (event.data.type === "tracking.parent.ready") {
    parentReady = true;
    messageQueue.forEach((msg) =>
      window.parent.postMessage(msg, PARENT_ORIGIN),
    );
    messageQueue.length = 0;
  }
});

function sendConsent(payload) {
  const msg = {
    type: "tracking.consent.update",
    payload,
    eventId: crypto.randomUUID(),
  };
  if (parentReady) {
    window.parent.postMessage(msg, PARENT_ORIGIN);
  } else {
    messageQueue.push(msg);
  }
}

Google Consent Mode v2’s default state must be denied, and queued events flushed after the update5.

Payment iframe Completion Signal

The “receive payment success via postMessage from the Stripe iframe” pattern shows up in older blog posts. As of 2026 this is not accurate.

Stripe Elements. stripe.confirmPayment() returns a Promise; after 3DS the user is redirected to return_url and the status is read from the payment_intent query parameter. The postMessage from the Stripe iframe to the parent is Stripe’s internal detail; it is not exposed to the developer through the SDK6.

Paddle Overlay. Events are received via Paddle.Initialize({ eventCallback: (data) => { ... } }) (checkout.completed, checkout.payment.initiated). Not raw postMessage, but an SDK abstraction7.

İyzico Checkout Form. The callbackUrl + server-side webhook + getAuthResponse API call pattern. On 3DS iframe exit the token is POSTed to the callback URL; there is no client-side postMessage flow in the official docs.

Custom 3DS iframe (PSP integration). If you host the 3DS challenge of your own acquirer or bank inside an iframe, postMessage becomes mandatory. A pattern common to PSPs like Acquired.com:

// From the 3DS iframe to the parent
window.parent.postMessage(
  {
    type: "payment.3ds.complete",
    eventId: crypto.randomUUID(),
    status: "authenticated",
    transactionId: tx.id,
  },
  PARENT_ORIGIN,
);

The 3DS flow takes three shapes depending on the vendor: top-level redirect, popup window, iframe challenge. iframe→parent messaging is not guaranteed across vendors; read the integration docs, do not assume.

In conversion tracking, the eventId of the purchase event must also be sent to the server-side gateway; GA4 and Meta CAPI run deduplication on the same ID.

Shopify Custom Pixel and the Sandbox

The Custom Pixel runs inside a Lax sandbox iframe. Sandbox constraints8:

  • Defined globals: console, timer APIs (setTimeout, clearTimeout, setInterval, clearInterval), fetch together with Headers/Request/Response, and the analytics, browser and init deconstructed from the Web Pixels API. Non-language globals outside the list can be set to undefined at any time; Shopify makes no guarantee8.
  • Inaccessible: window.parent, window.top, document, third-party script loading, most DOM APIs.

The sandbox is deliberately isolated; direct access to the storefront is closed for security. The official Shopify path for data flow:

analytics.subscribe("checkout_completed", (event) => {
  fetch("https://gateway.example.com/_track", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      type: "tracking.purchase",
      eventId: event.id,
      payload: event.data,
    }),
  });
});

To carry the real URL or a custom context from the storefront, the event.context.window.location.href pattern from the URL anatomy post applies.

Checkout UI Extensions do not support custom JavaScript or DOM manipulation; postMessage does not even come up.

Embed Widget → Parent Analytics

Third-party widgets are usually loaded as iframes; most already use postMessage, but the event names and payload structures differ by vendor.

Calendly. There is an official event list9: calendly.profile_page_viewed, calendly.event_type_viewed, calendly.date_and_time_selected, calendly.event_scheduled. Parent listener:

window.addEventListener("message", (event) => {
  if (!event.origin.endsWith(".calendly.com")) return; // official pattern; vendor ownership verified
  if (event.data.event !== "calendly.event_scheduled") return;
  dataLayer.push({
    event: "meeting_scheduled",
    meeting_uri: event.data.payload?.event?.uri,
  });
});

Typeform. The form-submit event arrives via postMessage but the payload only carries formId and responseId; form field contents are not delivered to the parent10. Enough for conversion tracking, not enough for lead enrichment; for that you need a Typeform Webhook. The full flow is in the Typeform conversion tracking guide.

Other chat/widget vendors. If there is no official event documentation, do not assume; the event name can change on a vendor update (breaking change). Do not wire production flows to names you reverse-engineered.

An allow-list approach for vendor subdomains in event.origin validation:

const VENDOR_ORIGINS = new Set([
  "https://calendly.com",
  "https://meetings.calendly.com",
]);

If the vendor uses a broad *.calendly.com deployment, you must parse with new URL(event.origin).hostname and treat the apex domain as the certificate owner; that is a decision based on vendor trust.

React/Astro Cleanup Pattern

import { useEffect } from "react";

export function ConsentListener({ onUpdate }) {
  useEffect(() => {
    if (typeof window === "undefined") return; // SSR guard

    const handler = (event) => {
      if (event.origin !== "https://cmp.example.com") return;
      if (event.data?.type !== "tracking.consent.update") return;
      onUpdate(event.data.payload);
    };

    window.addEventListener("message", handler);
    return () => window.removeEventListener("message", handler);
  }, [onUpdate]);

  return null;
}

Common mistakes:

  • Listener without cleanup. On SPA route transitions the same handler is bound multiple times; a single event is processed 3–5 times, duplicates go to GA4.
  • addEventListener with an inline arrow function. removeEventListener cannot find the reference, cleanup fails.
  • Wrong dependency array. If [onUpdate] does not change you can use an empty array; rebinding the listener on every render is not desirable.
  • window undefined in SSR. A typeof window guard is mandatory in Astro client:load or code outside Next.js useEffect.

Security Checklist

  • targetOrigin is the exact origin for sensitive payloads; '*' only for public signals
  • event.origin exact match, event.source reference check
  • Subdomain validation via new URL().hostname + allow-list, do not use endsWith
  • Normalize Punycode, reject null origin
  • Envelope schema: type namespace, eventId dedup, version field
  • HMAC or JWT-style signature for payment/fintech
  • Sensitive data (token, card number, PII, personal data under GDPR/KVKK) does not belong in a postMessage payload; it goes through a POST to the gateway
  • Listener cleanup is mandatory
  • Use CSP frame-ancestors to restrict parent embedding; frame-src to restrict which iframes the page can embed11
  • <iframe sandbox="allow-scripts allow-same-origin"> with the minimum required permissions
  • postMessage is protected by TLS at the network layer but is plaintext inside the JS context; other scripts on the same page can listen

Modern Alternatives

APIScenarioCross-originNotes
postMessageCross-origin iframe/popup/windowYesDominant in tracking practice
BroadcastChannelTab/iframe/worker on the same originNoCleaner API, no separate window ref needed
MessageChannelPrivate channel between two portsYesBuilt on top of postMessage, isolated channel
Service Worker postMessageBetween an SW and a pageSame-originNot practical for iframe communication
Custom EventsComponent sync within the same documentN/ANo iframe, single window
Storage Access APIFirst-party cookie access for cross-site iframesYesAs cookie blocking tightens, postMessage becomes more necessary12

The Storage Access API lets vendor scripts inside an iframe request access to the parent’s first-party cookies. Because Safari ITP and Firefox ETP block cross-site cookies by default, vendor SDKs are forced to request data via postMessage instead of reading cookies; postMessage’s role in tracking is not shrinking, it is growing.

Next Step

postMessage is the in-browser bridge for tracking flows. The other side of the bridge is the server; the consent or purchase event that comes from an iframe to the parent has to leave the parent for your own gateway. Server-side gateway design, the CNAME cloaking risk, path-based proxy vs subdomain patterns are in URL anatomy for tracking. A separate post on Consent Mode v2 default state and the update flush mechanics is on the way.

Footnotes

  1. Same-origin policy. MDN web docs
  2. Window.postMessage(). MDN web docs 2
  3. Structured clone algorithm. MDN web docs
  4. Cookiebot Google Consent Mode v2 implementation. Cookiebot docs
  5. Consent Mode v2 default state and updates. Google Developers
  6. Stripe.js API Reference confirmPayment. Stripe docs
  7. Paddle.js eventCallback. Paddle docs
  8. Web Pixels API sandbox. Shopify Dev 2
  9. Calendly Embed Event Listeners. Calendly Developer
  10. Embed SDK form-submit event. Typeform Developer
  11. CSP frame-ancestors vs frame-src. MDN web docs
  12. Storage Access API. MDN web docs
Key Takeaways
  • 01 event.origin exact match alone is not enough; the event.source !== iframe.contentWindow check closes the multi-iframe spoof risk
  • 02 Stripe Elements, Paddle Overlay and İyzico use their own SDK abstractions; developers do not write raw postMessage, with custom 3DS iframe integration as the exception
  • 03 The Shopify Custom Pixel sandbox is limited to console, timer APIs, fetch and the Web Pixels API (analytics, browser, init, customerPrivacy); there is no window.parent.postMessage call, and data flow goes through analytics.subscribe() + fetch (keepalive: true) to your own gateway
  • 04 Race condition in consent forwarding: the default state is denied, and without an iframe ready handshake plus a parent buffer the first events go out with the wrong consent state
  • 05 For critical tracking like the purchase event, duplicate conversions are inevitable without eventId + a dedup buffer
Frequently Asked Questions (FAQ)
+ Between two tabs on the same origin, should I use postMessage or BroadcastChannel?

BroadcastChannel is cleaner for same-origin. The API is one line, it does not need a separate window reference, and it broadcasts to every tab/iframe/worker on the same origin. Keep postMessage for cross-origin scenarios (iframe parent, popup opener). BroadcastChannel had partial support on Safari historically, but as of 2026 it is stable on evergreen browsers.

+ Is the postMessage payload encrypted?

No. The message is plaintext inside the JS context. HTTPS only protects at the network layer; another script loaded on the same page can listen with window.addEventListener('message', ...). Sensitive data (tokens, card data, PII) does not belong in a postMessage payload. If you genuinely need to prove the sender, add a nonce + HMAC or JWT-style signature to the envelope.

+ Do I receive the payment success event from Stripe Elements via postMessage?

No. The Stripe.js SDK exposes a stripe.confirmPayment() Promise and a return_url redirect pattern. Direct postMessage from the Stripe iframe to the parent is closed as an implementation detail. The same applies to Paddle Overlay (Paddle.Initialize({ eventCallback })) and İyzico Checkout Form (callbackUrl + webhook + getAuthResponse). The scenario that genuinely requires raw postMessage is a custom 3DS iframe integration you build yourself.

+ Does Cookiebot run inside an iframe?

No. Cookiebot loads as a script tag, runs in the same window as the parent page, and communicates through gtag('consent', 'update', ...) and dataLayer. The iframe + postMessage flow appears in OneTrust's special scenarios like AMP, or when you host your own custom consent banner inside an iframe. The CMP choice drives the postMessage design decision.

+ What happens if I do not clean up the listener in React?

On SPA route transitions the same handler is bound multiple times. The result: a single consent.update message is processed 3–5 times, duplicate events go to GA4, and purchase tracking double-counts revenue (on the report, not in reality). removeEventListener in the useEffect cleanup return is mandatory. If the handler is an inline arrow function, removeEventListener cannot find the reference; use a named function or a useCallback reference.

+ Is it possible to send postMessage from a Custom Pixel?

The Shopify Custom Pixel runs inside a Lax sandbox; the officially permitted globals are console, timer APIs, fetch plus Headers/Request/Response, and the deconstructed analytics, browser, init (and customerPrivacy) from the Web Pixels API. There is no direct window.parent or top.window access. Globals outside the list can be set to undefined at any time. The official way to deliver data to the storefront is fetch to your own gateway; for data flow from the theme into the pixel, use Shopify.analytics.publish(eventName, payload) on the theme side and analytics.subscribe(eventName, ...) on the pixel side.