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.
Surfaces
Section titled “Surfaces”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.
URL options for room and floor shares
Section titled “URL options for room and floor shares”| 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.
Turning on the protocol
Section titled “Turning on the protocol”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:
<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.
Envelope
Section titled “Envelope”Every message in either direction is a plain object:
{ "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.
Host → embed
Section titled “Host → embed”| type | payload | Answered with | Behavior |
|---|---|---|---|
load_layout | { document } | layout_changed or error | Apply a sunny-layout v1 document. Only /embed accepts it; room and typical embeds answer read_only, floor embeds unsupported. |
set_view | { mode: "2d" | "3d" } | none | Switch between the plan and the 3D view. Ignored where unsupported. |
get_layout | none | layout | Ask for the current layout. No reply while there is nothing to report. |
preview_finish | { requestId, presetId | null } | finish_preview_result | Show one of the finish presets listed in ready's capabilities.finishPresets; null restores the published finishes. |
set_camera | { requestId, camera } | camera_result | Move the 3D camera. Requires capabilities.camera in ready. See Camera commands below. |
preview_product | { requestId, placementId, optionId | null } | product_preview_result | Swap 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.
Capabilities
Section titled “Capabilities”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.
Camera commands
Section titled “Camera commands”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.
Embed → host
Section titled “Embed → host”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. |
documentis asunny-layoutv1 document.bom.totalis in whole US dollars;bom.itemCountcounts priced bill-of-materials lines (distinct item, finish, and size combinations), not placements.render_selected’sindexis a position in the share’s render list (0 = newest, the orderrender=counts in) andurlis that render’s public image URL. Both arenullwhen the room is back on stage. It never fires for the view the embed opened on.stage_loadedis 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 codes
Section titled “Error codes”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. |
Example host page
Section titled “Example host page”<!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>Loading a layout into /embed
Section titled “Loading a layout into /embed”/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.
Single-item embeds
Section titled “Single-item embeds”/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. |
selectionis{ finishName, sizeName }. Either isnull(never missing) when nothing is chosen on that axis.finishNamesandsizeNameslist the options in catalog order,[]when there are none.manufacturerisnullwhen the item has none on file.priceis the configured total — base price, plus the selected finish’s and size’s price differences, plus any selected accessories — in whole US dollars. It isnullwhen the token hides prices.accessoryIdslists the selected accessory item ids. It is present only when at least one is selected.selection_changedalways 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
Section titled “embed.js”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.
<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.
JavaScript API
Section titled “JavaScript API”<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.setCameraandpreviewFinishreturn promises that resolve when the embed confirms, and reject with an error whosecodeis one of the error codes above or an SDK-side code:not_ready(called beforeonReady),unsupported(capability not advertised),invalid_camera,invalid_preset,timeout,reloaded, ordestroyed.onErrorfires 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>.
Origin allowlists
Section titled “Origin allowlists”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.
Managing tokens
Section titled “Managing tokens”- 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).
Analytics
Section titled “Analytics”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.
oEmbed
Section titled “oEmbed”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.
GET https://app.sunnysdk.com/api/oembed?url=https%3A%2F%2Fapp.sunnysdk.com%2Fs%2FYOUR_SHARE_TOKEN&maxwidth=800url(required) must be a Sunny share page (/s/{token}, a room or floor) or item page (/i/{token}) — not the/embedURL.maxwidthandmaxheightare optional.formatdefaults tojson;xmlreturns501.
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.