postMessage protocol between your page and the Moda iframe.
The beta protocol version is
2026-05-27. Always send and validate the
channel, version, and sessionId fields.There are two version numbers, and they are not the same. They live in different layers and are set in different places:
They version independently and will diverge further over time. The iframe silently drops any message whose
version doesn’t match the protocol version, so a transposed value fails with no error — send 2026-05-27 in the envelope, and 2026-05-01 in the API header. See Session API for the header.Message envelope
Every command and event uses the same envelope:requestId to correlate command responses.
Minimal helper
moda-embed-client.ts
iframe you pass in needs the right capability attributes — clipboard paste and the file picker for image upload depend on them:
a.click() on the returned downloadUrl), so the iframe needs no downloads permission. See Security and Production for the full rationale and CSP guidance.
Handshake and lifecycle
There are two distinct milestones, signalled by two different events:- Session ready — the iframe posts
readyonce, as soon as it has loaded its session. Payload is minimal:{ canvasId, mode, externalUser? }. After this you can send session-scoped commands (save,chat.send,setReadonly). - Canvas interactive — the canvas finishes loading and rendering separately. This is signalled by
canvas:changedwith{ status: 'loaded', page, pageCount, category, pages }(and an initialpage:changed). Gate page/export/edit commands (focusPage,export,insertText,insertImage) on this, not onready.
ready does not fire a second time with page info — page state arrives via canvas:changed/page:changed. (You can get a ready carrying page state, but only as a reply to host.hello — see below.)
Use host.hello to win the load race
The iframe’s first ready can fire before your page has attached its message listener (a classic iframe-load race). host.hello is the robust fix: the iframe replies with ready every time it receives one, and that reply includes the current page state if the canvas is already interactive. Poll it until you get a ready, then stop:
Commands
Save
canvas:savingcanvas:savedcanvas:save_error
Export
flatten: true to rasterize every
page instead — useful when a canvas uses a custom font whose glyphs must be reproduced
exactly, at the cost of selectable text and a larger file:
export:completed always carries a job
envelope — jobId, downloadUrl and a warnings array — rather than inline bytes. Your
page fetches the artifact from the URL:
downloadUrl— a short-lived signed link to the same durable artifact every other Moda export transport returns. There is no inlinedataUrlon any format.- A multi-page PNG/JPEG export is ONE
.zip, assembled server-side.filenameandmimeTypefollow the artifact, so check them rather than assuming the requested format.
view-no-export mode is rejected by the export endpoint, not merely hidden in the SDK.
warnings lists anything that qualifies an otherwise successful artifact — for example
transparent_background_unsupported (a page with a background effect was composited
opaque), pptx_content_dropped (content is missing from the deck) and
pptx_shape_rasterized (shapes were baked into the slide background rather than kept as
editable objects). Surface these to your users; an artifact that reports them is incomplete.
Because the bytes never cross
postMessage, export size is bounded by the
artifact’s own limits rather than by the message channel. The SDK still times
out after ~120s waiting for the job, so for very large documents prefer the
Public API export,
which supports background polling.Focus a page
page:changedwith{ page, pageCount, category, pages }
Toggle readonly
Insert text
Insert image
UseFile or Blob when the user picks or pastes a local image. The iframe uploads it using its scoped embed browser credential.
Embed uploads currently accept PNG, JPEG, GIF, and WebP files up to 25 MB. SVG uploads are not accepted in embedded sessions.
url when your app already has an HTTPS image URL that Moda can render.
Chat modes
Forui.chat: "inside", the iframe renders Moda’s hosted chat panel. Your app does not need to send chat commands, but it can still listen for chat:* events for analytics, logging, or mirrored UI.
For ui.chat: "external", your app owns the chat UI and sends prompts to the Moda agent.
chat:message — the one that matters
Each chat:message is a complete, self-contained message appended to the conversation — not an incremental delta. The text lives in text, and the author in sender. Do not accumulate or diff these; render each as its own bubble keyed by id.
chat:progress and chat:tool_call, never as chat:message deltas.
All chat events
Refresh session
The iframe proactively emitssession:refresh_requested ahead of expiry (by default 60
seconds before the browser token lapses), so you can refresh in response instead of
running your own timer:
session:expired (or session:revoked)
so you can surface a re-authentication prompt.
Recover from full expiry — create, don’t refresh
session:expired and session:revoked are terminal for that session. Once either
fires, the session no longer exists server-side, and every lane of the embed stops with
it — including live sync: the collaboration socket is closed with a distinct terminal
code and the iframe stops retrying, so edits made in a lingering tab are not being saved.
One recovery handler covers both events. Two properties matter: try a refresh first
when only the browser token lapsed (scope: 'browser_token' — the outer session may
still be alive, and a refresh preserves the running iframe and any unflushed edits where
a reload would discard them), and guard the whole thing with a single in-flight flag
— session:expired and session:revoked can both fire for the same dead session
(revocation is the stronger verdict and is delivered even after an expired signal), and
two concurrent handlers would mint two replacement sessions.
scopetells you which boundary lapsed.session:expiredwithscope: 'browser_token'means the short-lived token expired before a refresh landed; the outer session may still be alive, in which case asession.refreshcan still recover it — that is why the handler above tries refresh first for that scope.scope: 'session'means the session itself is gone and only a create recovers. Creating a new session is always safe in both cases; refreshing first is the edit-preserving optimization.- Edits made after the session died cannot reach Moda. The session that authorized
writes is gone, so anything the user typed into a dead tab is not persisted and is not
carried into the replacement session. The refresh loop (
session:refresh_requested) is what protects unsaved work; the create-on-expired handler stops the bleeding, it does not recover it.
session:refresh_requested warning exists so a healthy integration rarely
reaches this point — but a suspended background tab can sleep through every warning and
wake up long after expiry, so the session:expired handler above is not optional.
Events
Keyboard and paste behavior
When the iframe has focus, Moda handles normal editor shortcuts:- Undo/redo
- Copy, cut, paste
- Pasted image files and screenshots
- Delete, duplicate, group, ungroup, arrange
- Arrow nudging
- Tool shortcuts like
V,T,R,O,L, andP
save, export, or chat.send.