← Back to series

postMessage contracts: Vimeo, YouTube, Safie compared

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

In Iframe isolation and CSP nonces: Safie’s 2.61 MB body I took Safie’s iframe isolation apart. Inside the iframe, Safie’s own CSP and its own JS run, and parent and child talk over postMessage. This part is about designing what goes into that conversation. Of the five products, three (Vimeo, YouTube and Safie) are iframe-based, so I read their three protocols side by side.

Protocols at a glance

Implementations extracted from each bundle as of 2026-08-08.

ProductMessage formatType validationOrigin checkHandshakeEncryption
Vimeo{method, value, event, data}In code: === "ready" etc.vimeo.com domain check + event.source match; pins the first origin"ping" → "ready" (calls wait until ready)None
YouTube{event, id, channel, data}Internal switch branches.origin === k.O strict equality"listening" → "initialDelivery"None
Safie{type, data, guid} + wrapped payloadJSON Schema (safie.link/schemas/...)Return if origin !== this._targetOrigin (child uses * until ACK)"syn" → "ack" → "connected" + key exchangeAES-GCM (parent → child only)

There are three generations. Vimeo is the minimal setup, YouTube adds state sync, and Safie is the heaviest, with a handshake and encryption. Which generation you need depends on how sensitive the SDK’s data is and how much state parent and child share.

The first messages exchanged by the parent (SDK on the customer site) and the child (iframe) in the three iframe products. Vimeo (Gen 1: minimal): ping from parent to child, ready from child to parent. play() and other calls wait inside the SDK until ready; after that, {method, value} goes from parent to child and {event, data} from child to parent. Origin check: vimeo.com domain + event.source, pins the first origin. No encryption. YouTube (Gen 2: state sync): listening from child to parent, then initialDelivery from parent to child with the initial state (video ID, autoplay, captions); after that, command goes from parent to child and event from child to parent. Origin check: strict equality, event.origin === k.O. No encryption. Safie (Gen 3: encryption): syn from child to parent (hands over keys), encrypted ack from parent to child, connected from child to parent. From then on, parent-to-child commands are encrypted with AES-GCM. Origin check: strict equality; the child uses "*" until the ACK. Encryption: AES-GCM (parent to child only)

Vimeo: the minimal setup

Pull the postMessage code out of Vimeo’s bundle (24 KB / gzip 8 KB) and it is surprisingly short.

// excerpt (formatted)
postMessage(e, t.origin)

// receiving side
event.data.event === "ready"
event.data.event === "spacechange"
event.data.event === "drminitfailed"

It sends {method, value} and receives {event, data}, so a method call maps directly onto a message. There is one light handshake: new Vimeo.Player() sends a ping to the iframe, and calls like play() wait inside the SDK until the child returns a "ready" event or a reply to the ping. The wait for ready is hidden inside a Promise, so customers don’t have to write callbacks.

The origin check is a little unusual.

// receiving side (formatted)
if (isVimeoUrl(event.origin) && this.element.contentWindow === event.source) {
  if (this.origin === "*") this.origin = event.origin
  // ...
}

The pattern is “pin the origin from the first message you receive as the target from then on”; here I’ll call it first-message trust. It’s a practical compromise for cases where the iframe’s real origin isn’t known at construction time (dynamic subdomains and so on). It isn’t unconditional, though. Before pinning, it checks with a regular expression that the origin is a vimeo.com domain, and it checks that the sender is its own iframe’s contentWindow. If another iframe speaks first, event.source won’t match, so there is little room for a takeover. The only message sent without narrowing the target is the first ping, before the origin is known.

