Roof Estimator

The embedded estimator puts Scanifly's roof and solar estimator on your own
website, in an iframe. A homeowner enters their address (or arrives with one you
already collected), sees their actual roof measured from aerial imagery,
previews new shingles on a photo of their house, and can save a proposal and
download it as a PDF. Everything is billed to your company, and every measurement
becomes a lead in your Scanifly account.

There are two ways your systems hear about what happens:

ChannelWhere it arrivesUse it for
Page messagesThe page hosting the iframe, in the browserSizing the iframe, analytics, reacting while the homeowner is still on the page, handing the PDF link to your own flow
Lead webhooksYour serverThe durable record: writing the lead into your CRM. Signed, retried and de-duplicated

Use both: page messages describe the visit as it happens, webhooks are the
record you can rely on. Send an externalId
when you mint a link to tie the two together.

How it works

  1. Your server mints a link with your API token:
    POST /api/v1/roof-estimator/embed-sessions. The response is a URL carrying a
    single-use code.
  2. Your page renders an iframe with that URL as its src.
  3. The iframe redeems the code for a one-hour session, inside Scanifly's own
    page. Your token never reaches the browser.
  4. The homeowner uses the estimator. The iframe posts
    page messages to your page, and Scanifly sends
    lead webhooks to your server.

Mint a new link for every page view, from your server, and never cache the
page that carries it. The code inside the link can be redeemed once and expires
after five minutes, so a cached page hands a spent code to every visitor after
the first, and they see "This estimator link is no longer valid."

Minting a link

curl -s -X POST https://api.portal.scanifly.com/api/v1/roof-estimator/embed-sessions \
  -H "x-api-token: $SCANIFLY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "products": ["roof", "solar"],
    "prefill": { "address": "1600 Wynkoop St, Denver, CO" },
    "mode": "result",
    "externalId": "crm-48213"
  }'
{
  "url": "https://portal.scanifly.com/embed/roof-estimator?code=q7Fv2hK...",
  "expiresAt": "2026-10-14T17:05:00.000Z"
}
<iframe
  src="{{url}}"
  title="Roof and solar estimate"
  style="width: 100%; height: 860px; border: 0"
></iframe>

The body is optional. Without one, the estimator opens empty as a roof
estimator, and the homeowner enters their own address.

FieldNotes
productsWhat to estimate: ["roof"] (the default), ["solar"], or ["roof", "solar"]. With both, the homeowner switches between a Roofing and a Solar tab, both from the same measurement. Solar shows the panel layout, system size, panel count, yearly production and, if you set a solar price per watt in Lead Settings, a cost.
prefill.addressAn address you already collected. It is geocoded when you mint, so an address that cannot be found is a 400 to your server rather than an error in front of the homeowner. It is held with the code, never put in the iframe URL. The estimator starts measuring as soon as it loads.
prefill.shingleIdThe shingle to render first, from the catalogue. Only with roof in products.
modefull (the default) shows every step. result hides the address and photo steps and opens straight on the result; it needs a prefill. A roof result renders its first preview without a click.
externalIdYour own reference for this visit, such as your CRM's id for the homeowner's form. Up to 255 characters. Every lead the visit creates carries it in its webhooks. See Matching a visit to its leads.
StatusWhen
201The link was minted.
400The prefill address cannot be found, its shingle is not in the catalogue, prefill.shingleId was sent without roof, or mode: "result" without a prefill.
401The API token is missing, expired or revoked.
403The token's user has no company to bill.
409A roof prefill was sent, but the company has not set up a shingle catalogue.
502The address lookup is unavailable; retry shortly.

Listing the shingle catalogue

curl -s https://api.portal.scanifly.com/api/v1/roof-estimator/shingles \
  -H "x-api-token: $SCANIFLY_API_TOKEN"
{
  "shingles": [
    {
      "id": "727ee0e0-edb0-4863-9c6a-ad6ce9d911a6",
      "name": "Moire Black",
      "line": "Landmark",
      "color": "#3c3c3c",
      "pricePerSquare": 550,
      "previewImage": "https://…"
    }
  ],
  "isConfigured": true
}

Use it to offer colours on your own form, then pass the chosen id as
prefill.shingleId. previewImage is a swatch photo link valid for a day, when
one was uploaded.

Page messages

The iframe sends your page a message with window.parent.postMessage whenever
something happens. Every message has the same envelope:

{
  "source": "scanifly-roof-estimator",
  "version": 1,
  "type": "measurement-complete",
  "payload": { … }
}

The iframe cannot know which site framed it, so it posts to any origin. Check
event.origin before you trust a message: compare it to the origin of the URL
you minted. Messages are only sent when the estimator is inside an iframe;
opening the link on its own sends nothing.

const embedUrl = '{{url}}'; // the URL your server minted
const embedOrigin = new URL(embedUrl).origin;
const iframe = document.getElementById('estimator');

window.addEventListener('message', (event) => {
  // Anything not from Scanifly's iframe is none of this listener's business.
  if (event.origin !== embedOrigin || event.source !== iframe.contentWindow)
    return;

  const { source, type, payload } = event.data ?? {};
  if (source !== 'scanifly-roof-estimator') return;

  switch (type) {
    case 'resize':
      iframe.style.height = `${payload.height}px`;
      break;
    case 'error':
      // The link is spent or expired. Fetch a fresh page, which mints a new link.
      break;
    case 'proposal-created':
      analytics.track('Proposal created', { proposalId: payload.proposalId });
      break;
    case 'pdf-generated':
      // A download link, valid for an hour: offer it, or attach it in your follow-up.
      break;
  }
});

