# Measured Bid developer docs
---
How to add the Measured Bid widget to a website, track it, and move its leads. Each section below is also a page under https://measuredbid.com/docs.
---
# Measured Bid developer docs
How to put the Measured Bid price widget on a website, track it, and send its leads where you need them. Written for web developers, people setting up a site builder, and AI assistants.
Source: https://measuredbid.com/docs
## Add the widget in three steps
1. **Copy the code.** In Measured Bid, open Widgets, pick the widget and open Install. Copy the Website code. It already has the widget ID in it.
2. **List the website.** In the widget's settings, under Websites, add each address the widget will appear on, one per line, like `https://example.com`. The widget stays blank anywhere else.
3. **Paste it.** Put the code where the price form should appear, in an HTML or code block. It sizes itself. [Where to paste it on each platform](https://measuredbid.com/docs/embed#platforms).
> **No developer? Use the link.** Every widget also has a homeowner link, like `https://measuredbid.com/quote/your-widget-id`. It works anywhere without code: a button on your site, a text message, your Google Business Profile. [More on links](https://measuredbid.com/docs/embed#link).
## How it works
- The widget is a page on measuredbid.com shown in an iframe on your site.
- A homeowner types an address, confirms the house, answers a few questions and sees a price range built from the contractor's rates.
- When they send the form, the lead is saved in the Measured Bid dashboard and sent to the contractor's email, texts, webhook or CRM, whichever are set up.
- Prices, questions, colors and service area come from the dashboard. Changes show on your site within a minute, with no need to paste the code again.
## What's in these docs
| Page | Use it to |
| --- | --- |
| [Embed the widget](https://measuredbid.com/docs/embed) | Paste the code on any site or site builder, allow the site, use a link instead, test it. |
| [URL parameters](https://measuredbid.com/docs/url-parameters) | Prefill the address, open on one trade, carry ad tracking into the lead. |
| [Events and tracking](https://measuredbid.com/docs/events) | Track widget steps in Google Tag Manager, GA4 or your own code. |
| [Webhooks](https://measuredbid.com/docs/webhooks) | Receive each new lead as JSON, and receive stage changes for leads you sent in. |
| [Sending leads in](https://measuredbid.com/docs/lead-intake) | Post leads from other forms or a CRM into Measured Bid, and update their stage. |
| [Troubleshooting](https://measuredbid.com/docs/troubleshooting) | Fix a blank widget, a cut-off frame, missing prices or missing leads. |
## Is there an API?
There is no public API for prices. Homeowners get prices through the widget. For lead data there are two webhooks Measured Bid sends and one URL it receives: [new leads and lead updates out](https://measuredbid.com/docs/webhooks), and [form leads and stage updates in](https://measuredbid.com/docs/lead-intake).
## For AI assistants
Every page has a plain Markdown version: add `.md` to its address, like [/docs/embed.md](https://measuredbid.com/docs/embed.md). All pages in one file: [/llms-full.txt](https://measuredbid.com/llms-full.txt). You can hand an assistant a prompt like this:
Prompt:
```text
Read https://measuredbid.com/llms-full.txt. Then add my Measured Bid widget (ID: your-widget-id) to my website's quote page, and tell me which website addresses to add under Websites in the widget's settings.
```
Questions: [support@measuredbid.com](mailto:support@measuredbid.com).
---
# Add the widget to a website
Paste one block of code where the price form should appear. It works on any site that lets you add custom HTML, once the site is listed in the widget's settings.
Source: https://measuredbid.com/docs/embed
## The code
Copy it from Install in the dashboard, where the widget ID is already filled in. It looks like this:
Website code:
```html
```
- Loads the widget in an iframe, 100% wide, 640px tall until it knows its real height.
- Resizes the iframe to fit each step, so there's no inner scrollbar.
- Copies ad tracking from the page's address into the lead: `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` and `gclid`, `gbraid`, `wbraid`, `fbclid`, `msclkid`, plus the page address as `ref`.
- Scrolls back to the top of the widget when a step changes and the top is off screen.
- Pushes an `iq_step` event to `window.dataLayer` when Google Tag Manager is on the page. See [events](https://measuredbid.com/docs/events).
## Allow your website
The widget only loads on websites listed in its settings. Browsers enforce this with the `frame-ancestors` security header, and an unlisted site shows a blank box.
- Open Widgets, pick the widget, and find Websites. Add one address per line.
- List every version visitors use: `https://example.com` and `https://www.example.com` are different sites.
- `https://*.example.com` covers every subdomain, but not `example.com` itself.
- Addresses must use https. The one exception is `http://localhost` for testing on your own computer.
- Up to 20 addresses. Changes apply within a minute.
## Where to paste it
| Platform | Where | Notes |
| --- | --- | --- |
| Any HTML site | Paste it in the page's HTML where the form should appear. | Nothing else needed. |
| WordPress | Install the [Measured Bid plugin](https://measuredbid.com/integrations/wordpress) and use the `[instant_quote]` shortcode, or paste the code in a Custom HTML block. | The plugin is easiest. A Custom HTML block needs an account that is allowed to add scripts. |
| Squarespace | Add a Code block and paste the code. | Check the live page, not the editor preview. |
| Webflow | Add a Code Embed element and paste the code. | Publish the site to test it. |
| Shopify | Online Store, Themes, Customize, then add a Custom Liquid section and paste the code. | |
| HighLevel sites and funnels | Add a Custom JS/HTML element and paste the code. | |
| Wix | Add, Embed Code, Embed HTML, then paste the code. | Wix runs pasted code inside a frame of its own, served from a `filesusr.com` address. Add `https://*.filesusr.com` to Websites along with your domain, and set the element about 900px tall. |
| GoDaddy Website Builder | Add a section, pick HTML, then paste the code. | GoDaddy also runs it inside a frame of its own. If the box stays blank, find that frame's address ([how](https://measuredbid.com/docs/troubleshooting#blank)) and add it to Websites. Set a tall height. |
| React, Next.js, Vue | Render the iframe yourself. | Frameworks don't run script tags inside injected HTML. [Use the component below](https://measuredbid.com/docs/embed#react). |
## React and other frameworks
This component does what the pasted script does: builds the widget address with tracking, resizes the frame, and drops the loading height after the first step. Replace `your-widget-id`. For Vue or Svelte, keep the same steps.
MeasuredBidWidget.jsx:
```js
"use client";
import { useEffect, useRef } from "react";
const APP = "https://measuredbid.com";
const WIDGET = "your-widget-id";
const PASS = ["utm_source","utm_medium","utm_campaign","utm_term","utm_content","gclid","gbraid","wbraid","fbclid","msclkid"];
export function MeasuredBidWidget({ title = "Price estimate" }) {
const ref = useRef(null);
useEffect(() => {
const frame = ref.current;
// Carry ad tracking from this page into the lead, plus the page itself.
const page = new URLSearchParams(window.location.search);
const params = new URLSearchParams();
PASS.forEach((key) => page.get(key) && params.set(key, page.get(key)));
params.set("ref", window.location.href);
frame.src = `${APP}/quote/${WIDGET}?${params}`;
function onMessage(e) {
if (e.origin !== APP || e.source !== frame.contentWindow || !e.data) return;
const { type, height, step } = e.data;
if (type === "instant-quote:height" && Number.isFinite(height) && height > 0 && height <= 20000) {
frame.style.height = `${Math.ceil(height)}px`;
}
if (type === "instant-quote:step" && step !== "address") frame.style.minHeight = "0";
}
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, []);
return ;
}
```
## Use a link or button instead
Every widget has a homeowner link: `https://measuredbid.com/quote/your-widget-id`. It needs no code and no Websites entry, so it works on any page, in texts and emails, and on Google Business Profile and Facebook. Add [URL parameters](https://measuredbid.com/docs/url-parameters) to open it on one trade or prefill the address.
Button:
```html
Get a price
```
## More than one widget on a page
Paste each widget's code where it should appear. Each script handles the iframe right before it, so keep each iframe and its script together. The WordPress shortcode can be used several times on one page.
## Test it
- Add your staging address, or `http://localhost:3000` (your port), to Websites, then open the page and price an address.
- Each new house priced counts as one estimate on the account. Pricing the same house again doesn't. The free trial includes 25.
- Demo widgets show what homeowners see without an account: [https://measuredbid.com/quote/demo-gutters](https://measuredbid.com/quote/demo-gutters). They only work as a direct link, not inside another site, and their leads aren't saved. IDs: `demo-washing`, `demo-gutters`, `demo-painting`, `demo-siding`, `demo-roofing`, `demo-lighting`, `demo-holiday`.
- Send the form once to see the lead arrive in the dashboard and any webhook.
---
# Widget URL parameters
Add these to the widget's address to prefill the address, open on one trade, and carry ad tracking into the lead. They work on the homeowner link and on the iframe's src.
Source: https://measuredbid.com/docs/url-parameters
## Parameters
| Parameter | Example | What it does |
| --- | --- | --- |
| `address` | `?address=412 Sample Ridge Ct, Roseville, CA` | Fills in the address box. The homeowner still picks and confirms the house. Up to 200 characters. |
| `service` | `?service=gutters` | Opens the widget on one trade. Ignored if the widget doesn't offer that trade. Values: `gutters`, `painting`, `siding`, `roofing`, `lighting`, `holiday`, `washing`. |
| `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` | `?utm_source=facebook&utm_campaign=spring` | Saved with the lead under `utm`. Up to 300 characters each. |
| `gclid`, `gbraid`, `wbraid`, `fbclid`, `msclkid` | `?gclid=abc123` | Ad click IDs, saved with the lead under `utm`. |
| `ref` | Set by the embed code | The page the widget was on, saved as `utm.referrer`. Without it, the browser's referrer is used. |
URL-encode values, as browsers do for links. Unknown parameters are ignored. Colors, questions and prices come from the widget's settings, not the URL.
## Ad tracking
The embed code copies these parameters from your page's address into the widget, so an ad that lands on your quote page is credited to the lead with no extra work. With a plain link, add them to the link yourself:
Link with tracking:
```text
https://measuredbid.com/quote/your-widget-id?service=gutters&utm_source=facebook&utm_medium=paid&utm_campaign=spring
```
## WordPress shortcode options
| Option | Default | What it does |
| --- | --- | --- |
| `brand` | The widget ID saved in Settings, Measured Bid | Which widget to show: `[instant_quote brand="your-widget-id"]`. |
| `service` | None | Opens on one trade, like the `service` parameter. |
| `title` | `Instant price` | The iframe's title, which screen readers announce. |
| `min_height` | `640` | Height in pixels while it loads. At least 320. |
---
# Widget events and analytics
The widget tells the page it's on its height and each step the homeowner reaches. Use the steps to track the quote funnel in Google Tag Manager, GA4 or your own code.
Source: https://measuredbid.com/docs/events
## Messages the widget sends
The widget uses `window.postMessage` to talk to the page that embeds it.
| type | Fields | When |
| --- | --- | --- |
| `instant-quote:height` | `height`: the widget's height in pixels | When its size changes, and before each step message. |
| `instant-quote:step` | `step`, and `brand`: the widget ID | Each time the homeowner reaches a new step. |
> **Check the sender.** The widget posts to any parent page. In your listener, check that `event.origin` is `https://measuredbid.com` and `event.source` is your iframe's `contentWindow`, as the embed code does.
## Steps
| step | Means |
| --- | --- |
| `address` | The widget loaded and shows the address box. |
| `confirm` | The homeowner picked a house to confirm. |
| `result` | A price range is on screen. |
| `no_data` | No price this time: the address is outside the service area, the house can't be measured from above, the account's plan is paused, or something failed. The widget offers a visit request form instead. |
| `booked` | The homeowner sent the form. This is the lead. |
## Listen in your own code
Step listener:
```js
const frame = document.querySelector("iframe[data-measuredbid-widget]");
window.addEventListener("message", (e) => {
// The widget posts to any parent, so check who sent it.
if (e.origin !== "https://measuredbid.com" || e.source !== frame.contentWindow) return;
if (e.data?.type !== "instant-quote:step") return;
if (e.data.step === "result") console.log("Price shown", e.data.brand);
if (e.data.step === "booked") console.log("Lead sent", e.data.brand);
});
```
## Google Tag Manager and GA4
The embed code pushes `{ event: "iq_step", step, brand }` to `window.dataLayer` for each step when Tag Manager is on the page. The WordPress plugin does the same and creates `dataLayer` if it's missing.
1. **Add variables.** In Tag Manager, create two Data Layer Variables: `step` and `brand`.
2. **Add a trigger.** Create a Custom Event trigger with the event name `iq_step`.
3. **Send it to GA4.** Create a GA4 Event tag on that trigger, for example named `quote_step`, with a `step` parameter set to the `step` variable.
4. **Mark the lead.** For a conversion, add a second trigger where `step` equals `booked`, and send a `generate_lead` event from it.
> **Which step is the conversion.** `booked` is the lead. `result` means a price was shown, which is a good middle-of-funnel step.
---
# Webhooks
Measured Bid can post each new lead to a URL you choose, and send stage changes back to the system that sent a lead in. Both send JSON.
Source: https://measuredbid.com/docs/webhooks
## New lead webhook
Set it in the widget's settings, under New-lead alerts, in Webhook. It works with Zapier, Make, n8n, a HighLevel inbound webhook or your own endpoint. Each widget has its own, so one account can feed several systems.
- Measured Bid sends each new lead once, as a POST with a JSON body, right after the homeowner sends the form. Leads your team saves from New estimate are sent the same way.
- The webhook address must be a public https:// address. Redirects are not followed.
- It waits up to 8 seconds for a reply and does not retry.
- If your endpoint is down, the lead is still saved in your dashboard and the homeowner still sees their confirmation.
- Demo widgets never send webhooks.
- It isn't signed. Use an address that's hard to guess, as Zapier and Make URLs are.
- Leads posted to your [form leads URL](https://measuredbid.com/docs/lead-intake) are not sent to this webhook.
Sample new lead (fictional):
```json
{
"leadId": "5b0e2c1a-8f3d-4c7e-9a61-2d4f7b8c9e10",
"brand": "sierra-gutter-co",
"brandName": "Sierra Gutter Co.",
"service": "gutters",
"guardProduct": {
"id": "sample-mesh",
"name": "Micro-mesh aluminum"
},
"quoteId": "9c4d7e2f-1a3b-4c5d-8e6f-7a8b9c0d1e2f",
"formattedAddress": "412 Sample Ridge Ct, Roseville, CA 95661, USA",
"name": "Jordan Example",
"phone": "(916) 555-0114",
"email": "jordan@example.com",
"preferredTime": "Weekday mornings",
"estimateLow": 1500,
"estimateHigh": 2025,
"smsConsent": false,
"smsConsentText": null,
"utm": {
"utm_source": "google",
"utm_medium": "cpc",
"utm_campaign": "sample-guards",
"gclid": "sample-click",
"referrer": "https://example.com/gutter-guards"
},
"property": {
"source": "rentcast",
"stories": 1,
"yearBuilt": 1998,
"propertyType": "single_family"
},
"receivedAt": "2026-09-25T16:42:07.000Z",
"firstName": "Jordan",
"lastName": "Example",
"street": "412 Sample Ridge Ct",
"city": "Roseville",
"state": "CA",
"zip": "95661"
}
```
### Fields
| Field | Type | What it holds |
| --- | --- | --- |
| `leadId` | string or null | The lead's ID in Measured Bid. Null only if the lead could not be saved; the rest still arrives. |
| `brand` | string | Your widget's ID. Each widget has its own webhook, so one account can feed several CRMs. |
| `brandName` | string | The business name shown on the widget. |
| `service` | "gutters" \| "painting" \| "siding" \| "roofing" \| "lighting" \| "holiday" \| "washing" | The trade the widget prices. |
| `guardProduct` | object | The guard product id and name saved with the estimate, when a named guard was quoted. |
| `quoteId` | string | The estimate record behind the lead, when there is one. |
| `formattedAddress` | string | The address as Google formats it: street, city, state and ZIP, then country. |
| `name` | string | The homeowner's name as typed. |
| `phone` | string | Their phone number as typed. Every lead has one. |
| `email` | string | Optional. Missing when they left it blank. |
| `preferredTime` | string | Optional. The best time for a visit, in their words. |
| `estimateLow` | number | The low end of the price range they saw, in dollars. Missing when no price was shown. |
| `estimateHigh` | number | The high end of the range, in dollars. Missing when no price was shown. |
| `smsConsent` | boolean | True only when they checked the box to get texts. |
| `smsConsentText` | string or null | The exact consent wording they agreed to, or null. |
| `utm` | object | Where the lead came from. Any of utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid, gbraid, wbraid, fbclid, msclkid, plus referrer, the page the widget was on. Missing when there's nothing to report. |
| `property` | object | County records for the home, such as stories, living area and year built. Only on plans with county records, and only when the county has a record. |
| `receivedAt` | string | When Measured Bid received the lead, as an ISO 8601 time in UTC. |
| `firstName` | string | The first word of name, for CRMs that want first and last names. |
| `lastName` | string | Every word of name after the first. Missing when they typed one word. |
| `street` | string | The street line of formattedAddress, with any unit. The whole address when it isn't a US street address. |
| `city` | string | The city from formattedAddress. Missing when the address isn't a US street address. |
| `state` | string | The two-letter state. Missing when the address isn't a US street address. |
| `zip` | string | The ZIP code. Missing when the address isn't a US street address. |
The webhook carries the contact details, the range, the trade and the source. The measured quantities are in the new-lead email. The lead in your dashboard has those, plus the homeowner's answers, the line-item dollars and the map of what was measured.
Step-by-step setup: [Zapier](https://measuredbid.com/integrations/zapier), [HighLevel](https://measuredbid.com/integrations/gohighlevel), [Jobber](https://measuredbid.com/integrations/jobber).
## Allowed webhook addresses
- It must start with `https://`.
- It must be a public web address. IP addresses, `localhost` and internal names like `.local` or `.internal` are refused.
- Redirects aren't followed. Use the final address.
## Lead updates webhook
For leads that arrived through your [form leads URL](https://measuredbid.com/docs/lead-intake) with an `external_id`. When such a lead's stage or value changes in Measured Bid, it posts the new state to the address in Send lead updates to, under Leads from your other website forms in the widget's settings.
Sample lead update:
```json
{
"event": "lead.updated",
"widget": "your-widget-id",
"lead": {
"id": "measured-bid-lead-id",
"external_ids": [
"your-lead-id"
],
"status": "won",
"value": 1775,
"updated_at": "2026-09-26T12:00:00.000Z"
}
}
```
| Field | What it holds |
| --- | --- |
| `event` | Always `lead.updated`. |
| `widget` | The widget ID. |
| `lead.id` | The lead's ID in Measured Bid. |
| `lead.external_ids` | The IDs your system sent with the lead. |
| `lead.status` | One of `new`, `contacted`, `quoted`, `won`, `lost`. |
| `lead.value` | The estimate total in whole dollars, or null. |
| `lead.updated_at` | When it changed, ISO 8601 in UTC. |
It is sent once per change, after the change is saved. It waits up to 5 seconds and does not retry. The same address rules apply.
### Check the signature
Each update carries `X-MeasuredBid-Signature: sha256=`, an HMAC-SHA256 of the raw body. The key is your form leads URL's token, the last part of that URL. Rotating the URL changes the key.
verify.js (Node):
```js
import { createHmac, timingSafeEqual } from "node:crypto";
// token: the last part of your form leads URL, https://measuredbid.com/api/intake/
// rawBody: the request body exactly as received, before JSON.parse
export function isFromMeasuredBid(rawBody, signatureHeader, token) {
const expected = "sha256=" + createHmac("sha256", token).update(rawBody).digest("hex");
const given = String(signatureHeader ?? "");
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
```
---
# Send leads into Measured Bid
Post leads from any website form, CRM or automation to a widget's form leads URL. They land in the same lead list as widget leads, and you can keep their stage in sync.
Source: https://measuredbid.com/docs/lead-intake
## Your form leads URL
- Find it in the widget's settings, under Leads from your other website forms. It looks like `https://measuredbid.com/api/intake/`.
- Keep it private. Anyone with it can add leads. Rotating it stops the old URL right away.
- Demo widgets don't have one.
## The request
- `POST` with JSON, form-encoded or multipart data. JSON can be nested; it's flattened.
- Up to 64 KB and 300 fields.
- Browsers can post to it directly (CORS is open).
- Optional `Idempotency-Key` header, up to 200 characters, so a retry isn't saved twice.
From a browser or Node:
```js
fetch("YOUR_FORM_LEADS_URL", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
external_id: "your-lead-id",
name: "Jordan Example",
email: "jordan@example.com"
})
});
```
From the command line:
```bash
curl -X POST "YOUR_FORM_LEADS_URL" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: crm-123" \
-d '{"name":"Jordan Example","phone":"(916) 555-0142","address":"412 Sample Ridge Ct, Roseville, CA 95661","service":"gutters","external_id":"crm-123"}'
```
## Fields it understands
Field names are matched loosely, so most form plugins work without renaming anything.
| Meaning | Field names accepted |
| --- | --- |
| Name | `full_name`, `fullname`, `your-name`, `contact_name`, `name` |
| First and last name | `first_name`, `fname`, `firstname`; `last_name`, `lname`, `lastname` |
| Phone | `phone_number`, `your-phone`, `phone`, `tel`, `telephone`, `mobile`, `cell` |
| Email | `email_address`, `your-email`, `email`, `e-mail` |
| Full address | `full_address`, `street_address`, `property_address`, `address` |
| Address parts | `address1`, `address_line_1`, `street`, `city`, `state`, `zip`, `zipcode`, `postal_code` |
| Message | `your-message`, `message`, `comments`, `notes`, `details`, `description` |
| Trade | `service`, `trade`, `project`, `project_type`, `interested_in`. Matched to the widget's trades by keyword. |
| Form name | `form_name`, `form`, `source` |
| Your lead ID | `external_id`, `externalid`, `hub_lead_id`. Needed for stage sync. |
| Spam trap | `website`, `_gotcha`, `hp`. Leave it empty. A filled one gets a normal answer and is not saved. |
Any `utm_*` field is kept. A lead needs a name (or first and last name, or an email) and a way to reach them: a phone number with at least 10 digits or an email.
## Responses
| Status | Means |
| --- | --- |
| 200 | Saved: `{ "ok": true, "id": "..." }`. |
| 400 | Missing name or contact (listed in `missing`), or a bad `Idempotency-Key`. |
| 404 | `unknown_url`: the URL is wrong or was rotated. |
| 413 | Body over 64 KB. |
| 415 | Content type it can't read. |
| 429 | Too many requests: 60 an hour and 500 a day per URL. |
| 503 | Try again after 60 seconds (see `Retry-After`). |
## Duplicates
The same `Idempotency-Key` is saved once. A lead with the same phone or email within 24 hours is merged into the first one instead of creating a second.
## Update a lead's stage
Post to your form leads URL with `/status` on the end. Use the `external_id` you sent with the lead.
POST