Integrations

Lead delivery & webhooks

When a visitor finishes an estimate, ClearCost creates an exclusive lead and POSTs it to your endpoint as a signed JSON webhook — so your crew can follow up in minutes.

Available on Studio and Enterprise

CRM webhook delivery is included on the Studio ($249/mo) and Enterprise plans. On Solo, leads still arrive by email and in your dashboard. See pricing.

Enable webhooks

In Pro dashboard → Website Embed → Webhooks, set your endpoint URL and copy the signing secret. Don't want to write code? Skip straight to the CRM setup guide and point the endpoint at Zapier.

The lead.created event

Each delivery is an HTTP POST with a JSON body and these headers:

FieldTypeDescription
X-ClearCost-SignaturestringSignature in the form "t=<unix>,v1=<hmac>". See below.
X-ClearCost-EventstringEvent type — currently always "lead.created".
Content-Typestringapplication/json
User-AgentstringClearCost-Embed-Webhook/1

Payload

POST body
{
  "event": "lead.created",
  "embed_id": "emb_123",
  "lead": {
    "id": "lead_abc123",
    "category": "deck",
    "location_zip": "30080",
    "estimated_price_min": 14200,
    "estimated_price_max": 19600,
    "customer_name": "Jane Doe",
    "customer_email": "jane@example.com",
    "customer_phone": "555-123-4567",
    "notes": "Replacing an old 12x16 deck",
    "image_url": "https://...",
    "created_at": "2026-06-21T15:04:05Z"
  }
}
FieldTypeDescription
eventstring"lead.created".
embed_idstring | nullThe embed that produced the lead.
lead.idstringUnique lead ID — use it to de-duplicate (see retries).
lead.categorystringProject category, e.g. "deck".
lead.location_zipstringCustomer ZIP code.
lead.estimated_price_minnumberLow end of the Fair Market Rate (USD).
lead.estimated_price_maxnumberHigh end of the Fair Market Rate (USD).
lead.customer_namestringCustomer's full name.
lead.customer_emailstringCustomer's email address.
lead.customer_phonestring | nullPhone, if the customer provided one.
lead.notesstring | nullFree-text project notes.
lead.image_urlstring | nullUploaded photo or AI render, if any.
lead.created_atstringISO 8601 timestamp.

Verify the signature

The signature is HMAC-SHA256 over "{timestamp}.{rawBody}" using your endpoint secret, formatted as t=<unix>,v1=<hex> (the same scheme Stripe uses). Always verify against the raw request body, before parsing JSON:

verify.js
import crypto from "node:crypto";

// Your endpoint's signing secret (Pro dashboard → Website Embed → Webhooks)
const SECRET = process.env.CLEARCOST_WEBHOOK_SECRET;

// rawBody must be the exact bytes ClearCost sent — verify BEFORE JSON.parse,
// and don't let a body parser re-serialize it.
export function verifySignature(header, rawBody) {
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("="))
  ); // { t, v1 }

  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");

  const signatureOk = crypto.timingSafeEqual(
    Buffer.from(parts.v1),
    Buffer.from(expected)
  );
  // Reject replays: signature older than 5 minutes.
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;

  return signatureOk && fresh;
}

Use the raw body

If your framework parses and re-serializes JSON, the bytes change and the signature won't match. Capture the raw body (e.g. Express express.raw(), Next.js route handlers via await req.text()) and hash that.

Retries & idempotency

Respond with a 2xx within 10 seconds to acknowledge. Any non-2xx response, a timeout, or a network error is retried up to 5 times with increasing backoff (about 1, 5, 30, 120, then 360 minutes). Because a lead can be delivered more than once, treat lead.id as an idempotency key and ignore IDs you've already processed.