postMessage contracts: Vimeo, YouTube, Safie compared
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.
| Product | Message format | Type validation | Origin check | Handshake | Encryption |
|---|---|---|---|---|---|
| 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 payload | JSON Schema (safie.link/schemas/...) | Return if origin !== this._targetOrigin (child uses * until ACK) | "syn" → "ack" → "connected" + key exchange | AES-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.

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.

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.

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.
-
Do parent and child share state?
No (Vimeo style: method calls and event notifications only) → apingthat 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. -
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 samepostMessage. -
Do you publish the message shape in machine-readable form?
A closed set of clients where shipping.d.tsis enough → TypeScript types only.
Third parties may write their own clients → JSON Schema, with runtime validation. -
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.

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.
Was this article helpful?