Quickstart#
Get a lead into BuiltBetter in three steps.
- 1Get an API key from your BuiltBetter contact. It looks like
bb_live_.... The customer generates it under Settings → Integrations → Create integration. - 2POST to
https://app.builtbetter.ai/api/leads - 3Include
Authorization: Bearer <key>andContent-Type: application/json.
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"
}'{
"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.Customer logs in to BuiltBetter.
- 2.Navigates to Settings → Integrations.
- 3.Clicks Create integration and gives it a name (e.g., "Hive Digital", "Facebook Lead Ads").
- 4.Copies the generated bb_live_* key (shown once; can be rotated later).
- 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_4f2c91a08c7b4e6a9d3e1b07a2f6c8d1Key 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#
| Method | POST |
| Preferred URL | https://app.builtbetter.ai/api/leads |
| Direct URL | https://<convex-deployment>.convex.site/leads |
| Content-Type | application/json |
| Max payload size | 32 KB |
| Charset | UTF-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/jsonOptional headers
X-Request-ID: <your-uuid> # echoed back in the response and in our logsRequest 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
| Field | Type | Required? | Description |
|---|---|---|---|
first_name | string | required | 1–100 charsLead's given name. |
last_name | string | required | 1–100 charsLead's family name. |
Strongly recommended
| Field | Type | Required? | Description |
|---|---|---|---|
email | string | optional | RFC 5322Primary contact email. |
phone | string | optional | E.164 preferred (+15551234567); US formats acceptedPrimary contact phone. |
external_id | string | optional | 1–128 charsYour unique ID for this lead. Used for deduplication — see Idempotency section. |
Optional
| Field | Type | Required? | Description |
|---|---|---|---|
company | string | optional | max 200 charsLead's company name, if B2B. |
address | object | optional | Project / mailing address. See sub-fields below. |
address.street1 | string | optional | max 200 |
address.street2 | string | optional | max 200Unit, suite, etc. |
address.city | string | optional | max 100 |
address.state | string | optional | 2-letter US state or full name |
address.zip_code | string | optional | max 20 |
address.country | string | optional | ISO 3166-1 alpha-2 (default US) |
address.latitude | number | optional | -90 to 90If you already geocoded. |
address.longitude | number | optional | -180 to 180If you already geocoded. |
project_type | enum | optional | residential | commercial | industrialType of construction project. |
scope | enum | optional | remodel | addition | new_construction | repair | renovation | otherScope of work. |
estimated_square_footage | number | optional | ≥ 0Project square footage estimate. |
estimated_value | number | optional | ≥ 0, integer, USD centse.g. $12,500.00 → 1250000 |
priority | enum | optional | low | medium | high | urgent (default: medium)Priority level for this lead. |
source | string | optional | max 100Free-text source label. Defaults to the integration's configured name. Override per-lead for finer attribution. |
notes | string | optional | max 5000Free-text. Appended as a leadActivity note on the lead when created. |
custom_fields | object | optional | max 8 KB serializedFree-form JSON. Stored verbatim. Accessible in CRM under the lead's 'Custom data' tab. |
submitted_at | string | optional | ISO 8601 with timezoneWhen the lead was captured on your side. Defaults to server receive time. |
Response codes#
| Status | Meaning | Body |
|---|---|---|
| 201 Created | New lead created. | { "data": { "lead_id": "...", "status": "created", "external_id": "..." } } |
| 200 OK | Duplicate matched on external_id + integration. Existing lead was updated. | { "data": { ..., "status": "duplicate" } } |
| 400 Bad Request | Malformed JSON or missing/invalid Content-Type. | Error object — see Error format. |
| 401 Unauthorized | Missing, malformed, revoked, or unknown API key. | Error object. |
| 422 Unprocessable | JSON is valid but fails schema validation. | Error object with error.details[] enumerating each failed field. |
| 429 Too Many Requests | Rate limit exceeded. Retry-After header included. | Error object. |
| 5xx | Transient server error. Retry with exponential backoff. | Error object. |
201 Created — success
{
"data": {
"lead_id": "lead_01HZK8R3X4F2P7Y9N6B0Q5M3VC",
"status": "created",
"external_id": "hive-2026-05-21-00042"
},
"meta": {
"request_id": "req_8c2a91d0f4b7"
}
}200 OK — duplicate
{
"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.
{
"error": {
"code": "VALIDATION_ERROR",
"message": "first_name is required",
"details": [
{ "field": "first_name", "issue": "missing" }
],
"request_id": "req_8c2a91d0f4b7"
}
}Error codes
| error.code | HTTP | When |
|---|---|---|
| INVALID_JSON | 400 | Body is not parseable JSON. |
| INVALID_CONTENT_TYPE | 400 | Content-Type is not application/json. |
| PAYLOAD_TOO_LARGE | 400 | Body exceeds 32 KB. |
| UNAUTHORIZED | 401 | API key missing, malformed, revoked, or unknown. |
| VALIDATION_ERROR | 422 | One or more fields failed validation. See error.details[]. |
| RATE_LIMITED | 429 | Per-integration rate limit exceeded. Honor Retry-After. |
| INTERNAL_ERROR | 5xx | Transient server issue. Retry. |
Validation details[] shape
{
"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.
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
Add a Webhook action
Click + Add Action → External → Webhook. Configure:
| Field | Value |
|---|---|
| URL | https://app.builtbetter.ai/api/leads |
| Method | POST |
| Headers | (add two — see below) |
| Body Type | JSON |
Headers — click "+ Add Header" twice:
Authorization: Bearer bb_live_PASTE_YOUR_KEY_HERE
Content-Type: application/jsonMap the JSON body
Paste this into the Body field, then click each {{...}} and pick the corresponding HighLevel variable from the dropdown:
{
"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}}"
}
}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.
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
| Response | Retry? | Strategy |
|---|---|---|
| 2xx | No | Success. |
| 400, 401, 422 | No | Deterministic errors. Fix the request and resubmit. |
| 429 | Yes | Wait at least Retry-After seconds, then retry. |
| 5xx | Yes | Exponential 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 -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 -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."
}'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#
| Version | Date | Notes |
|---|---|---|
| 1.0.0 | 2026-05-21 | Initial 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.