# 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=<hex>`, 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/<token>
// 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));
}
```