version is raised only if a message's shape changes in a way that would break
a listener. New message types and new payload fields can appear without a new
version, so ignore what you do not recognise.

Message reference

typeWhen
readyThe link was redeemed and the estimator is usable.
errorThe link cannot be used. Nothing else will follow.
resizeThe estimator's content changed height.
property-selectedThe homeowner settled on an address, or the prefilled one was applied.
measurement-completeThe roof was measured.
measurement-failedThe roof could not be measured.
preview-generatedA shingle preview was generated, or served from Scanifly's store.
proposal-createdThe homeowner saved a proposal.
pdf-generatedThe proposal's PDF is ready to download.

ready

{ "type": "ready", "payload": {} }

error

{
  "type": "error",
  "payload": { "message": "This estimator link is no longer valid." }
}

Sent when the code is spent, expired or was never issued. Mint a new link.

resize

{ "type": "resize", "payload": { "height": 1184 } }

The height of the estimator's content in pixels, sent whenever it changes. Set
your iframe to it and the homeowner never scrolls inside the frame.

property-selected

{
  "type": "property-selected",
  "payload": {
    "address": "1600 Wynkoop St, Denver, CO 80202, USA",
    "placeId": "ChIJxSw4i8Z4bIcRc0rPAQQ7kdE",
    "latitude": 39.7525,
    "longitude": -105.0009
  }
}

Sent each time an address is settled, so a homeowner who tries a second
address sends it again. Measuring starts at the same moment.

measurement-complete

{
  "type": "measurement-complete",
  "payload": {
    "leadId": "6acfb4874f821bbbf442c54b",
    "area": 1898,
    "squares": 19,
    "pitch": 7,
    "solar": {
      "capacityKw": 17.2,
      "annualProductionKwh": 13353.51,
      "panelCount": 43
    }
  }
}
FieldNotes
leadIdThe Scanifly lead behind the measurement: the same value as lead.id in its webhooks.
areaMeasured roof area in square feet.
squaresRoofing squares (100 sq ft), to one decimal.
pitchThe pitch covering the most roof, as rise over a run of 12 (7 means 7/12). Absent when no usable roof plane was found.
solarOnly when products includes solar: the panel layout's system size, yearly production in kWh, and panel count. The layout fills the usable roof.

measurement-failed

{ "type": "measurement-failed", "payload": { "reason": "failed" } }

reason is failed when the roof could not be measured, or unaffordable
when your company did not have the credits to measure it. Either way the
estimator falls back to a typical roof size for its numbers.

preview-generated

{
  "type": "preview-generated",
  "payload": {
    "visualizationId": "6a1f0c2a9e1b4d0012ab34cd",
    "shingle": {
      "id": "727ee0e0-…",
      "name": "Moire Black",
      "line": "Landmark",
      "color": "#3c3c3c"
    },
    "cached": false
  }
}

Sent for each preview the estimator asks for: when the homeowner clicks
Generate, or when a result link renders its first preview by itself. cached
is true when that colour had already been rendered for this photo, so it was
served at no charge. Switching back to a colour already rendered during the
visit shows it again without a message.

proposal-created

{
  "type": "proposal-created",
  "payload": {
    "proposalId": "6acfb4f2d66df27d662e917b",
    "address": "1600 Wynkoop St, Denver, CO 80202, USA",
    "shingle": {
      "id": "727ee0e0-…",
      "name": "Moire Black",
      "line": "Landmark",
      "color": "#3c3c3c"
    },
    "roofMetrics": { "area": 1898, "pitch": 7 },
    "pricing": { "min": 9396, "max": 11484 }
  }
}

The proposal is priced by Scanifly from your catalogue and the measurement; the
page only names which preview it shows. pricing is the estimated range in
dollars.

pdf-generated

{
  "type": "pdf-generated",
  "payload": {
    "proposalId": "6acfb4f2d66df27d662e917b",
    "url": "https://…/proposal-pdfs/…pdf?X-Amz-Signature=…"
  }
}

url downloads the branded proposal PDF and stops working after an hour.
Download it promptly if you want to keep it, for example to attach to your
follow-up email.

Matching a visit to its leads

Pass your own reference as externalId when you mint the link. Every lead the
visit creates carries it as lead.externalId in its lead.created,
lead.completed and lead.failed webhooks, so the webhook
can be filed against the right record in your CRM without anything passing
through the browser.

On the page, measurement-complete carries leadId, which equals the
webhook's lead.id, if you also want to join what the page saw to what your
server received.

A measurement Scanifly can reuse, because your company already measured that
building, creates no new lead and sends no webhooks. measurement-complete
still arrives on the page, with the existing lead's id.

Limits and billing

LimitValue
Link codeSingle use, expires 5 minutes after minting
Session1 hour from when the code is redeemed
Properties per session
  1. After that the homeowner is asked to reload the page, which mints a new link
Previews per property4 free, then 6 more bought automatically in credits, up to 10 in total per property, across all visits. Re-showing a colour already rendered is free
Proposals per session10
PDFs per session20
  • Measurements are billed like any lead in your Scanifly account when they
    complete, unless the building's existing measurement is reused.
  • Prefilled links start a measurement as soon as the page loads, and a roof
    result also renders its first preview, so every page view with a prefill can
    cost credits. Mint a prefilled link only for a page the homeowner will see.
  • Solar adds no cost of its own: it reads the same measurement.
  • Without credits, the estimator still loads, but measuring reports
    measurement-failed with reason unaffordable, and previews past a
    property's free four are refused.