← Back to series

Build your own embed SDK: a chat widget in 6 decisions

Part 6 of 6 How to build a JavaScript SDK: 5 real teardowns

Across this series I took five products apart. Here I put one back together. The target is a minimal SDK: the customer pastes one <script> line into their site and a chat widget floats in the bottom-right corner. I’ll make each of the six decisions the teardowns surfaced, explicitly, in code.

Design sketch of the minimal embeddable chat SDK. Three zones: the customer site (example.com), the SDK iframe (sdk.myapp.com, ours) and our backend (api.myapp.com). The customer pastes one line, <script src="…/v1/latest/">, about 5 KB and no-cache. The loader blocks a double load with __MyAppChatLoaded, creates the iframe and passes the origin in the hash, and buffers push([...]) calls to send after ready. It checks every message: origin is https://sdk.myapp.com and event.source === iframe.contentWindow. postMessage carries cmd {type, method, params, id} from the loader to the iframe, and ready / res / event back. Inside the iframe, iframe.html is no-cache with a fresh-nonce CSP on every request, and main-<hash>.js is the body (all UI), immutable for 1 year. The CSP is script-src 'self' 'nonce-…' and connect-src allows only api.myapp.com. The iframe checks every message: origin equals the customer site received in the hash, and event.source === window.parent. HTTPS / WSS goes only to hosts allowed by connect-src. The backend has an HTTPS API (REST and auth) and a WebSocket (wss://, pushes new chat messages). What this shape buys: the customer's CSS and JS never collide with our UI, and even after an XSS, fetch and WebSocket can reach only api.myapp.com. Along the bottom, the six decisions and where each applies: 1 iframe, stays out of the customer's DOM (the three zones); 2 loader / body, split into 5 KB and about 300 KB (the script line and main.js); 3 CSP, nonce plus connect-src (inside the iframe); 4 postMessage, 4 message types with origin and source checks (the arrows on the left); 5 caching, loader no-cache and body immutable (no version choice); 6 init, push queue and double-load guard (inside the loader). A design sketch; nothing in it was run or measured

Requirements

  • It works when the customer site (example.com) pastes one line: <script src="https://sdk.myapp.com/v1/latest/">
  • A chat button floats in the bottom-right corner; clicking it opens a conversation window
  • It doesn’t interfere with the customer site’s CSS, JS or cookies
  • It sends and receives the conversation log with our own server (api.myapp.com)
  • Encrypting chat history is out of scope (about as sensitive as video metadata)

Decision 1: iframe or DOM injection

The requirement not to interfere with the customer’s CSS and JS points to an iframe. Even if the customer mixes Tailwind and Bootstrap, our UI is unaffected. As a side benefit, the auth cookie stays confined to the myapp.com origin. The alternative, injecting into the DOM (Web Components), isolates CSS through shadow DOM but still shares window on the JS side. If the customer site has rewritten window.fetch, the side effects are hard to predict. A chat widget has a closed set of requirements, so the simple move is to pick an iframe and isolate the whole thing.

Figure comparing Web Components (DOM injection plus shadow DOM) with an iframe on which of the customer site's CSS, JS and cookies reach the SDK. Title: shadow DOM still shares the window; an iframe gets its own. With Web Components, the customer's page example.com window holds the customer CSS and customer JS; the SDK UI sits in a shadow root, but the SDK's JS runs in the customer's window. CSS is blocked by the shadow DOM. The window is shared: a patched window.fetch hits the SDK. It runs on the customer's origin example.com. With an iframe (the choice in this part), the iframe on sdk.myapp.com inside the customer's page is a different origin and a different window. CSS is blocked. The window is separate, so the customer's patches don't reach it. Auth cookies stay on myapp.com

Decision 2: split the loader from the body

