v1.0.0RESTJSONPer-org key

Leads Webhook API

Push new construction leads from your marketing platform, ad network, or lead-gen form directly into a BuiltBetter customer's CRM pipeline. Authenticated, idempotent, and ready in under 10 minutes.

On this page

Quickstart#

Get a lead into BuiltBetter in three steps.

  1. 1
    Get an API key from your BuiltBetter contact. It looks like bb_live_.... The customer generates it under Settings → Integrations → Create integration.
  2. 2
    POST to https://app.builtbetter.ai/api/leads
  3. 3
    Include Authorization: Bearer <key> and Content-Type: application/json.
curl — minimal quickstart
curl -X POST https://app.builtbetter.ai/api/leads \
  -H "Authorization: Bearer bb_live_4f2c91a08c7b4e6a9d3e1b07a2f6c8d1" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "your-lead-id-001",
    "first_name": "Jane",
    "last_name": "Doe",
    "email": "jane.doe@example.com",
    "phone": "+15551234567"
  }'
201 Created — success response
{
  "data": {
    "lead_id": "lead_01HZK8R3X4F2P7Y9N6B0Q5M3VC",
    "status": "created",
    "external_id": "your-lead-id-001"
  },
  "meta": {
    "request_id": "req_8c2a91d0f4b7"
  }
}

Authentication#

BuiltBetter uses a per-organization API key model. The customer (the BuiltBetter user whose CRM will receive the leads) generates the key inside their account and shares it with you over a secure channel.

Key format

bb_live_<random>

Example: bb_live_4f2c91a08c7b4e6a9d3e1b07a2f6c8d1

How the customer provisions a key

  1. 1.Customer logs in to BuiltBetter.
  2. 2.Navigates to Settings → Integrations.
  3. 3.Clicks Create integration and gives it a name (e.g., "Hive Digital", "Facebook Lead Ads").
  4. 4.Copies the generated bb_live_* key (shown once; can be rotated later).
  5. 5.Sends the key to you out-of-band (password manager, encrypted email, etc.).

Sending the key

Include the key in the Authorization header on every request:

Authorization: Bearer bb_live_4f2c91a08c7b4e6a9d3e1b07a2f6c8d1

Key lifecycle

  • Keys can be rotated or revoked by the customer at any time from the same integrations page.
  • A revoked key returns 401 Unauthorized immediately on all future requests.
  • Never log, embed in client-side code, or commit a bb_live_* key to source control. Treat it like a password.

Endpoint#

MethodPOST
Preferred URLhttps://app.builtbetter.ai/api/leads
Direct URLhttps://<convex-deployment>.convex.site/leads
Content-Typeapplication/json
Max payload size32 KB
CharsetUTF-8

Both URLs accept the same payload, headers, and produce the same response. The preferred URL (app.builtbetter.ai) is the stable hostname and the recommended choice for most integrations. The direct Convex URL is appropriate for high-volume integrations where you want the lowest possible latency.

Required headers

Authorization: Bearer bb_live_<...>
Content-Type: application/json

Optional headers

X-Request-ID: <your-uuid>   # echoed back in the response and in our logs

Request payload#

All fields use snake_case. The server normalizes and validates each field before persisting. Extra top-level fields not in this schema are silently ignored — put non-standard data in custom_fields.

Requiredrequired

FieldTypeRequired?Description
first_namestringrequired

1–100 charsLead's given name.

last_namestringrequired

1–100 charsLead's family name.

Strongly recommended

FieldTypeRequired?Description
emailstringoptional

RFC 5322Primary contact email.

phonestringoptional

E.164 preferred (+15551234567); US formats acceptedPrimary contact phone.

external_idstringoptional

1–128 charsYour unique ID for this lead. Used for deduplication — see Idempotency section.

Optional

FieldTypeRequired?Description
companystringoptional

max 200 charsLead's company name, if B2B.

addressobjectoptional

Project / mailing address. See sub-fields below.

address.street1stringoptional

max 200

address.street2stringoptional

max 200Unit, suite, etc.

address.citystringoptional

max 100

address.statestringoptional

2-letter US state or full name

address.zip_codestringoptional

max 20

address.countrystringoptional

ISO 3166-1 alpha-2 (default US)

address.latitudenumberoptional

-90 to 90If you already geocoded.

address.longitudenumberoptional

-180 to 180If you already geocoded.

project_typeenumoptional

residential | commercial | industrialType of construction project.

scopeenumoptional

remodel | addition | new_construction | repair | renovation | otherScope of work.

estimated_square_footagenumberoptional

≥ 0Project square footage estimate.

estimated_valuenumberoptional

≥ 0, integer, USD centse.g. $12,500.00 → 1250000

priorityenumoptional

low | medium | high | urgent (default: medium)Priority level for this lead.

sourcestringoptional

max 100Free-text source label. Defaults to the integration's configured name. Override per-lead for finer attribution.

notesstringoptional

max 5000Free-text. Appended as a leadActivity note on the lead when created.

custom_fieldsobjectoptional

max 8 KB serializedFree-form JSON. Stored verbatim. Accessible in CRM under the lead's 'Custom data' tab.

