Skip to content

Embeds

Sunny has four surfaces you can put in an <iframe> on your own site. Each one speaks a small, versioned postMessage protocol, so your page can read the layout, steer the view, and react to what the visitor does. If you’d rather not write the listener yourself, the embed.js script mounts the frame and hands you callbacks.

All four are served from https://app.sunnysdk.com and can be framed from any origin (Content-Security-Policy: frame-ancestors *). Every other Sunny page refuses cross-origin framing.

Route What it shows Accepts load_layout?
/embed The full studio. Yes — applies the document.
/s/[token]/embed A shared room, or a shared floor. The token comes from the room’s Share dialog. No. Rooms answer read_only; see below.
/i/[token]/embed One catalog item, with its finish and size pickers. No inbound messages at all.
/t/[token]/embed A manufacturer typical (a preset furniture assembly). No — answers read_only.

Room shares and typicals post the same ready, layout, layout_changed, and stage_loaded messages. A floor share framed at /s/[token]/embed is a reduced surface: it posts ready (with readOnly: true and no capabilities) and never posts a layout. It answers load_layout, set_camera, preview_finish, and preview_product with unsupported; get_layout gets no reply and set_view is ignored.

Query param Applies to Effect
view=2d | lineup Room Open on the plan or the lineup instead of the 3D view (the default).
view=plan Floor Open on the plan instead of the 3D view.
bom=0 Room Hide the docked bill of materials.
render=<n> Room Open with one of the share’s saved renders over the stage; 0 is the newest. Out-of-range values are ignored.
controls=full Room, floor Show the view switcher, ruler, zoom and expand buttons. The default (min) is the bare frame.
title=0 Room, floor Hide the title card.

Unknown values are ignored rather than rejected.

The protocol is off by default. It turns on only when the iframe’s src carries a host_origin query parameter that parses as an absolute http(s) URL:

Opting in
<iframe src="https://app.sunnysdk.com/embed?host_origin=https%3A%2F%2Fpartner.example.com"></iframe>
  • Without host_origin (or with a malformed one), the embed adds no listener and posts nothing.
  • The value is normalized to an origin (scheme, host, port). Every inbound message is checked against it and against the parent window before it’s parsed.
  • Every outbound message is posted to that exact origin, never "*".

The host origin is declared by you, not verified by Sunny. It scopes the two-way channel to the origin you name; on its own it doesn’t restrict who may frame the embed. To restrict that, set an origin allowlist on the token.

Every message in either direction is a plain object:

Every message
{ "source": "sunny-embed", "v": 1, "type": "...", "payload": {} }

A message with the wrong source, an unsupported v, or a payload that doesn’t match its type is ignored — logged to the embed’s console, never answered. That includes a set_camera or preview_finish with an invalid payload, so put a timeout on any request you send.

Within version 1, only additive optional fields and new message types are introduced. Ignore fields and types you don’t recognize. A breaking change would ship as version 2.

typepayloadAnswered withBehavior
load_layout{ document }layout_changed or errorApply a sunny-layout v1 document. Only /embed accepts it; room and typical embeds answer read_only, floor embeds unsupported.
set_view{ mode: "2d" | "3d" }noneSwitch between the plan and the 3D view. Ignored where unsupported.
get_layoutnonelayoutAsk for the current layout. No reply while there is nothing to report.
preview_finish{ requestId, presetId | null }finish_preview_resultShow one of the finish presets listed in ready's capabilities.finishPresets; null restores the published finishes.
set_camera{ requestId, camera }camera_resultMove the 3D camera. Requires capabilities.camera in ready. See Camera commands below.
preview_product{ requestId, placementId, optionId | null }product_preview_resultSwap a piece for one of the share's product options. Requires capabilities.products; null restores the published piece.

requestId is any string of 1–128 characters you choose. A newer request of the same type replaces one still in flight: the older one is answered with ok: false and code superseded.

ready can carry a capabilities object describing what this embed supports beyond the basics. Check it before sending the matching request.

Field Present when Enables
camera Always true on room and typical embeds. set_camera
finishPresets The share’s owner has defined finish presets. [{ id, label }]. preview_finish
products The share’s owner has defined product options. preview_product

/embed and floor shares send no capabilities. Finish presets and product options are set per share through the finishPresets and productOptions fields of PATCH /spaces/{spaceId}/shares/{token} on the REST API.

