---
title: "Inbound leads"
url: https://docs.light-ai.app/inbound.md
canonical_url: https://docs.light-ai.app/inbound
last_updated: 2026-10-13
summary: "Send estimate requests into Light AI from any form, ad platform or automation tool, with or without code."
api_status: beta
related:
  - https://docs.light-ai.app/inbound/google-ads
  - https://docs.light-ai.app/inbound/meta-and-lsa
  - https://docs.light-ai.app/api-reference/leads/create-lead
  - https://docs.light-ai.app/integrations
---

# Inbound leads

> Send estimate requests into Light AI from any form, ad platform or automation tool, with or without code.

Every request becomes a **lead**. A lead with a street address also becomes a **project** in the contractor's pipeline. Repeat requests never create duplicates: the same address and service on an open lead becomes a **touch** (`lead.touched`).

| Path | Use it for | Credential |
| --- | --- | --- |
| `POST /v1/leads` | Your own code, or any tool that can set an API key. | API key with `leads:write` |
| `POST /v1/inbound/{source_id}` | HighLevel workflows, Zapier, Make, n8n, form tools. Any JSON shape (mapped). | Source secret in a header |
| `POST /v1/inbound/{source_id}/{token}` | Senders that cannot set headers (Sweep&Go, some form tools). | Secret token in the URL |
| `POST /v1/inbound/google-ads/{source_id}` | Google Ads lead form extensions. [Setup](https://docs.light-ai.app/inbound/google-ads). | Google's `google_key` |
| n8n templates | Meta Lead Ads and Google Local Services Ads. [Setup](https://docs.light-ai.app/inbound/meta-and-lsa). | API key |

## Required fields

A name plus an e-mail or a phone. Everything else is optional. A lead without a street address waits for one (`needs_address: true`) and converts to a project when you add it with `PATCH /v1/leads/{id}`. (The contractor's own [website request form](https://docs.light-ai.app/forms) always asks the homeowner for an e-mail, a phone and the address; that does not change what the API requires.)

## Inbound sources

An inbound source is a receive-only credential: it can create leads and nothing else (it can never read data or change existing clients). The owner creates one in **Settings → Developers → Inbound** (at most 10 per organization; revoked sources do not count) and picks a mode.

### Header mode (recommended)

Send the secret as `Authorization: Bearer lai_whin_…` or `X-Light-AI-Source-Secret: lai_whin_…`.

```bash
curl -X POST "https://api.light-ai.app/v1/inbound/src_…" \
  -H "Authorization: Bearer $INBOUND_SOURCE_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
  "contact": { "first_name": "Dana", "last_name": "Reyes", "email": "dana@example.com", "phone": "512-555-0134" },
  "address": { "line1": "4102 Oak Hollow Dr", "postal_code": "78731" },
  "message": "Roofline, warm white"
}'
```

This is the `POST /v1/leads` body shape, which every source accepts as is. A sender with its own field names (`name`, `street`, `zip`, …) needs the source's field map first: see [Field mapping](#field-mapping).

### URL mode

For senders that cannot set headers, the secret token is part of the URL: `https://api.light-ai.app/v1/inbound/src_…/lai_whu_…`. The URL itself is the credential and shows up in the sender's logs, so keep it private and rotate it if it leaks.

### Responses

Inbound sources always get a **receipt** (`{"object":"receipt","status":"received"}`), never the lead, so a sender cannot probe for existing customers. Validation errors name the field pointer without echoing values.

## Capture and map

A source accepts either the `POST /v1/leads` body or **any JSON your tool already sends**. To map a custom shape:

1. Open the source in Settings → Developers → Inbound and click **Capture a sample**.
2. Send one real request from your tool. Light AI shows the received body (kept 24 hours).
3. Click each value onto a Light AI field. Optional transforms: trim, lowercase, phone to +1 format, first name(s) or last word of a full name, join a list.
4. Save. Every later request uses the map. Unmapped values can go to `custom_fields`.

| Light AI field | Notes |
| --- | --- |
| `contact.full_name`, `contact.first_name`, `contact.last_name` | Map a full name to `contact.full_name`, or split it with the first-word and last-word transforms. |
| `contact.email`, `contact.phone` | At least one. Phones are normalized to E.164. |
| `address.formatted` or `address.line1`, `line2`, `city`, `region`, `postal_code`, `country` | A country name is normalized (`United States` → `US`). |
| `service_interest` | `christmas_lights`, `permanent_lighting`, `landscape_lighting` or free text (kept raw). |
| `message`, `source_detail`, `submitted_at` |  |
| `external_ref.id`, `external_ref.namespace` | Your record id: a repeat with the same id replays the original lead. |
| `consent.email`, `consent.sms`, `consent.marketing`, `consent.text` | Stored verbatim for your compliance records. |
| `attribution.utm_source`, `utm_medium`, `utm_campaign`, `gclid`, `campaign_id` |  |
| `custom_fields.<key>` | Anything else (letters, digits, `_`). |

Only the contact can refuse a lead. An unusable address or optional value is dropped (its raw value kept in `custom_fields.inbound_*`) rather than losing the request.

## Limits

Each source takes 60 requests a minute by default and a daily limit (default 500; at most 2,000 in header mode and 500 in URL mode). Above a limit the answer is 429 with `Retry-After`. Organizations also have a daily cap on created leads across all channels.

## With an API key

curl:

```bash
curl -X POST "https://api.light-ai.app/v1/leads" \
  -H "Authorization: Bearer $LIGHT_AI_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "source": "zapier",
  "contact": {
    "first_name": "Dana",
    "last_name": "Reyes",
    "phone": "+1 512 555 0134"
  },
  "address": {
    "line1": "4102 Oak Hollow Dr",
    "postal_code": "78731",
    "country": "US"
  },
  "service_interest": "permanent_lighting",
  "external_ref": {
    "system": "zapier",
    "id": "form-entry-88"
  }
}'
```

JavaScript:

```javascript
const response = await fetch('https://api.light-ai.app/v1/leads', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.LIGHT_AI_API_KEY}`,
    'Idempotency-Key': crypto.randomUUID(),
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "source": "zapier",
    "contact": {
      "first_name": "Dana",
      "last_name": "Reyes",
      "phone": "+1 512 555 0134"
    },
    "address": {
      "line1": "4102 Oak Hollow Dr",
      "postal_code": "78731",
      "country": "US"
    },
    "service_interest": "permanent_lighting",
    "external_ref": {
      "system": "zapier",
      "id": "form-entry-88"
    }
  }),
});
const data = await response.json();
```

Python:

```python
import os, uuid, requests

response = requests.post(
    "https://api.light-ai.app/v1/leads",
    headers={
        "Authorization": f"Bearer {os.environ['LIGHT_AI_API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "source": "zapier",
        "contact": {
            "first_name": "Dana",
            "last_name": "Reyes",
            "phone": "+1 512 555 0134"
        },
        "address": {
            "line1": "4102 Oak Hollow Dr",
            "postal_code": "78731",
            "country": "US"
        },
        "service_interest": "permanent_lighting",
        "external_ref": {
            "system": "zapier",
            "id": "form-entry-88"
        }
    },
)
data = response.json()
```

A key without `leads:read` also gets a receipt (`202`). Keep its `rcpt_…` id: you can add the address later with `PATCH /v1/leads/rcpt_…`.

## Paused organizations

While an organization is paused its leads are **held** (`held: true`) and convert automatically when it resumes. Nothing is lost.