submitted_atstringoptional

ISO 8601 with timezoneWhen the lead was captured on your side. Defaults to server receive time.

Response codes#

StatusMeaningBody
201 CreatedNew lead created.{ "data": { "lead_id": "...", "status": "created", "external_id": "..." } }
200 OKDuplicate matched on external_id + integration. Existing lead was updated.{ "data": { ..., "status": "duplicate" } }
400 Bad RequestMalformed JSON or missing/invalid Content-Type.Error object — see Error format.
401 UnauthorizedMissing, malformed, revoked, or unknown API key.Error object.
422 UnprocessableJSON is valid but fails schema validation.Error object with error.details[] enumerating each failed field.
429 Too Many RequestsRate limit exceeded. Retry-After header included.Error object.
5xxTransient server error. Retry with exponential backoff.Error object.

201 Created — success

json
{
  "data": {
    "lead_id": "lead_01HZK8R3X4F2P7Y9N6B0Q5M3VC",
    "status": "created",
    "external_id": "hive-2026-05-21-00042"
  },
  "meta": {
    "request_id": "req_8c2a91d0f4b7"
  }
}

200 OK — duplicate

json
{
  "data": {
    "lead_id": "lead_01HZK8R3X4F2P7Y9N6B0Q5M3VC",
    "status": "duplicate",
    "external_id": "hive-2026-05-21-00042"
  },
  "meta": {
    "request_id": "req_3f1c08a7e2d9"
  }
}

Error format#

All error responses follow the BuiltBetter API convention. Always pass error.request_id when contacting BuiltBetter support — it lets us pull the exact request from our logs.

json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "first_name is required",
    "details": [
      { "field": "first_name", "issue": "missing" }
    ],
    "request_id": "req_8c2a91d0f4b7"
  }
}

Error codes

error.codeHTTPWhen
INVALID_JSON400Body is not parseable JSON.
INVALID_CONTENT_TYPE400Content-Type is not application/json.
PAYLOAD_TOO_LARGE400Body exceeds 32 KB.
UNAUTHORIZED401API key missing, malformed, revoked, or unknown.
VALIDATION_ERROR422One or more fields failed validation. See error.details[].
RATE_LIMITED429Per-integration rate limit exceeded. Honor Retry-After.
INTERNAL_ERROR5xxTransient server issue. Retry.

Validation details[] shape

json
{
  "field": "email",
  "issue": "invalid_format",
  "message": "email must be a valid RFC 5322 address"
}

Possible issue values: missing, invalid_format, too_long, too_short, out_of_range, unknown_enum_value, wrong_type.

Idempotency & deduplication#

BuiltBetter deduplicates on the tuple (integration, external_id). The integration is determined by the API key in the request.

Behavior

First POST with a given external_id

New lead created. Response 201, status: "created".

Subsequent POST with the same external_id

Existing lead updated. Response 200, status: "duplicate".

Subsequent POST with same external_id and identical payload

No-op update. Still 200, status: "duplicate".

Update semantics on duplicate

  • Non-empty fields in the new payload overwrite the stored values.
  • Fields omitted or sent as empty strings / null do NOT clear existing data — they are ignored.
  • custom_fields is shallow-merged: keys present overwrite; keys absent are preserved.
  • notes is appended as an additional leadActivity entry — every POST with a notes value adds a new note.
  • created_at is preserved from the original; updated_at is bumped.

Idempotency window

Unlimited. We retain the external_id ↔ lead_id mapping for the lifetime of the integration. If you re-POST a lead from years ago using the same external_id, it will still match.

What if you don't send external_id?

The request still works, but every POST creates a new lead — there is no other dedup key. We strongly recommend always sending external_id.

HighLevel (GoHighLevel) setup#

If you're sending leads from HighLevel, here's the exact workflow configuration. Most agencies have this running in under 15 minutes.

1

Create a workflow

In HighLevel, go to Automation → Workflows → New Workflow. Pick a trigger that fires when a new lead is captured — typically:

  • "Form Submitted" — if leads come from a HighLevel form
  • "Facebook Lead Form Submitted" — if leads come from Facebook lead ads
  • "Opportunity Created" or "Contact Created" — catch-all
2

Add a Webhook action

Click + Add Action → External → Webhook. Configure:

FieldValue
URLhttps://app.builtbetter.ai/api/leads
MethodPOST
Headers(add two — see below)
Body TypeJSON

Headers — click "+ Add Header" twice:

Headers
Authorization: Bearer bb_live_PASTE_YOUR_KEY_HERE
Content-Type: application/json
3

Map the JSON body

Paste this into the Body field, then click each {{...}} and pick the corresponding HighLevel variable from the dropdown:

HighLevel Webhook Body
{
  "external_id": "{{contact.id}}",
  "first_name": "{{contact.first_name}}",
  "last_name": "{{contact.last_name}}",
  "email": "{{contact.email}}",
  "phone": "{{contact.phone}}",
  "address": {
    "street1": "{{contact.address1}}",
    "city": "{{contact.city}}",
    "state": "{{contact.state}}",
    "zip_code": "{{contact.postal_code}}",
    "country": "{{contact.country}}"
  },
  "source": "{{contact.source}}",
  "submitted_at": "{{contact.date_added}}",
  "custom_fields": {
    "ghl_location_id": "{{location.id}}",
    "tags": "{{contact.tags}}"
  }
}
4