set_camera’s camera is one of these shapes. Placement ids are the id values in the layout document; coordinates are never exposed.

view Fields Effect
overview none The default framing of the whole room.
overhead none Looking straight down.
focus placementId, optional fit (boolean) Frame one piece.
orbit at least one of azimuthDeg (−360–360), elevationDeg (5–87), and either zoom (0.1–10) or zoomBy (0.25–4) Adjust the current orbit; zoom is absolute, zoomBy relative.
transition placementId, progress (0–1), azimuthDeg, elevationDeg, zoom, optional follow (boolean) Scrub a camera move toward a piece, for scroll-driven views.

Extra fields are rejected. A placement id that isn’t in the room answers unknown_placement.

type payload When
ready { version, readOnly, capabilities? } Once, right after the embed mounts.
layout { document } In reply to get_layout.
layout_changed { document, bom: { itemCount, total } } Debounced (about 300 ms) whenever the layout changes, including once at load.
render_selected { index, url } The visitor brought a saved render over the stage, or went back to the room (room shares).
stage_loaded {} Once, when the 3D stage has finished loading its models and textures.
camera_result { requestId, ok, code?, message? } In reply to set_camera.
finish_preview_result { requestId, presetId, ok, code?, message? } In reply to preview_finish, after the preview has rendered.
product_preview_result { requestId, ok, code?, message? } In reply to preview_product.
error { code, message } A load_layout couldn’t be applied.
  • document is a sunny-layout v1 document.
  • bom.total is in whole US dollars; bom.itemCount counts priced bill-of-materials lines (distinct item, finish, and size combinations), not placements.
  • render_selected’s index is a position in the share’s render list (0 = newest, the order render= counts in) and url is that render’s public image URL. Both are null when the room is back on stage. It never fires for the view the embed opened on.
  • stage_loaded is the signal to swap a poster image for the live frame. A room with nothing to stream sends it about a second after mount. A view with no 3D stage (opened on the plan, or a browser without WebGL) never sends it, so keep your poster if it doesn’t arrive.

error messages and the code on a failed *_result use a short, stable set. Match on code; message is human-readable and may change between releases.

code Meaning
read_only This surface never accepts load_layout (room and typical embeds).
invalid_layout The load_layout document failed validation or couldn’t be applied. message has the field errors.
unsupported This embed doesn’t offer the request: load_layout on a floor share, or set_camera, preview_finish, or preview_product where ready didn’t advertise the capability.
superseded A newer request of the same type replaced this one.
unknown_placement The camera command named a placement that isn’t in the room.
not_ready The 3D camera is still loading; retry after stage_loaded.
camera_failed The camera couldn’t be positioned.
invalid_preset preview_finish named a preset that doesn’t exist, or one that no longer applies to the room.
invalid_option preview_product named an option that doesn’t exist for that placement.
load_failed The finish or product preview couldn’t be loaded or rendered.
Vanilla JS
<!doctype html>
<html>
<body>
<iframe
id="sunny"
src="https://app.sunnysdk.com/s/YOUR_SHARE_TOKEN/embed?host_origin=https%3A%2F%2Fpartner.example.com"
style="width: 100%; height: 600px; border: 0"
></iframe>
<script>
const EMBED_ORIGIN = "https://app.sunnysdk.com"; // the iframe's origin
const iframe = document.getElementById("sunny");
const send = (type, payload) =>
iframe.contentWindow.postMessage(
{ source: "sunny-embed", v: 1, type, payload },
EMBED_ORIGIN,
);
window.addEventListener("message", (event) => {
if (event.origin !== EMBED_ORIGIN) return; // not from Sunny
if (event.source !== iframe.contentWindow) return; // not from this iframe
const msg = event.data;
if (!msg || msg.source !== "sunny-embed" || msg.v !== 1) return;
switch (msg.type) {
case "ready":
send("get_layout");
if (msg.payload.capabilities?.camera) {
send("set_camera", { requestId: "intro", camera: { view: "overhead" } });
}
break;
case "layout":
case "layout_changed":
console.log("layout:", msg.payload.document, "bom:", msg.payload.bom);
break;
case "camera_result":
if (!msg.payload.ok) console.warn("camera:", msg.payload.code);
break;
case "error":
console.warn("embed error:", msg.payload.code, msg.payload.message);
break;
}
});
</script>
</body>
</html>

