# Capture leads from your website or ads

Send a lead from your website form or an ad platform straight onto the broker's Leads board. This
guide covers the one request you need, how BrokerPlus handles a person who is already in the book,
and what to do when a request fails.

## How it works

```
Website form or ad platform  ->  Your server  ->  POST /v1/leads  ->  BrokerPlus
```

Your server receives the form and makes one `POST /v1/leads` request. BrokerPlus creates the
contact and the lead together, files the lead with `source: "api"`, runs your **New Lead Created**
automations, and sends the `lead.created` webhook.

**Never call the API from the browser.** A key in a web page is a key every visitor can copy, and
it can read the broker's whole book. Post the form to your own server and keep the key there.

## Before you start

1. Create a key for this form only, named after it (for example `Website lead form`), and give it
   the `leads_only` limit. See [Authentication](/authentication).
2. Pick the broker who gets the leads. A key acts as the broker who created it, and that broker
   owns every lead it creates.
3. Put the key in an environment variable on your server, for example `BROKERPLUS_API_KEY`.

## Send a lead

`first_name` is the only required field. Send `email` whenever you have it: it is how BrokerPlus
recognises a person who is already in the book.

Add an `Idempotency-Key` header built from your form tool's submission id. If your server retries
after a timeout, the API replays the first response instead of creating a second lead. See
[Idempotency and batch](/idempotency).

<CodeTabs syncKey="lang">

```bash title="curl"
curl -X POST https://api.mos.getbrokerplus.ca/v1/leads \
  -H "Authorization: Bearer $BROKERPLUS_API_KEY" \
  -H "Idempotency-Key: website-form-5f2c9a" \
  -H "Content-Type: application/json" \
  -d '{"first_name":"Jordan","last_name":"Client","email":"jordan@example.com","phone":"+1 587 555 1234","service_type":"Purchase","first_time_buyer":true,"additional_details":"Wants to close in June."}'
```

```ts title="TypeScript"
type FormSubmission = {
  id: string;
  firstName: string;
  lastName?: string;
  email?: string;
  phone?: string;
  message?: string;
};

export async function sendLead(submission: FormSubmission) {
  const body = JSON.stringify({
    first_name: submission.firstName,
    last_name: submission.lastName ?? null,
    email: submission.email ?? null,
    phone: submission.phone ?? null,
    service_type: "Purchase",
    additional_details: submission.message ?? null,
  });

  const res = await fetch("https://api.mos.getbrokerplus.ca/v1/leads", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}`,
      "Idempotency-Key": `website-form-${submission.id}`,
      "Content-Type": "application/json",
    },
    body,
  });
  if (!res.ok) throw new Error(`BrokerPlus returned ${res.status}: ${await res.text()}`);
  return (await res.json()).data;
}
```

```python title="Python"
import json
import os

import requests


def send_lead(submission: dict) -> dict:
    body = json.dumps({
        "first_name": submission["first_name"],
        "last_name": submission.get("last_name"),
        "email": submission.get("email"),
        "phone": submission.get("phone"),
        "service_type": "Purchase",
        "additional_details": submission.get("message"),
    })
    res = requests.post(
        "https://api.mos.getbrokerplus.ca/v1/leads",
        data=body,
        headers={
            "Authorization": f"Bearer {os.environ['BROKERPLUS_API_KEY']}",
            "Idempotency-Key": f"website-form-{submission['id']}",
            "Content-Type": "application/json",
        },
        timeout=30,
    )
    res.raise_for_status()
    return res.json()["data"]
```

</CodeTabs>

The API answers `201` with the lead:

```json
{
  "success": true,
  "data": {
    "object": "lead",
    "id": "cmlead7q...",
    "contact_id": "cmcontact...",
    "first_name": "Jordan",
    "last_name": "Client",
    "email": "jordan@example.com",
    "phone": "+1 587 555 1234",
    "source": "api",
    "stage_id": "cmstage...",
    "service_type": "Purchase",
    "first_time_buyer": true,
    "next_follow_up_at": null,
    "additional_details": "Wants to close in June.",
    "archived_at": null,
    "submission_count": 1,
    "submission": {},
    "created_at": "2026-10-07T15:04:11.000Z",
    "updated_at": "2026-10-07T15:04:11.000Z"
  }
}
```

The name, email, and phone are read from the lead's contact. If the email already belonged to
someone in the book, you get that person's details back, not the ones you sent.

## When the person is already in the book

You don't need to search first. BrokerPlus matches on email:

| The person you send                                    | What BrokerPlus does                                                                 |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------ |
| An email no contact has                                | Creates a contact and a lead.                                                        |
| The email of a contact with no open lead               | Reuses that contact and opens a lead on it.                                          |
| The email of a contact who already has an open lead    | Reuses that lead, adds 1 to `submission_count`, and does not send a second webhook.  |
| No email                                               | Creates a contact and a lead. Without an email there is nothing to match on.         |

When BrokerPlus reuses a contact, it leaves that contact's name and phone alone. It does write
`service_type`, `next_follow_up_at`, and `additional_details` on the lead, and a field you leave
out is cleared. Send all three on every request.

The idempotency key and the email match solve different problems. The key stops a **retry** of one
submission from making two leads. The email match stops a **second submission** from the same
person from making a second lead.

## Look a person up before you send

Sometimes you need to know who someone is first, for example to greet a past client differently.
Search contacts by email or phone:

<CodeTabs syncKey="lang">

```bash title="curl"
curl "https://api.mos.getbrokerplus.ca/v1/contacts?email=jordan@example.com" \
  -H "Authorization: Bearer $BROKERPLUS_API_KEY"