Test the workflow

In HighLevel, use the workflow's "Test" feature with a sample contact. You should see 201 Created in the webhook action's execution log. Then check the BuiltBetter customer's /crm/leads view — the test lead should appear at the top with the HighLevel integration name as a badge.

5

Activate

Toggle the workflow to Publish / Active. HighLevel will fire the webhook on every matching trigger from now on.

Common pitfalls

  • 401 UnauthorizedCheck Bearer prefix and that you copied the full key (no truncation).
  • 422 Validation failedCheck first_name and last_name are non-empty — HighLevel sometimes sends empty strings if the form didn't collect the field; either make those fields required on the lead form or split a single name field client-side.
  • Duplicate leadsMake sure external_id maps to {{contact.id}} (HighLevel's stable contact ID) so retries don't create duplicates.
  • Phone formattingHighLevel sends phone numbers in various formats. Our endpoint accepts E.164 (+15551234567) and US-style ((555) 123-4567) — both work fine.

Rate limits & retries#

Rate limits

  • 100 requests per minute per integration, rolling window.
  • On exceed: 429 Too Many Requests with a Retry-After header (seconds).
  • Need a higher limit? Ask the customer to contact BuiltBetter support — limits can be raised on a case-by-case basis.

Retry guidance

ResponseRetry?Strategy
2xxNoSuccess.
400, 401, 422NoDeterministic errors. Fix the request and resubmit.
429YesWait at least Retry-After seconds, then retry.
5xxYesExponential backoff: 1s, 2s, 4s, 8s, 16s. Max 5 retries, then dead-letter on your side and alert.

Always include the same external_id on retries — that's what makes the operation safe. If a request succeeded server-side but the response was lost, a retry will return 200 OK with status: "duplicate", not create a second lead.

Observability

The customer can see your delivery stats in real time:

Settings → Integrations → [Your integration name]

  • Last 24h / 7d / 30d request counts
  • Success rate (2xx vs 4xx vs 5xx)
  • Last successful delivery timestamp
  • Recent error samples (with request_id so they can forward them to you)

Sample requests#

Complete — all fields populated

curl — full request
curl -X POST https://app.builtbetter.ai/api/leads \
  -H "Authorization: Bearer bb_live_4f2c91a08c7b4e6a9d3e1b07a2f6c8d1" \
  -H "Content-Type: application/json" \
  -H "X-Request-ID: 7d3f2a10-6b4c-4e8d-9a12-3f5c7b1d9e02" \
  -d '{
    "external_id": "hive-2026-05-21-00042",
    "first_name": "Jane",
    "last_name": "Doe",
    "email": "jane.doe@example.com",
    "phone": "+15551234567",
    "company": "Doe Holdings LLC",
    "address": {
      "street1": "1234 Oak Avenue",
      "street2": "Suite 200",
      "city": "Austin",
      "state": "TX",
      "zip_code": "78704",
      "country": "US"
    },
    "project_type": "residential",
    "scope": "remodel",
    "estimated_square_footage": 2400,
    "estimated_value": 8500000,
    "priority": "high",
    "source": "Hive Digital — Facebook Lead Ad — Campaign #4471",
    "notes": "Interested in full kitchen + primary bath remodel. Budget ~$85k. Timeline: Q3 2026.",
    "custom_fields": {
      "utm_source": "facebook",
      "utm_medium": "cpc",
      "utm_campaign": "summer-remodel-2026"
    },
    "submitted_at": "2026-05-21T14:32:10-05:00"
  }'

Dedup on retry — same external_id, updated phone

The existing lead's phone is overwritten; the new notes entry is appended. The original email, project_type, scope, priority, and source are preserved.

curl — dedup retry
curl -X POST https://app.builtbetter.ai/api/leads \
  -H "Authorization: Bearer bb_live_4f2c91a08c7b4e6a9d3e1b07a2f6c8d1" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "hive-2026-05-21-00042",
    "first_name": "Jane",
    "last_name": "Doe",
    "phone": "+15559876543",
    "notes": "Follow-up: prefers evening calls after 6 PM."
  }'
200 OK — duplicate
HTTP/1.1 200 OK
Content-Type: application/json

{
  "data": {
    "lead_id": "lead_01HZK8R3X4F2P7Y9N6B0Q5M3VC",
    "status": "duplicate",
    "external_id": "hive-2026-05-21-00042"
  },
  "meta": { "request_id": "req_3f1c08a7e2d9" }
}

Changelog#

VersionDateNotes
1.0.02026-05-21Initial release. POST /api/leads endpoint, per-org API keys, dedup on external_id, 100 req/min rate limit.

Support

For partner integration questions, contact your customer's BuiltBetter administrator first — they own the API key and the integration configuration. For platform-level issues (the endpoint is down, unexpected 5xx, suspected bugs), the customer can open a ticket with BuiltBetter support and include the request_id from the failing response.