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:
| Channel | Where it arrives | Use it for |
|---|---|---|
| Page messages | The page hosting the iframe, in the browser | Sizing the iframe, analytics, reacting while the homeowner is still on the page, handing the PDF link to your own flow |
| Lead webhooks | Your server | The 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
- 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. - Your page renders an iframe with that URL as its
src. - The iframe redeems the code for a one-hour session, inside Scanifly's own
page. Your token never reaches the browser. - 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.
| Field | Notes |
|---|---|
products | What 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.address | An 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.shingleId | The shingle to render first, from the catalogue. Only with roof in products. |
mode | full (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. |
externalId | Your 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. |
| Status | When |
|---|---|
201 | The link was minted. |
400 | The prefill address cannot be found, its shingle is not in the catalogue, prefill.shingleId was sent without roof, or mode: "result" without a prefill. |
401 | The API token is missing, expired or revoked. |
403 | The token's user has no company to bill. |
409 | A roof prefill was sent, but the company has not set up a shingle catalogue. |
502 | The 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
type | When |
|---|---|
ready | The link was redeemed and the estimator is usable. |
error | The link cannot be used. Nothing else will follow. |
resize | The estimator's content changed height. |
property-selected | The homeowner settled on an address, or the prefilled one was applied. |
measurement-complete | The roof was measured. |
measurement-failed | The roof could not be measured. |
preview-generated | A shingle preview was generated, or served from Scanifly's store. |
proposal-created | The homeowner saved a proposal. |
pdf-generated | The proposal's PDF is ready to download. |
ready
ready{ "type": "ready", "payload": {} }error
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
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
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
measurement-complete{
"type": "measurement-complete",
"payload": {
"leadId": "6acfb4874f821bbbf442c54b",
"area": 1898,
"squares": 19,
"pitch": 7,
"solar": {
"capacityKw": 17.2,
"annualProductionKwh": 13353.51,
"panelCount": 43
}
}
}| Field | Notes |
|---|---|
leadId | The Scanifly lead behind the measurement: the same value as lead.id in its webhooks. |
area | Measured roof area in square feet. |
squares | Roofing squares (100 sq ft), to one decimal. |
pitch | The pitch covering the most roof, as rise over a run of 12 (7 means 7/12). Absent when no usable roof plane was found. |
solar | Only 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
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
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
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
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
| Limit | Value |
|---|---|
| Link code | Single use, expires 5 minutes after minting |
| Session | 1 hour from when the code is redeemed |
| Properties per session |
|
| Previews per property | 4 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 session | 10 |
| PDFs per session | 20 |
- 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-failedwith reasonunaffordable, and previews past a
property's free four are refused.