```

```ts title="TypeScript"
const url = new URL("https://api.mos.getbrokerplus.ca/v1/contacts");
url.searchParams.set("email", "jordan@example.com");
const res = await fetch(url, {
  headers: { Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}` },
});
const { data } = await res.json();
const existing = data[0] ?? null;
```

```python title="Python"
import os

import requests

res = requests.get(
    "https://api.mos.getbrokerplus.ca/v1/contacts",
    params={"email": "jordan@example.com"},
    headers={"Authorization": f"Bearer {os.environ['BROKERPLUS_API_KEY']}"},
)
data = res.json()["data"]
existing = data[0] if data else None
```

</CodeTabs>

```json
{
  "success": true,
  "data": [
    {
      "object": "contact",
      "id": "cmcontact...",
      "kind": "individual",
      "types": ["client"],
      "first_name": "Jordan",
      "last_name": "Client",
      "email": "jordan@example.com",
      "phone": "+1 587 555 1234"
    }
  ],
  "pagination": { "total": 1, "page": 1, "page_size": 50, "total_pages": 1 }
}
```

The response is trimmed to a few fields.

- `email` ignores case and also matches a contact's additional email addresses.
- `phone` compares the last 10 digits, so `+1 (587) 555-1234` and `5875551234` match.
- `GET /v1/leads` takes the same `email` and `phone` filters, matched against each lead's contact.
- No match is an empty `data` list, not a `404`.

## Leads from ads

Facebook, Google, and other ad platforms post each lead to a URL you own. Point that URL at your
server, not at BrokerPlus, and make the same `POST /v1/leads` request from there.

- Use the platform's lead id as the `Idempotency-Key`, for example `meta-lead-1234567890`. Ad
  platforms do deliver the same lead more than once.
- Acknowledge the platform first, then send the lead to BrokerPlus. A platform that waits on
  BrokerPlus can time out and send the lead again.
- Record where the lead came from in custom fields, so the broker can filter and report on it.

### Record the source and the click id

`POST /v1/leads` takes `custom_fields`, an object of values keyed by field `key`. A lead's custom
fields are its contact's custom fields.

1. In BrokerPlus, create a contact custom field for each value, for example `lead_source` and
   `click_id`.
2. Call `GET /v1/custom-fields?entity=contact` to get each field's exact `key`.
3. Send the values with the lead.

<CodeTabs syncKey="lang">

```bash title="curl"
curl -X POST https://api.mos.getbrokerplus.ca/v1/leads \
  -H "Authorization: Bearer $BROKERPLUS_API_KEY" \
  -H "Idempotency-Key: meta-lead-1234567890" \
  -H "Content-Type: application/json" \
  -d '{"first_name":"Jordan","last_name":"Client","email":"jordan@example.com","service_type":"Purchase","custom_fields":{"lead_source":"Facebook - Spring purchase ad","click_id":"fbclid-8a2c41"}}'
```

```ts title="TypeScript"
type AdLead = { id: string; firstName: string; lastName?: string; email?: string; adName: string; clickId: string };

export async function sendAdLead(lead: AdLead) {
  const body = JSON.stringify({
    first_name: lead.firstName,
    last_name: lead.lastName ?? null,
    email: lead.email ?? null,
    service_type: "Purchase",
    custom_fields: { lead_source: `Facebook - ${lead.adName}`, click_id: lead.clickId },
  });

  const res = await fetch("https://api.mos.getbrokerplus.ca/v1/leads", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}`,
      "Idempotency-Key": `meta-lead-${lead.id}`,
      "Content-Type": "application/json",
    },
    body,
  });
  if (!res.ok) throw new Error(`BrokerPlus returned ${res.status}: ${await res.text()}`);
  return (await res.json()).data;
}
```

```python title="Python"
import json
import os

