Build an embeddable checkout widget dropped into any merchant site via a script tag.
A small loader script injects an iframe hosting your checkout UI. The iframe isolates CSS/JS from the host page; postMessage (with strict origin checks) is the host↔widget channel. Concerns: namespace isolation, security (never trust the host), payment confirmation server-side, responsive overlay, versioning.
An embeddable checkout widget (think Stripe/Razorpay) has one defining constraint: it runs inside a stranger's page — you control neither their CSS, their JS, nor their intentions. The architecture is built around isolation.
Architecture: a loader script + an iframe
- A tiny loader script the merchant adds via
<script src="https://you.com/checkout.js">. It's small, cacheable, and version-stable. Its only job: expose a minimal global API and, when invoked, inject an iframe. - An iframe hosting your actual checkout UI, served from your origin (
https://checkout.you.com). This is the core decision.
Why an iframe — isolation
- CSS isolation — the merchant's stylesheet can't bleed into (or break) your checkout, and yours can't break theirs.
- JS isolation — separate global scope; the merchant's scripts can't read your DOM, your variables, or — critically — the card fields.
- Security boundary — the iframe is same-origin to you, cross-origin to them. The merchant's page cannot script into the iframe. This is essential for handling card data (and for PCI scope).
Communication: postMessage with strict origin checks
The host page and the widget talk only via window.postMessage:
- Loader → iframe: "open with this order, amount, config."
- Iframe → loader: "checkout closed," "payment UI succeeded" (UI signal only — see below), "resize me."
- Every
messagelistener MUST validateevent.originagainst an allowlist and validateevent.source. Never act on an unverified message — a malicious host could forge them.
Namespace isolation on the host
The loader must not pollute the merchant's window. Expose one namespaced global (window.YourCheckout) — ideally a frozen object — built via an IIFE/module so no internal variables leak. (This is its own deep topic.)
Security — the merchant is untrusted
- Never trust the host page for anything that matters. A "payment success"
postMessageis a UI signal only; actual payment confirmation is server-to-server — your backend confirms the charge (signed webhook / verify call), the host's JS never grants anything. - The iframe's
Content-Security-Policyandframe-ancestorscontrols who can embed you; sensitive fields live only inside your iframe. - HTTPS everywhere.
UX concerns
- Overlay/modal — usually the iframe is a full-screen or modal overlay (high
z-index, the loader manages a positioned container), so the host's layout/overflowcan't clip it. - Responsive — the iframe posts resize messages; the loader sizes the container.
- Loading & error states, focus trapping, accessibility, mobile.
Operational concerns
- Versioning — the loader URL is stable; version the iframe app behind it so you can ship without merchants changing their script tag. Backwards compatibility of the postMessage protocol.
- Performance — keep the loader tiny; lazy-load the heavy iframe app only when checkout opens; don't block the merchant's page.
- Analytics/error tracking scoped to your iframe.
The framing
"The defining constraint is that I run inside someone else's page I don't control. So: a tiny loader script the merchant includes, which injects an iframe hosting the real checkout from my origin. The iframe gives CSS, JS, and security isolation — the merchant can't style-break it, script into it, or read the card fields. Host and widget talk only via postMessage with strict origin allowlisting. The loader exposes one namespaced global so it doesn't pollute their window. Security-wise the host is untrusted — a success message is just a UI signal, real confirmation is server-to-server. Then the practical layer: an overlay that escapes their layout, responsive resize messaging, and a stable loader URL with the iframe app versioned behind it."
Follow-up questions
- •Why an iframe instead of injecting components directly into the host DOM?
- •How do the host page and widget communicate securely?
- •Why can't you trust a 'payment success' message from the host?
- •How do you version the widget without merchants changing their script tag?
Common mistakes
- •Injecting your UI directly into the host DOM — no CSS/JS/security isolation.
- •postMessage listeners with no origin validation.
- •Polluting the merchant's global namespace.
- •Trusting client-side payment-success signals instead of server confirmation.
- •A heavy loader script that slows the merchant's page.
Performance considerations
- •Keep the loader tiny and non-blocking; lazy-load the iframe app on demand. The iframe is a separate document/context — that's an isolation win but means its own load cost, so defer it until checkout opens.
Edge cases
- •Host page CSS with aggressive global resets or high z-index competing with your overlay.
- •Merchant embedding multiple instances of the widget.
- •Host page itself inside another iframe (nested framing).
- •Older merchant integrations on an old protocol version.
Real-world examples
- •Stripe Checkout / Stripe Elements and Razorpay's embeddable checkout.
- •Embeddable chat widgets and comment widgets using the same loader+iframe pattern.