Do you keep the loader small to start fast, or ship a single file? I’ll split them this time.

  • Loader (https://sdk.myapp.com/v1/latest/): around 5 KB; it creates the iframe and bridges postMessage, nothing more
  • Body (the real code the loader pulls in, starting from https://sdk.myapp.com/v1/lib/iframe.html): the whole UI, ~300 KB

The main reason to split is the cache-control strategy (Decision 5). The loader is refetched every time so the version can be switched; the body lives at a hash URL and is cached for a long time. A single file can’t be split that way.

Decision 3: CSP and isolation inside the iframe

Response headers for iframe.html:

content-security-policy:
  default-src 'self';
  script-src 'self' 'nonce-<per-request-random>';
  style-src  'self' 'nonce-<per-request-random>';
  connect-src 'self' https://api.myapp.com wss://api.myapp.com;
  img-src    'self' data: blob:;
  • script-src 'self' 'nonce-...': the server that delivers iframe.html issues a per-request nonce, and only <script nonce="..."> tags inside the iframe that match it execute
  • connect-src limited to api.myapp.com: even if an XSS lands, data can’t be carried off to another domain
  • No frame-ancestors (the whole point is to be embedded by customer sites)

A per-request nonce needs a server that delivers iframe.html dynamically with that header. Static hosting alone (S3 + CloudFront) can’t do it. You end up putting Cloudflare Workers or a CDN’s edge functions in front.

Figure: a per-request nonce goes into both the response header and the HTML, with a new value every time, shown with Safie as the example. Title: the nonce goes in both the header and the HTML, fresh every time. Fetching lib/iframe.html on safie.link twice in a row on 2026-10-01 (first 8 characters of each nonce): the 1st response header has script-src 'self' 'nonce-0cf50fa7…' and the HTML body has script nonce="0cf50fa7…"; the 2nd has 'nonce-3bef7cc4…' in the header and nonce="3bef7cc4…" in the body. Static hosting alone (S3 + CloudFront) returns the same bytes every time, so the nonce is fixed and useless once it is known. Putting Workers or Edge Functions in front lets you generate a random value per request and write it into the header and the HTML; in this part, iframe.html is served no-cache. Only iframe.html is dynamic; the hashed main and styles files stay static and ship as immutable

Decision 4: the postMessage contract

Parent and child share no state (the loader only creates the iframe and sets up the postMessage bridge; state lives in the iframe). A one-sided “ready” notification is enough of a handshake.

Protocol:

// child → parent (iframe finished loading)
{ type: "ready" }

// parent → child (the customer calls an SDK method)
{ type: "cmd", method: "open", params: {}, id: <number> }

// child → parent (method result)
{ type: "res", id: <number>, result: any, error: string | null }

// child → parent (state change notification)
{ type: "event", name: "message-received", data: {...} }

Check origin by exact match, and the sender with event.source:

// iframe side
window.addEventListener("message", (event) => {
  if (event.origin !== "https://example.com") return; // the customer site's origin (passed in by the loader)
  if (event.source !== window.parent) return;         // is the sender the parent that embedded us?
  // handle the payload
});

When two widgets are embedded on the same page, both iframes have the origin https://sdk.myapp.com, and origin alone can’t tell whether a reply came from your own iframe. So the loader side also checks event.source === iframe.contentWindow on top of the origin.

Figure: two widgets on one page share an origin, and event.source tells them apart. The customer site example.com has widget A and widget B, each with its own iframe (iframe A and iframe B, both sdk.myapp.com). message events arrive on window, so a reply sent by iframe B also reaches A's listener. What A's listener lets through: event.origin === "https://sdk.myapp.com" passes anyway, because A and B have the same origin; event.source === iframe.contentWindow (did iframe A's window send it?) drops B's message. origin asks "which site?", source asks "which window?". The iframe side also checks event.source === window.parent

The loader tells the iframe the customer site’s origin. Concretely, it embeds the origin in the hash as iframe.src = ".../iframe.html#" + encodeURIComponent(location.origin), and the iframe’s JS parses location.hash on startup.

I don’t choose first-message trust. It leaves room for an attacker’s iframe to speak first, so while the implementation is young I’ll stay with the stricter exact-match design.

No encryption needed: the loader sends straight to the iframe’s window with a targetOrigin, so messages don’t reach sibling iframes or other tabs. They could be read if another script running on the customer’s page added a listener to the parent’s window, but that script could also rewrite postMessage or crypto.subtle itself, so I think in-page encryption would struggle to hold that line (this is reasoning; I haven’t tried it). I judged chat history to be about as sensitive as a video player’s data and left encryption out.

Decision 5: cache-control strategy

The loader is the “versioning contract”, so it is refetched every time. The body lives at a hash URL and is cached for a year.

ResourceURLcache-control
loaderhttps://sdk.myapp.com/v1/latest/no-cache
iframe.htmlhttps://sdk.myapp.com/v1/lib/iframe.htmlno-cache (served dynamically with the nonce header)
iframe body JShttps://sdk.myapp.com/v1/lib/main-<hash>.jspublic, max-age=31536000, immutable
iframe body CSShttps://sdk.myapp.com/v1/lib/styles-<hash>.csspublic, max-age=31536000, immutable

Customers don’t choose a version. The loader is refetched every time, and on our side it always emits the latest body hash. Part 4 covers why this strategy follows naturally once you’ve chosen an iframe.

Decision 6: initialization style

I’ll make it imperative.

<!-- the code the customer writes -->
<script src="https://sdk.myapp.com/v1/latest/" async></script>
<script>
  window.MyAppChat = window.MyAppChat || [];
  window.MyAppChat.push(["init", { apiKey: "..." }]);
  window.MyAppChat.push(["setUser", { id: "user123" }]);
</script>

This is the deferred command queue pattern. Commands called before the SDK finishes loading are stored in an array (MyAppChat), and once the SDK has loaded it replays the array in order. Since customers don’t have to write callbacks, their code reads synchronously. Google Analytics’ gtag and others use this pattern, and it works in single-page apps and static HTML alike.

Sequence diagram of the messages in the minimal chat SDK built in this part, from startup to the first command. Two lifelines, the loader (parent, example.com) and the iframe (child, sdk.myapp.com), with the customer's code on the left. 1 The customer can call MyAppChat.push(["init", …]) before the SDK arrives, and the command is held in the loader's array. 2 The loader passes the customer's origin in the hash as iframe.html#https%3A%2F%2Fexample.com, and the child checks messages against it (this is the iframe URL, not a postMessage). 3 The child sends {type: "ready"} to the parent. 4 The parent sends the queued commands in order as {type: "cmd", method, params, id}. 5 A push after ready is sent at once as {type: "cmd", method, params, id} instead of being queued. 6 The result comes back from the child with the same id as {type: "res", id, result, error}. 7 Notifications such as new messages arrive from the child as {type: "event", name, data}. Types as defined in Decision 4

I’m not choosing Web Components (<my-chat>) here. With an iframe already in place, stacking Web Components’ isolation benefit (shadow DOM) on top adds little, and there’s no need to cut off environments that lack Web Components support.

Guarding against double load:

// at the top of the loader (put it inside an IIFE as in the skeleton below; a return outside a function is a syntax error)
if (window.__MyAppChatLoaded) return;
window.__MyAppChatLoaded = true;

Now if a customer accidentally writes the <script> twice, the second copy returns immediately. What this stops is only the SDK definition (replacing window.MyAppChat) running twice; the loader still creates one widget per init. To put two on the same page, write init twice (see the skeleton below).

Skeleton implementation

Loader (pseudocode):

(function() {
  // Only the SDK definition runs once. A widget is created per init
  if (window.__MyAppChatLoaded) return;
  window.__MyAppChatLoaded = true;

  const parentOrigin = location.origin;
  const targetOrigin = "https://sdk.myapp.com";
  let nextId = 0; // res comes back with the same id, so we can match it to its cmd

  // One widget: its iframe, its ready state, and the commands queued before ready
  function createWidget() {
    const iframe = document.createElement("iframe");
    iframe.src = "https://sdk.myapp.com/v1/lib/iframe.html#" +
      encodeURIComponent(parentOrigin);
    iframe.style.cssText = "position:fixed;bottom:20px;right:20px;" +
      "border:none;width:60px;height:60px;z-index:2147483647";
    // A visible iframe goes in body. If the loader runs in <head> before body exists, wait
    const mount = () => document.body.appendChild(iframe);
    if (document.body) mount();
    else document.addEventListener("DOMContentLoaded", mount);

    let ready = false;
    const queue = [];
    const send = (cmd) => iframe.contentWindow.postMessage(
      { type: "cmd", method: cmd[0], params: cmd[1], id: ++nextId },
      targetOrigin
    );

    window.addEventListener("message", (event) => {
      if (event.origin !== targetOrigin) return;
      if (event.source !== iframe.contentWindow) return; // tell it apart from other widgets on the same page
      if (event.data.type === "ready") {
        ready = true;
        queue.forEach(send); // replay the queued commands
      }
      // res / event handling omitted
    });

    return { push: (cmd) => (ready ? send(cmd) : queue.push(cmd)) };
  }

  // init creates a widget; later commands go to the most recently initialized widget
  let current = null;
  const push = (cmd) => {
    if (cmd[0] === "init") current = createWidget();
    if (current) current.push(cmd);
  };

  // Flush commands queued before the SDK arrived, then intercept window.MyAppChat.push
  const early = window.MyAppChat || [];
  window.MyAppChat = { push };
  early.forEach(push);
})();

Figure separating what the skeleton loader does once from what it creates on every init. Title: only the SDK definition runs once; each init creates a widget. Two script tags still define it once; two init calls give you two widgets. Left, when the same script appears twice: the first sets window.__MyAppChatLoaded = true and defines the SDK; the second hits if (window.__MyAppChatLoaded) return and returns at once, so the definition is never swapped twice. Before the SDK arrives, the customer fills an array; the first script replaces it with window.MyAppChat = { push } and replays it in order, and every later push goes to this function. Right, where the customer's pushes go: an init command creates widget A and later commands go to A; a setUser command goes to A; the next init command creates widget B and later commands go to B; an open command goes to B. Widgets A and B each own one iframe and queue their commands until ready. Drawn from the loader pseudocode in this part, not run or measured

iframe body (pseudocode):

const parentOrigin = decodeURIComponent(location.hash.slice(1));

window.addEventListener("message", (event) => {
  if (event.origin !== parentOrigin) return;
  if (event.source !== window.parent) return;
  const { type, method, params, id } = event.data;
  if (type !== "cmd") return;
  handleCommand(method, params).then(
    (result) => event.source.postMessage(
      { type: "res", id, result, error: null }, parentOrigin
    ),
    (error) => event.source.postMessage(
      { type: "res", id, result: null, error: String(error) }, parentOrigin
    )
  );
});

// signal that loading has finished
window.parent.postMessage({ type: "ready" }, parentOrigin);

That gives you the skeleton of the minimal build. On top of it you’ll need documentation for the imperative API, error handling, retries, accessibility (ARIA), internationalization, telemetry, CI/CD and CDN configuration. What’s written here is only the bones of the decisions; a production SDK puts flesh on them.

The six decisions at a glance

DecisionWhat I choseMain reason
Embedding shapeiframeFull isolation from the customer’s CSS/JS/cookies
Loader/body splitSplitFor the cache-control strategy
CSP isolationPer-request nonce + restricted connect-srcStop data being carried off if an XSS ever lands
postMessage contract{type, method, params, id} + exact origin match + event.source checkStrict and simple, with no crossed wires even with two embeds
cache-controlLoader no-cache, body immutableWith an iframe, customers don’t choose a version
Initialization APIImperative (deferred command queue)Works in both static HTML and single-page apps

Mapped onto the five products covered across the series, this minimal SDK is a hybrid: Safie’s isolation approach, YouTube-style origin checks and a Google Analytics-style initialization queue. Each of those designs came out of its product’s economic and technical constraints. Recombining them to fit your own requirements is how I’d suggest using this series once you’ve read it.

Which one would you choose

The chat widget in this part falls into four of the situations in the table below: “must not interfere”, “may be embedded two or more times on the same page”, “customers split between static HTML and single-page apps” and “any shape”. When requirements change, so do the choices, so here is how to choose by situation. It’s the same table as on the series landing page.

SituationWhat to chooseWhy it works
The customer site’s CSS, JS and cookies must not interfere with yoursAn iframe. Split the loader from the body, and don’t let customers choose a versionThe customer’s styles and window.fetch rewrites can’t reach you. A loader refetched every time keeps parent and child on the same version
Customers build their own UI or want it to blend into their frameworkDOM injection (Web Components). Let customers choose a URL that can name an exact versionOnce customers write a version, they choose when to upgrade even if the API changes (Video.js requires a version; Mux Player, if you write one, is cached immutable for a year)
You handle things that would hurt to leak, such as auth tokens or recorded footageSet a per-request nonce CSP inside the iframe and lock connect-src to your own hostsEven a hijacked frame can’t carry data out (Safie). Static hosting alone can’t build it
Parent and child share stateWait for the child’s ready signal, then push the initial stateYouTube’s listening → initialDelivery
You may be embedded two or more times on the same pageCheck event.source in addition to an exact origin matchBoth iframes have the same origin, so origin alone can’t tell whether it’s your iframe
Customers use static HTML or a CMS and write no JSInitialize from data attributesLike Video.js’s data-setup, it works just by pasting
Customers are split between static HTML and single-page appsInitialize imperatively, with a deferred command queueCalls made before load are queued in an array and replayed after load, so the same code works in both
Any shapeAdd a double-load guard from the first commitTag managers fire snippets twice. Four of the five products ship one

Once the top two rows settle the shape, the versioning policy is settled with it.

I put a per-request nonce CSP in Decision 3 because, while the conversation log isn’t as sensitive as the “would hurt to leak” row, I still didn’t want it carried off. If you’re handling auth tokens or recorded footage and considering encryption, then as I wrote in Decision 4, in-page scripts can’t be fully kept out, so I expect you’d start from a design that at least never puts the key on the same postMessage channel (this is reasoning; I haven’t tried it).

Wrap-up and where to go next

An SDK where pasting one script line makes something work has quietly settled all six decisions behind the scenes. Actually reading the five products’ bundles showed that their answers branch cleanly, each worked backward from economic and technical constraints. When you build your own, get the requirements clear first, then choose decision by decision. That is the series’ argument as a whole.

Directions to go deeper:

  • Observability: a design that catches errors inside the iframe and sends them to your own server, so window.onerror on the customer site stays clean
  • A/B testing: switching the variant shown inside the iframe from your own side
  • Privacy: using the fact that the iframe can’t access the customer site’s cookies to simplify GDPR/CCPA compliance
  • Binary transport: carrying Protocol Buffers or Cap’n Proto over postMessage to cut JSON serialization cost

Your own SDK will need these one by one as it gets closer to production. Once the skeleton is built, I hope this series serves as a reference point as you extend it in each direction.

Measured 2026-08-08. The five products’ bundle IDs, keys and URLs will change, but these six decision axes won’t. Tear them apart again a year from now and the same framework should still read them.