import requests


def send_ad_lead(lead: dict) -> dict:
    body = json.dumps({
        "first_name": lead["first_name"],
        "last_name": lead.get("last_name"),
        "email": lead.get("email"),
        "service_type": "Purchase",
        "custom_fields": {
            "lead_source": f"Facebook - {lead['ad_name']}",
            "click_id": lead["click_id"],
        },
    })
    res = requests.post(
        "https://api.mos.getbrokerplus.ca/v1/leads",
        data=body,
        headers={
            "Authorization": f"Bearer {os.environ['BROKERPLUS_API_KEY']}",
            "Idempotency-Key": f"meta-lead-{lead['id']}",
            "Content-Type": "application/json",
        },
        timeout=30,
    )
    res.raise_for_status()
    return res.json()["data"]
```

</CodeTabs>

Each value is text, in the same format `PATCH /v1/contacts/{id}/custom-fields/{key}` takes. A
`select` field takes one of its options.

The API checks every key and value before it creates anything. One unknown key or bad value
refuses the whole request with `400 VALIDATION_ERROR`, and `details.custom_fields` says what to fix:

```json
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Fix the custom_fields named in details",
    "details": {
      "custom_fields": {
        "click_id": "No contact custom field has this key. List the keys with GET /v1/custom-fields?entity=contact."
      }
    }
  }
}
```

Nothing was created, so fix the field and send the request again with the same `Idempotency-Key`.

## When a request fails

| Response               | What to do                                                                    |
| ---------------------- | ----------------------------------------------------------------------------- |
| `400 VALIDATION_ERROR` | Fix the field that `details` names. Sending the same body again won't help.   |
| `401 UNAUTHORIZED`     | The key was revoked, or its broker was deactivated. Create a new key.         |
| `429 RATE_LIMITED`     | Wait `Retry-After` seconds, then send the same request.                       |
| A timeout or a `5xx`   | Send the same request again with the same `Idempotency-Key`.                  |

Keep each submission on your server until a request succeeds. A lead your server drops is a lead
no broker ever sees. Every code is listed on [Errors](/errors).

## Lead forms

A lead form is a form that BrokerPlus hosts for the broker. Put its `url` in an `iframe` on a
website, or link to it, and each answer arrives on the Leads board with `lead_form_id` set. You can
build, change, and delete forms through the API, with the same checks as the form builder.

```bash title="curl"
curl -X POST https://api.mos.getbrokerplus.ca/v1/lead-forms \
  -H "Authorization: Bearer $BROKERPLUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Renewal quote" }'
```

```json
{
  "success": true,
  "data": {
    "object": "lead_form",
    "id": "cmg8k2f1x0001lf0j3x8w9q2a",
    "name": "Renewal quote",
    "slug": "renewal-quote",
    "url": "https://mos.getbrokerplus.ca/embed/f/renewal-quote",
    "enabled": true,
    "owner_user_id": "cmg1alexbroker0001",
    "owner_name": "Alex Broker",
    "config": { "headline": "Get in touch", "questions": ["..."], "thanks": { "autoReply": false } },
    "lead_count": 0,
    "recent_lead_count": 0,
    "converted_count": 0,
    "newest_lead_at": null,
    "created_at": "2026-10-09T15:02:11.000Z",
    "updated_at": "2026-10-09T15:02:11.000Z"
  }
}
```

- `config` is the whole form, in the builder's own format. To change it, read it with
  `GET /v1/lead-forms/{id}`, change it, and send all of it back with `PATCH`. A field that fails a
  check is named in `details`, for example `config.questions`.
- Every form asks everyone for a first name and an email. BrokerPlus refuses a form that does not.
- `PATCH` with `enabled: false` hides the form. Its leads stay in the book.
- `DELETE` removes the form, and its page stops working at once. Its leads stay, with no
  `lead_form_id`.
- List the leads that a form brought in with `GET /v1/leads?lead_form_id={id}`.

### The auto-reply

A form can send an email or a text to each person who fills it in. That reaches a client, so a
write cannot turn it on: save the form with `thanks.autoReply` set to `false`, then call
`POST /v1/lead-forms/{id}/turn-on-auto-reply`. The reply is on when the response comes back, and the
response is the form. While the reply is on, you can turn it off with `PATCH`, but you cannot change
what it says. A key with the `no_sending` limit cannot turn it on.

## Hear about every new lead

Subscribe a webhook to `lead.created` to get a signed POST whenever BrokerPlus creates a lead from
any source: your form, a BrokerPlus form, or a call. See [Webhooks](/webhooks).
