3D Viewer Browser Messages

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:

  1. Fire-and-forget notifications (viewer → parent) the viewer posts a message
    to window.parent. No response is expected. Most are plain strings; the
    production-values message is a structured object.
  2. 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 validate
event.origin itself. The inbound command channel does enforce
an origin allowlist (see that section).

Message index

MessageDirectionStyleSection
PRODUCTION_VALUES_CHANGEDviewer → parentobject notification
DESIGN_SAVEDviewer → parentstring notification
DESIGN_FINALIZEDviewer → parentstring notification
showChatWidget / hideChatWidgetviewer → parentstring notification
saveDesignparent → viewerrequest / response

Every listener example below assumes a shared constant for the viewer's origin:

const VIEWER_ORIGIN = 'https://viewer.scanifly.com'; // the viewer's origin

Outbound notifications (viewer → parent)

PRODUCTION_VALUES_CHANGED

The 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 / designId let 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.
  • asa and tsrf are undefined when 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:

  1. The viewer is embedded (window !== window.parent). A standalone viewer
    never posts.
  2. 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.
  3. 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

A 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

A 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

A 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

Saves 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.