Skip to main content
The browser side of Canvas Embed SDK is a small 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:
Use requestId to correlate command responses.

Minimal helper

moda-embed-client.ts
The iframe you pass in needs the right capability attributes — clipboard paste and the file picker for image upload depend on them:
Export downloads happen in your page (you call 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:
  1. Session ready — the iframe posts ready once, 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).
  2. Canvas interactive — the canvas finishes loading and rendering separately. This is signalled by canvas:changed with { status: 'loaded', page, pageCount, category, pages } (and an initial page:changed). Gate page/export/edit commands (focusPage, export, insertText, insertImage) on this, not on ready.
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

Events:
  • canvas:saving
  • canvas:saved
  • canvas:save_error

Export

PDF exports emit a selectable text layer by default. Pass 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:
Every format is produced on Moda’s servers, so 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 inline dataUrl on any format.
  • A multi-page PNG/JPEG export is ONE .zip, assembled server-side. filename and mimeType follow the artifact, so check them rather than assuming the requested format.
Exports also enforce the session’s export permission server-side: a session created in 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

Event:
  • page:changed with { page, pageCount, category, pages }

Toggle readonly

This is useful when your app needs to temporarily block editing while another workflow is running. It cannot grant editing to a non-edit session.

Insert text

Insert image

Use File 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.
Use url when your app already has an HTTPS image URL that Moda can render.

Chat modes

For ui.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.
Incremental, in-flight streaming (thinking, tool steps, progress) is surfaced through chat:progress and chat:tool_call, never as chat:message deltas.

All chat events

Refresh session

The iframe proactively emits session: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:
If a refresh doesn’t land in time, the iframe emits 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.
Do not respond to session:expired by calling POST /v1/embed/sessions/ {session_id}/refresh. Refresh rotates the token of a live session; it cannot resurrect a dead one. After expiry it returns 404 (the session record is gone) or 409 (still present but expired/revoked) — a retry loop against it never recovers. Persist with one and the API begins answering 410 Gone, which is terminal — no Retry-After, and waiting changes nothing. The only recovery is creating a new session with POST /v1/embed/sessions and swapping the iframe to the new embed_url.
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.
Two nuances worth knowing:
  • scope tells you which boundary lapsed. session:expired with scope: 'browser_token' means the short-lived token expired before a refresh landed; the outer session may still be alive, in which case a session.refresh can 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.
The proactive 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, and P
When your parent app has focus, keyboard events belong to your app. If you want parent-level buttons or shortcuts, send explicit commands such as save, export, or chat.send.