The 3D viewer runs inside an <iframe> and communicates with its embedding parent
window through the browser postMessage
API. This doc describes the messages the viewer sends and receives, so an
integration embedding the viewer can implement and listen to them.
There are two independent messaging styles:
- Fire-and-forget notifications (viewer → parent) the viewer posts a message
towindow.parent. No response is expected. Most are plain strings; the
production-values message is a structured object. - Request / response command channel (parent → viewer) the parent sends the
viewer a command and receives a structured response. Origin-checked against a
trusted-origins allowlist.
Origin handling. Outbound notifications are posted with a target origin of
'*'. Because the sender does not restrict the target, the consuming parent
must validateevent.originitself. The inbound command channel does enforce
an origin allowlist (see that section).
Message index
Every listener example below assumes a shared constant for the viewer's origin:
const VIEWER_ORIGIN = 'https://viewer.scanifly.com'; // the viewer's originOutbound notifications (viewer → parent)
PRODUCTION_VALUES_CHANGED
PRODUCTION_VALUES_CHANGEDThe viewer's richest notification. Emitted whenever a user edit causes solar
production values to settle, so the parent can mirror the current design's
production numbers. Structured object.
Message shape
{
type: 'PRODUCTION_VALUES_CHANGED',
payload: {
projectId: string | undefined, // correlation ID for the project
designId: string | undefined, // correlation ID for the design
annualProductionKwh: number, // total annual production, kWh
segments: Array<{
id: string,
name: string,
moduleCount: number,
systemSizeKw: number, // system size, kW
annualProductionKwh: number, // per-segment annual production, kWh
asa: number | undefined, // annual solar access; undefined w/o viewshed data
tsrf: number | undefined, // annual TSRF; undefined w/o viewshed data
}>,
}
}projectId/designIdlet a consumer associate the message with a specific
project or design. Use these if you embed more than one viewer, or to guard
against stale messages.asaandtsrfareundefinedwhen the design has no viewshed data.- All energy figures are in kWh and all system sizes in kW (no conversion needed).
When it fires — the viewer only posts when all of the following hold:
- The viewer is embedded (
window !== window.parent). A standalone viewer
never posts. - The user has made at least one undoable design edit. Initial load and hydration
are suppressed — you will not receive a message just from opening a design. - Production values are ready and complete for every roof segment (no partial
or transient zero totals), and either production just became ready or the total
production changed.
In practice: expect one message shortly after each user change that alters
production, carrying the fully settled totals.
Listening
window.addEventListener('message', (event) => {
// 1. Verify the sender origin — do not trust messages from other origins.
if (event.origin !== VIEWER_ORIGIN) return;
const data = event.data;
if (!data || data.type !== 'PRODUCTION_VALUES_CHANGED') return;
const { projectId, designId, annualProductionKwh, segments } = data.payload;
// 2. (Optional) Ignore messages for a design you are not currently showing.
if (designId !== currentDesignId) return;
// 3. Update your UI.
updateProductionDisplay({ annualProductionKwh, segments });
});Guidelines:
- Always check
event.origin. The viewer posts with target origin'*'. - Discriminate on
data.type === 'PRODUCTION_VALUES_CHANGED'. Other messages
(plain strings) arrive on the same channel. - Treat it as idempotent state, not a delta. Each message carries the complete
current production snapshot; render the latest one. - Register the listener before the design can change (e.g. on iframe mount) so
you don't miss the first post.
DESIGN_SAVED
DESIGN_SAVEDA plain string message. Communicates that a design save completed
successfully. Fires whether the save was triggered by the user or by the
saveDesign command.
window.addEventListener('message', (event) => {
if (event.origin !== VIEWER_ORIGIN) return;
if (event.data === 'DESIGN_SAVED') onDesignSaved();
});DESIGN_FINALIZED
DESIGN_FINALIZEDA plain string message. Communicates that the user finalized a simple design.
window.addEventListener('message', (event) => {
if (event.origin !== VIEWER_ORIGIN) return;
if (event.data === 'DESIGN_FINALIZED') onDesignFinalized();
});showChatWidget / hideChatWidget
showChatWidget / hideChatWidgetA pair of plain string messages requesting that the parent show or hide the
HubSpot chat widget. hideChatWidget is sent when an irradiance map becomes active
(so the widget doesn't cover the map); showChatWidget otherwise. Only meaningful
if the parent hosts the HubSpot chat widget.
window.addEventListener('message', (event) => {
if (event.origin !== VIEWER_ORIGIN) return;
if (event.data === 'showChatWidget') showHubSpotWidget();
if (event.data === 'hideChatWidget') hideHubSpotWidget();
});Inbound command channel (parent → viewer)
A structured request/response channel for the parent to drive the viewer. Unlike
the notifications above, this channel enforces a trusted-origin allowlist: the
viewer only handles commands whose event.origin is in its configured allowlist,
and silently ignores the rest.
Request / response format
// Parent → viewer
type Request = {
id?: string; // optional, echoed back for correlation
action: string; // the command to run, e.g. 'saveDesign'
payload?: unknown; // optional command input
};
// Viewer → parent (posted back to event.source at event.origin)
type Response = {
ok: boolean; // whether the command succeeded
action: string; // the action that was executed
id?: string; // echoed from the request, if provided
payload?: unknown; // command result, if any
error?: string; // error message when ok === false
};saveDesign
saveDesignSaves the current design, equivalent to the user pressing save. The response
carries no payload; ok: true indicates success.
const REQUEST_ID = crypto.randomUUID();
const onReply = (event) => {
if (event.origin !== VIEWER_ORIGIN) return;
const res = event.data;
if (res?.action !== 'saveDesign' || res.id !== REQUEST_ID) return;
window.removeEventListener('message', onReply);
if (res.ok) onSaveSucceeded();
else onSaveFailed(res.error);
};
window.addEventListener('message', onReply);
viewerIframe.contentWindow.postMessage(
{ id: REQUEST_ID, action: 'saveDesign' },
VIEWER_ORIGIN,
);The viewer also emits the fire-and-forget DESIGN_SAVED
notification when the save completes. Use the command response for correlation; use
DESIGN_SAVED if you only need to know a save happened.
In all cases, a parent integration should validate event.origin against the
viewer's known origin before acting on any message it receives.