Flow diagram of the checks the parent runs when a message arrives, Vimeo and YouTube side by side. Vimeo (first-message trust): when a message arrives, is the origin a vimeo.com domain (checked with a regex)? If not, drop it. Is the sender our own iframe (contentWindow === event.source)? If not, drop it. If the target is still "*", pin it to this origin, then handle the message. YouTube (strict equality): check event.origin === k.O (k.O is YouTube's own origin, saved when the iframe was created); if not, drop it; if it matches, handle it. Below: the only message Vimeo sends with the target left as "*" is the first ping, before the origin is known

YouTube: adding state sync

YouTube’s widgetapi (27 KB) runs a protocol one generation newer than Vimeo’s.

// vocabulary
"listening"      // child to parent: "I'm ready"
"initialDelivery" // parent to child: "here is the initial state"
"command"        // parent to child: method call
"event"          // child to parent: state change notification

"listening" is effectively one side of a handshake, sent to the parent once the JS inside the iframe finishes initializing. The parent waits for it, then sends the video ID, autoplay and caption settings to the child in "initialDelivery". From then on it sends play and pause through "command" and receives state changes through "event".

The origin check is a strict equality comparison.

event.origin === k.O

k.O is YouTube’s own origin, remembered when the iframe was created (normally https://www.youtube.com), and it’s checked on every message. Compared with Vimeo’s first-message trust, the code is shorter and the design is stiffer. I read the one-sided handshake as a consequence of the data YouTube handles (video metadata, playback position) being public in the first place: getting state sync right matters more than protecting against eavesdropping.

Safie: the heaviest, built for sensitive data

Safie works as I broke it down in Iframe isolation and CSP nonces: Safie’s 2.61 MB body. Here are just the key points.

// child (iframe body) → parent: send the private key together with the wrapped AES key
postMessage("syn", { privateKey: a.privateKey, wrappedAesKey: c.wrappedAesKey })
// parent → child: ACK encrypted with AES-GCM
postMessage("ack", { id, name })
// child → parent
postMessage("connected")
// origin check (the child uses "*" until the ACK)
if (this._targetOrigin !== "*" && event.origin !== this._targetOrigin) return
// from here on, parent → child payloads are encrypted with AES-GCM

It’s a postMessage version of TCP’s 3-way handshake with an AES-GCM key exchange over asymmetric keys grafted on. But the private key and the wrapped key travel in the same message, so anyone who can receive the SYN can decrypt. Rather than a defense against interception, it is closer to an integrity check (the child drops messages from anyone without the key) plus obfuscation that keeps auth tokens from flowing in plaintext.

My reading is that Safie goes this far because the data it handles includes access rights to recorded video. If a video player’s currentTime leaks, the real damage is small. If a camera-feed SDK’s auth token leaks, a stranger can watch the contracted cameras live. Which protocol generation you pick depends on the economic and legal value of the data your SDK handles.

Bundling method definitions as JSON Schema

Safie’s loader contains a row of safie.link/schemas/invoke-method/ URLs.

safie.link/schemas/invoke-method/auth/set-token
safie.link/schemas/invoke-method/devices/query-devices
safie.link/schemas/invoke-method/streaming/start-ptz

These are the $id values of JSON Schemas (draft-07) bundled into the loader, and they define, in machine-readable form, the shape of the messages sent through postMessage. The URLs are only names: open one and you get a 404 (checked 2026-10-01). The schemas themselves live in the bundle, each incoming invoke is validated against them at runtime, and a mismatch gets an invalid_params error back. An unexpected type is rejected on the spot. TypeScript’s .d.ts helps with editor support, but in an SDK where parent and child run JS from different domains it’s hard to be sure both sides share the same type definitions, and JSON Schema’s runtime validation acts as a safety net.

Table figure comparing three places to put a type contract by whether it takes effect in development or at runtime. Title: type contracts take effect at different times. Parent and child are JS on different domains, and only runtime validation can reject a value that actually arrives through the iframe. TypeScript types (ship a .d.ts): in development you get completion and type errors, assuming parent and child share the same version; at runtime they are gone, and whatever arrives, types won't stop it. JSON Schema (Safie, safie.link/schemas/…): in development the schemas ship in the bundle, each named by a $id URI; at runtime it validates what arrives against the schema, and a mismatch gets an invalid_params error. String checks in code (Vimeo, YouTube): internal TypeScript types in development; at runtime it branches on event === "ready" and the like, and an unknown value matches no branch. Takeaway: if third parties will write their own clients, bring validation all the way to runtime (the Safie side). Safie's schemas and validation from the loader at safie.link/sdk/js/api/v1/latest/ fetched 2026-10-01 (the $id URLs return 404); the rest from each bundle on 2026-08-08

Vimeo and YouTube don’t go this far. They get by with string checks in the code, like event === "ready", and internal TypeScript types. Across the three iframe products, the more sensitive the data, the further validation reaches into runtime. That is an inference from three examples, so I can’t claim more than a tendency.

Decision points for your own SDK

Where you land among the three generations can be decided with these questions.

  1. Do parent and child share state?
    No (Vimeo style: method calls and event notifications only) → a ping that waits for readiness and a "ready" are about all you need.
    Yes (YouTube style: the parent sends initial state) → you need a step where the parent waits for a ready signal, the equivalent of "listening", before sending the initial state.

  2. How sensitive is the payload?
    Public data only → no encryption needed.
    Harmful if another script running in the receiving window reads it → consider encryption, and don’t put the key in the same postMessage.

  3. Do you publish the message shape in machine-readable form?
    A closed set of clients where shipping .d.ts is enough → TypeScript types only.
    Third parties may write their own clients → JSON Schema, with runtime validation.

  4. How strict is the origin check?
    The iframe origin is fixed at construction → strict equality (YouTube style).
    Sandboxing or subdomain switching is involved → first-message trust (Vimeo style) is sometimes chosen, with trade-offs.

The four questions that decide the protocol generation, each laid out as two options: without the condition and with it. 1 Do parent and child share state? No (methods and events only): ping and "ready" are about enough (Vimeo style). Yes (parent sends initial state): wait for the ready signal, then send the initial state (YouTube style). 2 How sensitive is the payload? Public data only: no encryption. Harmful if another script reads it: consider encryption, and never put the key in the same postMessage. 3 Publish the message shape in machine-readable form? Closed set of clients: TypeScript types (.d.ts) only. Third parties write their own clients: JSON Schema with runtime validation (Safie). 4 How strict is the origin check? iframe origin is known at construction: strict equality (YouTube style). sandbox or subdomain switching involved: first-message trust is sometimes chosen (Vimeo style, with trade-offs)

Here is an example of an implementation where the minimal setup is enough. If all you need is for the child to report the iframe’s height to the parent, identify the sender by matching event.source against contentWindow, and run the received number through Number.isFinite and an upper clamp. An implementation that matches by URL breaks the moment the same page is embedded twice. The embed.js I wrote about in historymap: one YAML file becomes a corporate-style product-history timeline is exactly this.

Next: versions and cache-control

Once the protocol is fixed, the next question is versioning. Line up the cache-control headers and URL structures of the five products, and the iframe SDKs and the DOM-injection SDKs split cleanly in two: Video.js and Mux Player let you pick a version, while Safie, YouTube and Vimeo don’t. The next part covers why they split that way.

Measured 2026-08-08. Protocol vocabulary and handshake shapes stay stable for years; payload specs change.