# Migrate from another CRM

Move your records from another CRM into BrokerPlus through the API. This guide covers the order to
import in, retries that never make duplicates, and how to stay inside the rate limit.

BrokerPlus also has an import in the app. Ask your account contact whether it reads your CRM's
export. Reach for the API when you need to reshape the data on the way in, or when the app import
doesn't read your CRM.

## Before you start

- Contacts, leads, deals, and tasks have batch endpoints that write up to 100 records per request.
  Addresses, custom fields, and notes are one request per record.
- Every record a key creates belongs to the broker who created the key. To import each broker's
  book under their name, use a key each broker creates.
- Import 10 records first and look at them in BrokerPlus before you send the rest.

## Pause your automations first

A record the API creates starts the same automations as one a broker adds by hand. A new contact
can start **Contact Added** automations, a new deal can start **Deal Created**, and a lead from
`POST /v1/leads` or `POST /v1/leads/batch` starts **New Lead Created**. Any of these can email or
text every person you import.

**Turn off every automation on those three triggers before the import**, and turn them back on
after.

Don't import old leads as leads. `POST /v1/leads` is for someone asking for help right now. For an
old lead, create a contact with `"types": ["lead"]`.

## The order of the import

Each step uses ids from the one before it:

1. Contacts: `POST /v1/contacts`, or `POST /v1/contacts/batch`.
2. Their addresses: `POST /v1/contacts/{id}/addresses`.
3. Their custom fields: `PATCH /v1/contacts/{id}/custom-fields/{key}`.
4. Deals, with the contacts on them: `POST /v1/deals`, or `POST /v1/deals/batch`.
5. Notes on contacts and deals: `POST /v1/contacts/{id}/notes` and `POST /v1/deals/{id}/notes`.

Keep a map from each old id to its new BrokerPlus id, in a file or a table. Every step needs it,
and it lets you pick up where you left off after a failure. The batch endpoints can keep this map
for you; see [Send records in batches](#send-records-in-batches).

## Use the old id in the idempotency key

Send an `Idempotency-Key` on every `POST`, built from the old system and the old id, for example
`oldcrm-contact-48213`. If the import stops and you run it again, a record that already went in
replays its first response instead of being created twice.

The API remembers a key for 24 hours, so use your id map to skip records from an earlier run, and
the idempotency key for retries within one run. A retry must send the exact same body bytes: build
each body once and reuse the string. See [Idempotency and batch](/idempotency).

## Check for people who are already in the book

Send `"check_duplicates": true` on each contact create. The API then refuses a contact with the
same email, or the same first and last name, as someone already in the book. You get
`409 DUPLICATE_CONTACT` with the matches in `details.matches`. Write the match into your id map
instead of creating the contact.

```json
{
  "success": false,
  "error": {
    "code": "DUPLICATE_CONTACT",
    "message": "A contact with this name or email address is already in your book",
    "details": {
      "matches": [{ "id": "cmcontact...", "name": "Jordan Client", "email": "jordan@example.com", "matched_on": "email" }]
    }
  }
}
```

A name match can be a different person with the same name. When `matched_on` is the name only,
check before you treat the two as one.

Your brokerage can also keep each person on one team only. Then a person who is already on another
team's book refuses the create with the same `409`, whether or not you sent `check_duplicates`, and
`details.matches` can be empty because that record isn't yours to see. Log those people and ask
the other team.

## Import a contact

<CodeTabs syncKey="lang">

```bash title="curl"
curl -X POST https://api.mos.getbrokerplus.ca/v1/contacts \
  -H "Authorization: Bearer $BROKERPLUS_API_KEY" \
  -H "Idempotency-Key: oldcrm-contact-48213" \
  -H "Content-Type: application/json" \
  -d '{"first_name":"Jordan","last_name":"Client","email":"jordan@example.com","phone":"+1 587 555 1234","types":["client"],"check_duplicates":true}'
```

```ts title="TypeScript"
const API = "https://api.mos.getbrokerplus.ca/v1";
const headers = {
  Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}`,
  "Content-Type": "application/json",
};

type OldContact = { id: string; first: string; last: string; email: string | null; phone: string | null };

// Returns the BrokerPlus id: a new contact, or the match already in the book.
// Returns null when another team's book has the person and you cannot see the record.
export async function importContact(old: OldContact): Promise<string | null> {
  const body = JSON.stringify({
    first_name: old.first,
    last_name: old.last,
    email: old.email,
    phone: old.phone,
    types: ["client"],
    check_duplicates: true,
  });

  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch(`${API}/contacts`, {
      method: "POST",
      headers: { ...headers, "Idempotency-Key": `oldcrm-contact-${old.id}` },
      body,
    });
    const json = await res.json();
    if (res.ok) return json.data.id;
    if (res.status === 409 && json.error.code === "DUPLICATE_CONTACT") return json.error.details?.matches?.[0]?.id ?? null;
    if (res.status === 429 || res.status >= 500) {
      const wait = Number(res.headers.get("Retry-After") ?? 2 ** attempt);
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue;
    }
    throw new Error(`contact ${old.id}: ${res.status} ${JSON.stringify(json.error)}`);
  }
  throw new Error(`contact ${old.id}: too many retries`);
}
```

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

import requests

API = "https://api.mos.getbrokerplus.ca/v1"
HEADERS = {
    "Authorization": f"Bearer {os.environ['BROKERPLUS_API_KEY']}",
    "Content-Type": "application/json",
}


def import_contact(old: dict) -> str | None:
    """Returns the BrokerPlus id: a new contact, or the match already in the book.
    Returns None when another team's book has the person and you cannot see the record."""
    body = json.dumps({
        "first_name": old["first"],
        "last_name": old["last"],
        "email": old.get("email"),
        "phone": old.get("phone"),
        "types": ["client"],
        "check_duplicates": True,
    })
    headers = {**HEADERS, "Idempotency-Key": f"oldcrm-contact-{old['id']}"}

    for attempt in range(5):
        res = requests.post(f"{API}/contacts", data=body, headers=headers, timeout=60)
        payload = res.json()
        if res.ok:
            return payload["data"]["id"]
        if res.status_code == 409 and payload["error"]["code"] == "DUPLICATE_CONTACT":
            matches = (payload["error"].get("details") or {}).get("matches") or []
            return matches[0]["id"] if matches else None
        if res.status_code == 429 or res.status_code >= 500:
            time.sleep(int(res.headers.get("Retry-After", 2 ** attempt)))
            continue
        raise RuntimeError(f"contact {old['id']}: {res.status_code} {payload['error']}")
    raise RuntimeError(f"contact {old['id']}: too many retries")
```