/embed accepts load_layout with a sunny-layout v1 document. The document is validated, its placements are resolved against the catalog, and it replaces what’s on the stage. A placement whose catalogId doesn’t resolve is dropped (logged to the embed’s console) rather than failing the load — the protocol has no field to report partial drops, so compare the layout_changed that follows with what you sent. Success is signalled by that layout_changed; there’s no separate acknowledgement. A document that fails validation answers error with invalid_layout. Nothing is saved to a project.

/i/[token]/embed shows one catalog item with its own finish and size pickers. It accepts no messages from the host. With host_origin set, it posts two:

type payload When
item_ready { itemId, name, manufacturer, finishNames, sizeNames, selection, price } Once, when the item has loaded.
selection_changed { finishName, sizeName, price, accessoryIds? } Every time the visitor changes the finish, size, or accessories.
  • selection is { finishName, sizeName }. Either is null (never missing) when nothing is chosen on that axis.
  • finishNames and sizeNames list the options in catalog order, [] when there are none. manufacturer is null when the item has none on file.
  • price is the configured total — base price, plus the selected finish’s and size’s price differences, plus any selected accessories — in whole US dollars. It is null when the token hides prices.
  • accessoryIds lists the selected accessory item ids. It is present only when at least one is selected.
  • selection_changed always carries the full current selection, not a delta, so there’s no separate “confirm” message.

The frame also links to the item’s full page on Sunny, carrying the current selection.

embed.js wraps the same iframes and protocol in a small script with no dependencies. The raw iframe approach above stays fully supported; the script is a convenience.

Drop-in
<div data-sunny-item-token="YOUR_ITEM_TOKEN"></div>
<div data-sunny-space-token="YOUR_SHARE_TOKEN" data-sunny-view="3d" data-sunny-bom="0"></div>
<div data-sunny-typical-token="YOUR_TYPICAL_TOKEN"></div>
<script src="https://app.sunnysdk.com/embed/v1.js" async></script>

When the script runs, it replaces each element carrying one of those attributes with a sized iframe. It sets host_origin to your page’s origin for you, and points the iframe at the origin the script itself was loaded from. Elements added later can be mounted by calling SunnyEmbed.autoMount(); already-mounted elements are skipped.

Attribute Applies to Effect
data-sunny-item-token — Mount a single-item embed. Default size 480 × 560.
data-sunny-space-token — Mount a room share. Default size 640 × 480.
data-sunny-typical-token — Mount a typical. Default size 640 × 480.
data-sunny-width All Iframe width in pixels.
data-sunny-height All Iframe height in pixels.
data-sunny-view Room 2d, 3d, or lineup.
data-sunny-bom Room 0 or false hides the bill of materials.
data-sunny-render Room Open on a saved render; 0 is the newest.

The declarative path wires no callbacks. For those, call the JavaScript API.

Callbacks
<div id="chair"></div>
<div id="room"></div>
<script src="https://app.sunnysdk.com/embed/v1.js"></script>
<script>
const chair = SunnyEmbed.mount(document.getElementById("chair"), {
token: "YOUR_ITEM_TOKEN",
onReady: (item) => console.log(item.name, item.selection, item.price),
onSelectionChanged: (sel) => console.log(sel.finishName, sel.sizeName, sel.price),
onError: (err) => console.warn(err),
});
const room = SunnyEmbed.mountSpace(document.getElementById("room"), {
token: "YOUR_SHARE_TOKEN",
view: "3d",
onReady: async (ready) => {
if (ready.capabilities?.camera) await room.setCamera({ view: "overhead" });
},
onLayoutChanged: ({ bom }) => console.log(bom.itemCount, "lines,", bom.total, "USD"),
onStageLoaded: () => document.getElementById("poster")?.remove(),
});
// Later, e.g. on a route change:
chair.destroy();
room.destroy();
</script>
Function Options Returns
SunnyEmbed.mount(el, opts) token, width, height, onReady, onSelectionChanged, onError { iframe, destroy }
SunnyEmbed.mountSpace(el, opts) token, width, height, view, bom, render, onReady, onLayout, onLayoutChanged, onRenderSelected, onStageLoaded, onError { iframe, destroy, setCamera(camera), previewFinish(presetId) }
SunnyEmbed.mountTypical(el, opts) token, width, height, onReady, onLayout, onLayoutChanged, onStageLoaded, onError { iframe, destroy }
  • Callbacks receive the message’s payload, not the envelope.
  • destroy() removes the listener and the iframe. It’s safe to call twice.
  • setCamera and previewFinish return promises that resolve when the embed confirms, and reject with an error whose code is one of the error codes above or an SDK-side code: not_ready (called before onReady), unsupported (capability not advertised), invalid_camera, invalid_preset, timeout, reloaded, or destroyed.
  • onError fires only for SDK usage mistakes, such as a missing token. It is not called when the embed stays silent.