</CodeTabs>

## Import a deal

`POST /v1/deals` takes the deal type, an optional pipeline and stage, a closing date, and the
contacts on the deal, using the BrokerPlus contact ids from your id map.

<CodeTabs syncKey="lang">

```bash title="curl"
curl -X POST https://api.mos.getbrokerplus.ca/v1/deals \
  -H "Authorization: Bearer $BROKERPLUS_API_KEY" \
  -H "Idempotency-Key: oldcrm-deal-9921" \
  -H "Content-Type: application/json" \
  -d '{"type":"purchase","closing_at":"2026-11-30","contacts":[{"contact_id":"cmcontact...","role":"primary_applicant"}]}'
```

```ts title="TypeScript"
const res = await fetch("https://api.mos.getbrokerplus.ca/v1/deals", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}`,
    "Idempotency-Key": "oldcrm-deal-9921",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    type: "purchase",
    closing_at: "2026-11-30",
    contacts: [{ contact_id: "cmcontact...", role: "primary_applicant" }],
  }),
});
const deal = (await res.json()).data;
```

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

import requests

res = requests.post(
    "https://api.mos.getbrokerplus.ca/v1/deals",
    data=json.dumps({
        "type": "purchase",
        "closing_at": "2026-11-30",
        "contacts": [{"contact_id": "cmcontact...", "role": "primary_applicant"}],
    }),
    headers={
        "Authorization": f"Bearer {os.environ['BROKERPLUS_API_KEY']}",
        "Idempotency-Key": "oldcrm-deal-9921",
        "Content-Type": "application/json",
    },
)
deal = res.json()["data"]
```

</CodeTabs>

Then fill in the rest with `PATCH` requests: the loan terms with `PATCH /v1/deals/{id}/application`,
the property address with `PATCH /v1/deals/{id}/property`, and the deal's status with
`PATCH /v1/deals/{id}`. A `PATCH` takes no idempotency key and doesn't need one: sending the same
`PATCH` twice gives the same result.

## Send records in batches

`POST /v1/contacts/batch`, `POST /v1/leads/batch`, `POST /v1/deals/batch`, and
`POST /v1/tasks/batch` write up to 100 records per request. Each item's `data` is the body you would
send to the single endpoint: a `POST` body for a new record, a `PATCH` body for one that exists.

Put your old id in `external_id` and the old system's name in `namespace`. The first batch creates
the record and remembers your id. A later batch with the same `namespace` and `external_id` updates
that record instead of creating another, so you can rerun the import after a failure, or a week
later to bring changes across, with no id map for these four types.

```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/contacts/batch \
  -H "Authorization: Bearer $BROKERPLUS_API_KEY" \
  -H "Idempotency-Key: oldcrm-contacts-page-1" \
  -H "Content-Type: application/json" \
  -d '{
    "namespace": "oldcrm",
    "items": [
      { "external_id": "48213", "data": { "first_name": "Jordan", "last_name": "Client", "email": "jordan@example.com" } },
      { "external_id": "48214", "data": { "first_name": "Sam", "email": "not-an-email" } }
    ]
  }'
```

The response is `200`, with one result per item, in the order you sent them:

```json
{
  "success": true,
  "data": {
    "created": 1,
    "updated": 0,
    "failed": 1,
    "results": [
      { "object": "batch_item_result", "index": 0, "status": "created", "id": "cmcontact...", "external_id": "48213", "error": null },
      {
        "object": "batch_item_result",
        "index": 1,
        "status": "failed",
        "id": null,
        "external_id": "48214",
        "error": { "status": 400, "code": "VALIDATION_ERROR", "message": "Invalid contact", "details": { "fieldErrors": { "email": ["Invalid email address"] } } }
      }
    ]
  }
}
```

A batch isn't one transaction: a failed item doesn't stop or undo the others. Check `status` on each
result, fix the failures, and resend only those. Each item also counts as one request against the
rate limit and runs the same checks and automations as the single endpoint. The full rules are on
[Idempotency and batch](/idempotency).

## Stay inside the rate limit

A key gets 600 requests a minute. A contact can take three or four requests (the contact, an
address, custom fields, notes), so plan on about 150 contacts a minute per key.

- Send at a steady pace of no more than 10 requests a second. Use a fixed-rate queue, not bursts.
- Watch `RateLimit-Remaining` and slow down when it gets low.
- On `429`, wait `Retry-After` seconds and send the same request again.
- Each broker's key has its own limit, so you can import several brokers in parallel, one key each.

For example, 10,000 contacts at four requests each is 40,000 requests: a bit over an hour on one
key. See [Authentication](/authentication#rate-limit).

## After the import

Run the [sync loop](/sync-your-book) once and compare the contact and deal counts with the old CRM.
Then give your brokers access. Don't keep taking new work in both systems: two places to write make
two different books.