/embed/v1.js redirects to the current content-hashed build, so fixes arrive without changing your snippet. To use Subresource Integrity, pin the hashed URL the redirect points to instead — the stable URL can’t carry an integrity hash. A breaking change would ship as /embed/v2.js.

embed.js has no floor mount; frame a floor share with a plain <iframe>.

Item, typical, and share tokens can carry an allowlist of origins (scheme, host, and optional port — no path) permitted to frame them. Empty, the default, means anywhere.

With an allowlist set, the embed sends a narrower frame-ancestors header, so browsers refuse to frame it anywhere else. If the page’s host_origin isn’t on the list, the protocol stays off: the iframe may still mount, but no messages are posted and embed.js callbacks never fire. onError isn’t called for this case.

Set the allowlist in the token’s dialog in the app, or with allowedOrigins on the API calls below. null clears it.

  • Share tokens come from a room’s or floor’s Share dialog.
  • Item tokens come from the catalog item’s ⧉ Embed action. Each token has an optional label, a show-price switch (off hides every price in the frame and sends price: null), and an origin allowlist, and can be revoked. A revoked or unknown token renders a 404.
  • Typical tokens come from the typical’s ⧉ Embed action, with the same settings. Prices are hidden by default on a new typical token and shown by default on a new item token.

Item and typical tokens can also be managed programmatically by an OAuth client or a workspace API key (Organization → API keys) carrying the embeds:write scope, through the REST API or the MCP tools manage_item_embeds, item_embed_stats, and manufacturer_embed_stats:

Method and path Purpose
POST /catalog/items/{id}/embeds Mint a token. Body: label, showPrice, allowedOrigins.
GET /catalog/items/{id}/embeds List the item’s active tokens.
PATCH /catalog/embeds/{token} Change label, showPrice, or allowedOrigins.
DELETE /catalog/embeds/{token} Revoke. Permanent.
GET /catalog/embeds/{token}/stats 30-day analytics.
POST/GET /catalog/typicals/{slug}/embeds Mint or list typical tokens.
PATCH/DELETE /catalog/embeds/typicals/{token} Update or revoke a typical token.
GET /catalog/embeds/typicals/{token}/stats 30-day analytics for a typical token.

Access is limited to items and typicals from manufacturers your workspace works with, either as the manufacturer or as a dealer. A manufacturer sees every token for its products; a dealer sees only the tokens its own workspace minted. Minting shares the API’s cost rate limit (5 requests a minute per caller).

Item embeds count, per day and token: views (one per load of the iframe), selections (finish or size changes, with the names chosen), and opens (clicks through to Sunny). They’re counted whether or not host_origin is set, and nothing identifying a visitor is stored. GET /catalog/embeds/{token}/stats returns the trailing 30 days — totals (views, selections, opens) plus topFinishes and topSizes — to a caller with embeds:read or embeds:write. Numbers stay readable after a token is revoked.

Sunny is an oEmbed provider for share and item links. Share and item pages advertise the endpoint with a <link rel="alternate" type="application/json+oembed"> tag.

Request
GET https://app.sunnysdk.com/api/oembed?url=https%3A%2F%2Fapp.sunnysdk.com%2Fs%2FYOUR_SHARE_TOKEN&maxwidth=800
  • url (required) must be a Sunny share page (/s/{token}, a room or floor) or item page (/i/{token}) — not the /embed URL.
  • maxwidth and maxheight are optional.
  • format defaults to json; xml returns 501.

The response is a rich oEmbed object whose html is an <iframe> pointing at the matching embed route. A link that isn’t a Sunny share or item page, a revoked token, or a customer (whole-project) link returns 404.