# BrokerPlus API
> Complete documentation for Large Language Models
---
## Document: Make your first call
Create an API key, check who it acts as, and read your first contacts.
URL: /quickstart
# Make your first call
This takes about two minutes. You need to be an admin of your brokerage in BrokerPlus, or to ask
one for a key. If you want to experiment without touching real data, make a
[sandbox](/sandbox) first and use its key.
## 1. Create a key
1. In BrokerPlus, go to **Settings > Developer**.
2. Select **New key**, and name it after what will use it, such as "Website lead form".
3. Copy the key now. BrokerPlus shows it only once.
Keep the key in an environment variable. Every example on this site reads it from
`BROKERPLUS_API_KEY`:
```bash
export BROKERPLUS_API_KEY="bp_live_..."
```
## 2. Check who the key is
`GET /v1/me` returns the brokerage, the broker the key acts as, and the key itself. It is the
quickest way to check that everything is wired up.
```bash title="curl"
curl https://api.mos.getbrokerplus.ca/v1/me \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```ts title="TypeScript"
const res = await fetch("https://api.mos.getbrokerplus.ca/v1/me", {
headers: { Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}` },
});
const { data } = await res.json();
```
```python title="Python"
import os
import requests
res = requests.get(
"https://api.mos.getbrokerplus.ca/v1/me",
headers={"Authorization": f"Bearer {os.environ['BROKERPLUS_API_KEY']}"},
)
data = res.json()["data"]
```
```json
{
"success": true,
"data": {
"organization": { "id": "cmsclli...", "name": "Example Mortgage Group" },
"user": { "id": "cmscllii...", "name": "Alex Broker", "email": "alex@example.com", "role": "admin" },
"api_key": {
"id": "cmsctvh...",
"name": "Website lead form",
"read_only": false,
"limits": [],
"last_used_at": null,
"created_at": "2026-10-07T14:00:00.000Z",
"team": { "id": "cmscteam...", "name": "Alex Broker's Team" }
}
}
}
```
A `401` means the key is wrong or revoked. See [Authentication and keys](/authentication).
## 3. Read some data
List the first five contacts in the book:
```bash
curl "https://api.mos.getbrokerplus.ca/v1/contacts?page_size=5" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```json
{
"success": true,
"data": [
{
"object": "contact",
"id": "cmsclk2a9...",
"kind": "individual",
"types": ["client"],
"first_name": "Sam",
"last_name": "Client",
"email": "sam@example.com",
"owner_id": "cmscllii...",
"owner_name": "Alex Broker",
"updated_at": "2026-10-06T19:12:40.000Z"
}
],
"pagination": { "total": 412, "page": 1, "page_size": 5, "total_pages": 83 }
}
```
The real response carries more fields on each contact. The [API reference](/reference) lists them
all.
## Next
- Pick the job you are building on the [overview](/introduction), and follow its guide.
- Read the [concepts](/units) once: units, pagination, errors, and which requests reach a client. Every guide
assumes them.
- Watch the [changelog](/changelog). The API is in beta and changes often.
---
## Document: Templates
Write the email and text templates that automations send, with merge fields that fill in the client's details.
URL: /templates
# Templates
A template is the words of one message: a subject and a body for an email, or just a body for a
text. [Automations](/automations) send templates, and a template never sends anything on its own.
This page shows you how to write one, and how to use merge fields to fill in the client's name, the
property address, and the rest.
## What a key sees
The API reads and writes the same Pulse library brokers see in BrokerPlus. A key sees the templates
of every team its broker belongs to, and a new template goes to the team the broker is working in.
Creating, changing, or archiving one needs permission to change automations; without it the API
returns `403 AUTOMATION_MANAGE_FORBIDDEN`.
Two kinds of template aren't in the API: task templates (they describe a task an automation files,
not a message), and the follow-up copy of a named outreach variant, which is edited beside its
variant in BrokerPlus.
`attachments` lists the files sent with each email from a template, with their name, size, and type.
Add or remove attachments in BrokerPlus.
## Find the merge fields
A merge field drops a value from the file into the message, such as the client's first name.
`GET /v1/templates/merge-fields` lists every one, including your brokerage's custom fields.
```bash title="curl"
curl "https://api.mos.getbrokerplus.ca/v1/templates/merge-fields" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```ts title="TypeScript"
const res = await fetch("https://api.mos.getbrokerplus.ca/v1/templates/merge-fields", {
headers: { Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}` },
});
const { data } = await res.json();
```
```python title="Python"
import os
import requests
res = requests.get(
"https://api.mos.getbrokerplus.ca/v1/templates/merge-fields",
headers={"Authorization": f"Bearer {os.environ['BROKERPLUS_API_KEY']}"},
)
data = res.json()["data"]
```
```json
{
"success": true,
"data": [
{
"name": "client.firstName",
"label": "Client first name",
"section": "Client",
"format": "text",
"example": "Priya",
"available_on": "any",
"may_be_empty": false
},
{
"name": "deal.financingConditionDate",
"label": "Financing condition date",
"section": "Deal",
"format": "date",
"example": "June 6, 2026",
"available_on": "deal",
"may_be_empty": true
}
]
}
```
The list is long. These are the fields a stage-change email usually needs:
| Merge field | Value |
| --- | --- |
| `client.firstName`, `client.fullName` | The client the message goes to. |
| `partner.realtor.fullName`, `partner.realtor.email`, `partner.realtor.phone` | The Realtor on the deal. |
| `property.fullAddress` | The address of the subject property. |
| `deal.financingConditionDate` | The financing condition date. |
| `broker.fullName`, `broker.email`, `broker.phone`, `broker.signature` | The broker the message goes out as. |
`available_on` tells you which automations can fill a field. A `deal` field is empty in an
automation that runs on a contact with no deal, so keep `deal` fields out of contact automations.
## Write a merge field
Write a merge field between double braces: `{{client.firstName}}`. A bare merge field is required:
if the file has no value for it, BrokerPlus holds the message for the broker rather than send it with
a gap.
When the value can be missing, add a fallback after a vertical bar:
`{{partner.realtor.firstName|your Realtor}}`. Give every field whose `may_be_empty` is `true` a
fallback.
A merge field that isn't in the list is refused with `400 UNKNOWN_MERGE_FIELDS`, and
`error.details.unknown` names each one.
## Create a template
`POST /v1/templates` creates a template. An email template needs a `subject`. A text template has
neither `subject` nor `body_html`, and sending either one is refused.
```bash title="curl"
curl -X POST "https://api.mos.getbrokerplus.ca/v1/templates" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Conditions waived",
"channel": "email",
"subject": "Your financing is firm, {{client.firstName}}",
"body": "Hi {{client.firstName}},\n\nThe financing conditions on {{property.fullAddress}} are waived. {{partner.realtor.firstName|Your Realtor}} knows.\n\n{{broker.fullName}}"
}'
```
```ts title="TypeScript"
const res = await fetch("https://api.mos.getbrokerplus.ca/v1/templates", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Conditions waived",
channel: "email",
subject: "Your financing is firm, {{client.firstName}}",
body: "Hi {{client.firstName}},\n\nThe financing conditions on {{property.fullAddress}} are waived. {{partner.realtor.firstName|Your Realtor}} knows.\n\n{{broker.fullName}}",
}),
});
const { data } = await res.json();
```
```python title="Python"
import os
import requests
res = requests.post(
"https://api.mos.getbrokerplus.ca/v1/templates",
headers={"Authorization": f"Bearer {os.environ['BROKERPLUS_API_KEY']}"},
json={
"name": "Conditions waived",
"channel": "email",
"subject": "Your financing is firm, {{client.firstName}}",
"body": "Hi {{client.firstName}},\n\nThe financing conditions on {{property.fullAddress}} are waived. {{partner.realtor.firstName|Your Realtor}} knows.\n\n{{broker.fullName}}",
},
)
data = res.json()["data"]
```
```json
{
"success": true,
"data": {
"object": "template",
"id": "cmt8k2v0q00",
"name": "Conditions waived",
"channel": "email",
"subject": "Your financing is firm, {{client.firstName}}",
"body": "Hi {{client.firstName}},\n\nThe financing conditions on {{property.fullAddress}} are waived. {{partner.realtor.firstName|Your Realtor}} knows.\n\n{{broker.fullName}}",
"body_html": null,
"merge_fields": [
{ "name": "client.firstName", "fallback": null },
{ "name": "property.fullAddress", "fallback": null },
{ "name": "partner.realtor.firstName", "fallback": "Your Realtor" },
{ "name": "broker.fullName", "fallback": null }
],
"attachments": [],
"team_id": "cmt1q9x4a00",
"archived_at": null,
"created_at": "2026-10-07T15:02:11.481Z",
"updated_at": "2026-10-07T15:02:11.481Z"
}
}
```
To send it, name the template's `id` in an `email` or `sms` step of an
[automation](/automations#example-email-the-client-and-the-realtor-when-a-deal-moves-into-a-stage).
## Change a template
`PATCH /v1/templates/{id}` changes the fields you send. Every automation that sends the template uses
the new words from its next message on.
`body_html` is the formatted version of an email. If you send a new `body` without a new `body_html`,
BrokerPlus drops the formatted version, because it still holds the old words. Send both to keep a
formatted email. Scripts and unsafe markup are stripped from `body_html` before it is saved.
A template's `channel` can't change. Create a new template instead.
## Archive a template
`DELETE /v1/templates/{id}` archives the template. Messages it already sent stay on the file. If an
automation still sends it, the API returns `409 TEMPLATE_IN_USE`: take it out of that automation
first, with `PATCH /v1/automations/{id}` or in BrokerPlus.
`GET /v1/templates` leaves archived templates out. Add `include_archived=true` to see them.
## Outreach templates
Outreach templates are the first message a broker sends by hand from the refinance, renewal, and
lead lanes, as the outreach templates editor shows them. They are separate from the Pulse library
above: automations never send them. `kind` names the lane and the channel: `refinance_initial`,
`renewal_initial`, and `leads_initial` are emails, and `refinance_sms`, `renewal_sms`, and
`leads_sms` are texts.
Each broker keeps their own set, and their team keeps a shared set. `GET /v1/outreach-templates`
lists both; `scope` says which set a template is in. A teammate's own templates are never listed.
The first time a lane is listed, the broker's own set gets the default copy, as in the editor.
- `POST /v1/outreach-templates` adds one to the broker's own set, or to the team's with
`"scope": "team"`. Send no `subject` and no `body` to start from the lane's default copy.
- `PATCH` and `DELETE /v1/outreach-templates/{id}` change or remove one. The broker's own set always
keeps one template per lane.
- `body_html` is the formatted version of an email template, as the broker's editor saves it. Send
it on `PATCH` with `body` as the plain-text version of the same message. A new `body` without a new
`body_html` removes the formatted version, and null goes back to plain text. Scripts and unsafe
markup are stripped before it is saved, and a text template never keeps one.
- `POST /v1/outreach-templates/{id}/set-primary` makes it the one the lane starts from when the
broker sends. Each set has one primary per lane.
- `POST /v1/outreach-templates/{id}/duplicate` copies it in its own set, `/share` copies one of the
broker's own into the team's set, and `/copy-to-personal` copies one of the team's into the
broker's own.
Only a broker who can send email can write the team's set; anyone else gets `403`. Nothing here
sends a message.
## Media library
The media library holds the pictures and video stills the brokerage puts in outreach emails. Every
broker in the brokerage shares one library.
- `GET /v1/media-assets` lists it, newest first. `url` is the picture's permanent public address,
the one an email shows.
- `POST /v1/media-assets` uploads a picture: PNG, JPEG, WebP, or GIF, at most 10 MB and 2000 pixels
on its longest side. Send JSON with `file_name` and `content_base64`, or `multipart/form-data`
with the file in a field named `file`.
- `POST /v1/media-assets/video-links` adds a YouTube, Vimeo, Loom, NeetoRecord, Instagram, or
Facebook video as a still with a play button, linking to the video.
- `DELETE /v1/media-assets/{id}` removes one. An email already sent that shows it shows a broken
picture from then on.
```bash
curl -X POST "https://api.mos.getbrokerplus.ca/v1/media-assets/video-links" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "name": "Renewal walkthrough"}'
```
---
## Document: Mail
Read a connected mailbox, act on a conversation, and get told when new mail arrives.
URL: /mail
# Mail
When a broker connects Gmail or Microsoft 365 in BrokerPlus, the API can read that mail too. You
don't need a second connection to Gmail or Microsoft Graph. This page walks through one job end to
end: a daily review that finds the email that needs an answer.
This API is in beta. Any endpoint can change in a breaking way without notice, so watch the
[changelog](/changelog).
## What a key can read
A key reads the mailboxes its broker can read in BrokerPlus, and no others. A teammate who hasn't
shared their mailbox stays private.
BrokerPlus holds roughly the last six months of a mailbox and keeps it current from the moment it is
connected. Sent and received mail are in the same list. Drafts aren't.
## Read the mail
Every email is a communication. `GET /v1/communications?channel=email` lists them, newest first.
```bash title="curl"
curl "https://api.mos.getbrokerplus.ca/v1/communications?channel=email&page_size=1" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```ts title="TypeScript"
const res = await fetch("https://api.mos.getbrokerplus.ca/v1/communications?channel=email&page_size=1", {
headers: { Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}` },
});
const { data } = await res.json();
```
```python title="Python"
import os
import requests
res = requests.get(
"https://api.mos.getbrokerplus.ca/v1/communications",
params={"channel": "email", "page_size": 1},
headers={"Authorization": f"Bearer {os.environ['BROKERPLUS_API_KEY']}"},
)
data = res.json()["data"]
```
```json
{
"success": true,
"data": [
{
"object": "communication",
"id": "cmsclloss01wap3ium49cw7rq",
"deal_id": "cmscllt4h01xyp3iuq7dk2m8z",
"channel": "email",
"direction": "inbound",
"subject": "Re: Mortgage commitment",
"body": "Here is the signed commitment.",
"body_html": "
Here is the signed commitment.
",
"status": "received",
"from": { "name": "Jordan Client", "email": "jordan@example.com" },
"to": [{ "name": "Alex Broker", "email": "alex@example.com" }],
"cc": [],
"mailbox": "alex@example.com",
"thread_id": "19fed14c88a5b164",
"attachments": [{ "id": "att_8f21", "name": "commitment.pdf", "content_type": "application/pdf", "size": 184320, "inline": false }],
"folders": [{ "name": "Inbox", "role": "inbox" }],
"read_at": null,
"starred": false,
"sent_at": null,
"received_at": "2026-09-30T18:04:11.000Z",
"created_at": "2026-09-30T18:04:14.221Z"
}
],
"pagination": { "page": 1, "page_size": 1, "total": 412, "total_pages": 412 }
}
```
`read_at` is null for mail nobody has opened. `body` is plain text and `body_html` is the sender's
HTML. BrokerPlus doesn't sanitize `body_html`, so sanitize it before you show it to anyone.
### Attachments
Ask for an attachment by its `id` to get a download link:
```sh
curl https://api.mos.getbrokerplus.ca/v1/communications/cmsclloss01wap3ium49cw7rq/attachments/att_8f21 \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```json
{
"success": true,
"data": {
"object": "attachment",
"id": "att_8f21",
"name": "commitment.pdf",
"content_type": "application/pdf",
"size": 184320,
"inline": false,
"download_url": "https://files.getbrokerplus.ca/...",
"download_url_expires_at": "2026-09-30T18:12:00.000Z"
}
}
```
The link works like a [document download URL](/files#download-urls): it carries its own
authorization, so follow it right away and don't store or share it. BrokerPlus fetches the file from
the mail provider the first time anyone asks, so the first call on an old message is slower. If the
mailbox no longer has the message, you get `410 ATTACHMENT_UNAVAILABLE`. A key with the
`no_documents` limit can't download attachments.
## The filters a review needs
| Filter | What it does |
| ------------------- | -------------------------------------------------------------------------------------------------- |
| `unread=true` | Only mail nobody has opened. |
| `folder=inbox` | One folder, by role or by the provider's own folder name. |
| `thread_id=...` | Every message in one conversation. |
| `direction=inbound` | Only mail that came in. |
| `updated_since=...` | Mail that changed at or after this time: read, starred, filed on a deal, or moved between folders. |
A daily review asks for the unread inbox mail since the last run:
```bash title="curl"
curl "https://api.mos.getbrokerplus.ca/v1/communications?channel=email&folder=inbox&unread=true&updated_since=2026-09-30T06:00:00Z" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```ts title="TypeScript"
const url = new URL("https://api.mos.getbrokerplus.ca/v1/communications");
url.search = new URLSearchParams({
channel: "email",
folder: "inbox",
unread: "true",
updated_since: "2026-09-30T06:00:00Z",
}).toString();
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}` },
});
const { data } = await res.json();
```
```python title="Python"
import os
import requests
res = requests.get(
"https://api.mos.getbrokerplus.ca/v1/communications",
params={
"channel": "email",
"folder": "inbox",
"unread": "true",
"updated_since": "2026-09-30T06:00:00Z",
},
headers={"Authorization": f"Bearer {os.environ['BROKERPLUS_API_KEY']}"},
)
data = res.json()["data"]
```
With `updated_since` the list runs oldest first, so you can page forward through a backlog; see
[Sync](/sync). To read the conversation around a message, filter by its `thread_id`.
## Get told instead of asking
Subscribe a [webhook](/webhooks) to `email.received`, and BrokerPlus calls you when mail arrives.
```sh
curl https://api.mos.getbrokerplus.ca/v1/webhooks \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/hooks/brokerplus", "events": ["email.received"] }'
```
The delivery carries the message's id; read it with `GET /v1/communications/{id}`. The event fires
for new mail only, not for junk and not for the history imported when a mailbox is first connected.
## Act on a conversation
`PATCH /v1/communications/{id}` does what the inbox does: mark read or unread, mark done, star, set a
reminder, or file the mail on a deal. Each change applies to the whole conversation, as in the inbox,
and nothing goes to the client.
```bash
curl -X PATCH https://api.mos.getbrokerplus.ca/v1/communications/cmsm2a1... \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"read": true, "remind_at": "2026-10-09T13:00:00Z", "remind_condition": "if_no_reply"}'
```
```json
{
"success": true,
"data": {
"object": "communication",
"id": "cmsm2a1...",
"read_at": "2026-10-07T18:02:11.000Z",
"remind_at": "2026-10-09T13:00:00.000Z",
"done_at": null
}
}
```
The response is shortened here. Marking mail read also marks it read in the broker's own mailbox. At
`remind_at`, the conversation comes back to the top of the inbox and the broker gets a notification;
with `if_no_reply`, the reminder is dropped if the client replies first.
Send `deal_id` to file the mail on a deal. Add `"deal_scope": "message"` to file only this message,
for example when one client writes about two deals in the same thread.
## Tag a conversation
Inbox tags are the brokerage's own labels, such as Rate Hold or Docs Outstanding. They are a
separate list from contact tags and deal tags. `GET /v1/communication-tags` lists them, and
`POST`, `PATCH` and `DELETE` on `/v1/communication-tags` add, rename and archive them, the same as the
tag editor in the inbox.
`POST /v1/communications/{id}/tags` with a `tag_id` puts a tag on every message in the conversation.
`DELETE /v1/communications/{id}/tags/{tagId}` takes it off again. Each message lists its tags in
`tags`.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/communications/cmsm2a1.../tags \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"tag_id": "cmtg81x..."}'
```
On an Outlook mailbox, the tag is also written to Outlook as a category. BrokerPlus does not change
the labels of a Gmail mailbox, so a tag on Gmail mail gets `409 TAG_SYNC_UNSUPPORTED`. A tag that
came from the mailbox itself cannot be renamed or archived while synced mail carries it.
## File a conversation in a folder
Folders are the mailbox's own folders and labels, read from Gmail or Outlook.
`GET /v1/communication-folders` lists the folders of the broker's mailbox. Pass `mailbox` with an
address to list another mailbox the broker can read. Each message lists the folders it sits in, with
their ids, in `folders`.
`POST /v1/communications/{id}/folders` with a `folder_id` files every message in the conversation
into that folder, and `DELETE /v1/communications/{id}/folders/{folderId}` takes it out. The move
also happens in the broker's mail app a few seconds later. On Gmail a conversation can carry several
labels; on Outlook it moves to the one folder. Make, rename and delete folders in the mail app.
## Snippets
Snippets are a broker's own saved replies. The broker types a snippet's `shortcut` in the composer to
insert it. `GET /v1/snippets` lists the snippets of the broker your key acts as, and `POST`, `PATCH`
and `DELETE` on `/v1/snippets` add, edit and delete them. You never see or change another broker's
snippets. Saving a snippet sends nothing.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/snippets \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"shortcut": "docs", "name": "Documents", "body": "Please send your last two pay stubs."}'
```
A shortcut is unique among the broker's snippets, so reusing one gets `409 DUPLICATE_SHORTCUT`. If
you send `body_html`, unsafe HTML is removed from it, the same as for an email template. A snippet's
files are listed in `attachments`. `GET /v1/snippets/{id}/attachments/{attachmentId}` gives a link to
one of them that works for five minutes.
`POST /v1/snippets/{id}/attachments` adds a file, sent as `multipart/form-data` in a `file` field or as
JSON with `file_name` and `content_base64`. BrokerPlus reads the file's type from its content, not its
name. A PDF, JPEG, PNG, TIFF, WebP, Word or Excel file is stored as what it is. Content with no
recognisable type is accepted only as CSV, so a CSV sent as JSON needs a name ending in `.csv`.
Anything else gets `400 INVALID_TYPE`. A snippet holds up to 10 files of at most 25 MB each.
`DELETE /v1/snippets/{id}/attachments/{attachmentId}` removes one.
## Reply to a client
`POST /v1/communications/{id}/reply` answers a message in its own conversation, the same as Reply in
the inbox. The reply goes from the mailbox the conversation is on, with the conversation's subject,
so the client's mail program shows it in the same thread. A text is answered from the broker's
number, in the same text conversation.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/communications/cmsm2a1.../reply \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: reply-cmsm2a1-1" \
-d '{"body": "Hi Sam, the lender approved the file this morning. I will send the commitment shortly."}'
```
```json
{
"success": true,
"data": {
"object": "draft",
"id": "cmdr91f...",
"channel": "email",
"status": "sent",
"subject": "Re: Renewal documents",
"to": [{ "email": "sam@example.com", "name": "Sam Renewal" }],
"cc": [],
"reply_to_communication_id": "cmsm2a1...",
"forward": false,
"communication_id": "cmsm7c4..."
}
}
```
The response is shortened here. The reply goes out at once, as the broker your key acts as, so a key
with the `no_sending` limit gets `403 KEY_LIMITED`. See [Reaching clients](/reaching-clients). An
email is sent in the background, so `communication_id` can be null for a short time.
`mode` picks who the reply goes to:
| `mode` | Goes to |
|---|---|
| `reply` (default) | The sender. On a message the broker sent, the people it was sent to. |
| `reply_all` | The sender and everyone the message was sent to and copied, except the broker's own mailbox. |
| `forward` | The addresses you send in `to`. The original message is quoted below `body`, and its files go with it. |
Send `to` to replace the addresses the mode picks, and `cc` or `bcc` to copy other people. To attach
a file, send it in `attachments` with `file_name`, `content_type`, and `content_base64`. A file can
be at most 10 MB, and the files of one email at most 25 MB together.
A forward carries the files of the email it forwards, before any you add. Send
`"include_attachments": false` to forward the text only. A picture shown inside the email body is
not sent as a file. If the email has a file that BrokerPlus can't send, such as a web page, the
forward gets `400`, and you can forward it without its files.
When the conversation is on a mailbox the broker can read but cannot send from, such as a teammate's
shared mailbox, the reply gets `403 THREAD_VIEW_ONLY`, as it does in the inbox.
### Let the broker read it first
`POST /v1/communications/{id}/drafts` takes the same body and sends nothing. The reply waits in the
broker's drafts, in its conversation, and the broker sends, edits, or discards it in BrokerPlus.
Your system can also change it and send it:
- `PATCH /v1/drafts/{id}` changes the subject, the text, the addresses, or the files.
- `POST /v1/drafts/{id}/send` sends it now.
- `POST /v1/drafts/{id}/discard` throws it away.
### Send it later
Add `send_at` to a reply or to `POST /v1/contacts/{id}/messages` to send it at that time. The draft
is `scheduled` until then. `POST /v1/drafts/{id}/schedule` moves it to another time, or schedules a
draft that waits for review. `POST /v1/drafts/{id}/unschedule` stops it, and the draft waits for
review again.
## Mail merges
A mail merge is one email that each person on a list gets on their own: nobody sees who else got
it. `POST /v1/mail-merges` sends one now, the same as Send merge in BrokerPlus. The response is `201`
with the mail merge and a count of the addresses in each state. A key with the `no_sending` limit
gets `403 KEY_LIMITED`.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/mail-merges \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"subject": "Rates moved this week", "body": "Fixed rates dropped on Monday.", "recipients": [{"email": "ann@example.ca", "name": "Ann Able"}, {"email": "bo@example.ca"}]}'
```
The merge goes from the mailbox of the broker your key acts as, to at most 500 addresses. Everybody
gets the same words, so a merge field such as `{{first_name}}` gets `400 TOKENS_NOT_SUPPORTED`. A
person who unsubscribed or withdrew consent is skipped. Send `schedule_at` to send it later.
`GET /v1/mail-merges` lists the merges sent from the broker's mailbox, with how many addresses are
in each state. `GET /v1/mail-merges/{id}/recipients` shows what became of each address.
BrokerPlus does not keep the address on the merge: `communication_id` is the copy in Sent mail, and
`contact_id` is the contact with that address.
## What you can't do yet
- Read the drafts a broker writes in BrokerPlus. You can read the drafts you file; see
[Drafts](/model#drafts).
- Delete mail.
Tell us which of these you need, and we'll tell you where it sits in the queue.
---
## Document: Log calls from your phone system
Put calls, transcripts, and recaps from another phone system or recorder in the BrokerPlus call log, on the right contact and deal.
URL: /log-calls
# Log calls from your phone system
Put a call from any phone system or recorder in the BrokerPlus call log. It shows up on the
contact and the deal with its transcript, and BrokerPlus writes the recap, just like a call the
broker made from BrokerPlus.
## Before you start
BrokerPlus already connects to Dialpad and Quo with no code: an admin turns them on in
**Settings > Integrations**, and calls flow in on their own. This guide is for everything else, for
example Plaud, Grain, Savo, or a second Dialpad company.
- A call lands in the call log of the broker who created the key. To log each broker's calls, use a
key each broker creates.
- Get your contacts into BrokerPlus first. BrokerPlus finds a call's contact by the client's phone
number.
## What happens when you send a call
When you send a call to `POST /v1/calls`, BrokerPlus:
1. **Finds the contact.** It uses `contact_id` if you send one. Otherwise it matches the client's
number: `from` on an `inbound` call, `to` on an `outbound` one. A number that matches two
contacts matches neither.
2. **Finds the deal.** It uses `deal_id` if you send one. Otherwise it picks the contact's most
recently updated deal.
3. **Files the call** in the broker's call log and on the contact and the deal.
4. **Writes a recap** if the call has a transcript and you didn't send a `summary`, and fills in the
application answers the client gave on the call. Send your own `summary` and BrokerPlus keeps it
instead of writing one.
5. **Sends the `call.logged` webhook.**
## Log a call
```bash title="curl"
curl -X POST https://api.mos.getbrokerplus.ca/v1/calls \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"direction": "inbound",
"provider": "Plaud",
"external_id": "rec_7f3a91",
"from": "+1 587 555 1234",
"started_at": "2026-10-06T15:30:00Z",
"duration_seconds": 312,
"transcript_segments": [
{ "start_seconds": 0, "end_seconds": 4.5, "speaker": "client", "text": "Hi, it is Jordan about the renewal." },
{ "start_seconds": 4.5, "end_seconds": 9, "speaker": "broker", "speaker_name": "Alex Broker", "text": "Happy to help, Jordan." }
]
}'
```
```ts title="TypeScript"
const res = await fetch("https://api.mos.getbrokerplus.ca/v1/calls", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
direction: "inbound",
provider: "Plaud",
external_id: "rec_7f3a91",
from: "+1 587 555 1234",
started_at: "2026-10-06T15:30:00Z",
duration_seconds: 312,
transcript: "Jordan: Hi, it is Jordan about the renewal.\nAlex Broker: Happy to help, Jordan.",
}),
});
const { data: call } = await res.json();
```
Send the words either as plain `transcript` text or as timed `transcript_segments`. The response
is `201`:
```json
{
"success": true,
"data": {
"object": "call",
"id": "cmcall7q2...",
"direction": "inbound",
"status": "completed",
"contact_id": "cmcontact...",
"contact_name": "Jordan Client",
"deal_id": "cmdeal...",
"deal_name": "Client - Renewal",
"user_id": "cmuser...",
"from": "+1 587 555 1234",
"to": null,
"started_at": "2026-10-06T15:30:00.000Z",
"duration_seconds": 312,
"provider": "Plaud",
"external_id": "rec_7f3a91",
"recording_url": null,
"has_recording": false,
"transcript": "Client: Hi, it is Jordan about the renewal.\nAlex Broker: Happy to help, Jordan.",
"transcript_segments": [
{ "start_seconds": 0, "end_seconds": 4.5, "speaker": "client", "speaker_name": null, "text": "Hi, it is Jordan about the renewal." },
{ "start_seconds": 4.5, "end_seconds": 9, "speaker": "broker", "speaker_name": "Alex Broker", "text": "Happy to help, Jordan." }
],
"summary": null,
"created_at": "2026-10-07T14:02:11.000Z",
"updated_at": "2026-10-07T14:02:11.000Z"
}
}
```
`summary` is `null` at first because BrokerPlus writes the recap a moment later. Read the call
again with `GET /v1/calls/{id}` to pick it up.
## Send each call once
Phone systems resend a webhook when they don't get a quick answer, so always send `external_id`
with the phone system's own call id. A second request with the same `provider` and `external_id`
returns the call that's already logged, with `200` instead of `201`, and logs nothing new. Case and
punctuation in `provider` don't matter: `Dialpad` and `dialpad` are the same system.
Send the call once the transcript is ready. BrokerPlus can't add a transcript to a call that's
already in the log, so if your phone system produces the transcript after the call ends, wait for
it.
## Example: relay a second Dialpad company
BrokerPlus connects to one Dialpad company per brokerage. This small server relays a second
company's calls: Dialpad posts a call event when a call ends, and the server sends it on to
BrokerPlus.
Dialpad's event fields can change, so check them against Dialpad's current documentation, and
verify each event's Dialpad signature before you read it.
```ts title="dialpad-relay.ts"
const API = "https://api.mos.getbrokerplus.ca/v1";
// Dialpad sends the call event here when a call ends.
Bun.serve({
port: 8080,
async fetch(req) {
const event = await req.json(); // Check the Dialpad signature first.
if (event.state !== "hangup" && event.state !== "missed" && event.state !== "voicemail") {
return new Response("ignored");
}
const res = await fetch(`${API}/calls`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
direction: event.direction === "inbound" ? "inbound" : "outbound",
provider: "Dialpad",
external_id: String(event.call_id),
from: event.direction === "inbound" ? event.external_number : event.internal_number,
to: event.direction === "inbound" ? event.internal_number : event.external_number,
started_at: new Date(event.date_started).toISOString(),
duration_seconds: Math.round((event.duration ?? 0) / 1000),
status: event.state === "hangup" ? "completed" : event.state,
}),
});
// A 5xx or a 429 tells Dialpad to send the event again. The external_id makes that safe.
return new Response(null, { status: res.ok ? 200 : 503 });
},
});
```
## Read the call log
- `GET /v1/calls` lists the calls the broker can see, newest first. Filter by `contact_id`,
`deal_id`, or `direction`, and use `updated_since` to sync (see [Sync](/sync)).
- `GET /v1/calls/{id}` returns one call.
- Subscribe to `call.logged` to hear about each new call. See [Webhooks](/webhooks).
The log includes calls on BrokerPlus lines and from connected phone systems; `provider` tells you
where each one came from.
## Read recorded meetings
Video meetings the BrokerPlus notetaker sat in, and Roam meetings, are on the Meetings tab, apart
from the call log.
- `GET /v1/meetings` lists them, newest first, with BrokerPlus's `summary`. Filter by `deal_id` or
`contact_id`, and use `updated_since` to sync.
- `GET /v1/meetings/{id}` adds the transcript line by line, who was in the meeting and how long each
person spoke, BrokerPlus's follow-ups in `action_items`, and the video platform's own summary
when it writes one.
- `POST /v1/meetings` sends the notetaker into a Zoom, Google Meet or Microsoft Teams call, now or
at `join_at`, as the broker your key acts as. Name a `deal_id` to file the meeting on a deal.
Everybody in the call sees it join, so a key with the `no_sending` limit is refused it.
- `POST /v1/meetings/{id}/stop` takes the notetaker out of the call before it ends. A Roam meeting
is stopped from inside Roam.
- `POST /v1/meetings/{id}/sync` asks the video platform for the transcript and summary again, for a
meeting that ended without them. They land on the meeting within a few minutes.
The recording itself is not on the API.
## Call lists
A call list is a round of calls the broker works one row at a time. Each row is a person to call,
with the outcome of the call and a note. BrokerPlus calls nobody and sends nothing: the list records
what the broker did.
- `POST /v1/call-lists` makes a list from contacts or deals. A deal puts its primary applicant on the
list.
- `POST /v1/call-lists/import` makes a list from rows of names, numbers, and emails, for example a
spreadsheet you read yourself. Each row is matched to a contact by email, then by number. A row
that matches nobody stays on the list only, unless you send `add_to_contacts: true`.
- `POST /v1/call-lists/{id}/entries` adds contacts, deals, or people typed in by name. A person
already on the list keeps their outcome and note.
- `GET /v1/call-lists/{id}/entries` lists the rows, in the order the broker works them. Filter by
`status`.
- `PATCH /v1/call-lists/{id}/entries/{entry_id}` records an outcome: `called`, `no_answer`,
`follow_up`, `done`, or back to `pending`. Any outcome can follow any other.
- `DELETE /v1/call-lists/{id}/entries/{entry_id}` takes a person off the list, and
`DELETE /v1/call-lists/{id}` deletes the list. The contacts stay.
```bash
curl https://api.mos.getbrokerplus.ca/v1/call-lists/cmlist0001/entries/cmentry0001 \
-X PATCH \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "follow_up", "note": "Call back after the 15th." }'
```
```json
{
"success": true,
"data": {
"object": "call_list_entry",
"id": "cmentry0001",
"call_list_id": "cmlist0001",
"contact_id": "cmcontact0001",
"deal_id": null,
"name": "Sam Client",
"email": "sam@example.com",
"phone": "+14165550134",
"status": "follow_up",
"note": "Call back after the 15th.",
"called_at": "2026-10-09T15:00:00.000Z",
"called_by_user_id": "cmuser0001",
"called_by_name": "Alex Broker",
"created_at": "2026-10-08T14:00:00.000Z",
"updated_at": "2026-10-09T15:00:00.000Z"
}
}
```
`called_at` and `called_by_user_id` record when the row first left `pending` and who worked it. They
stay when the outcome changes again, and clear when the row goes back to `pending`.
## Call scripts
A call script is the list of questions a broker reads on a call, in sections. It is the same script
the broker sees in the Scripts pane. A question fills nothing in the application: it is a question
to ask, with an optional note for whoever runs the call.
- `GET /v1/call-scripts` lists the scripts, and `GET /v1/call-scripts/{id}` returns one.
- `POST /v1/call-scripts` makes a script on the broker's team.
- `PATCH /v1/call-scripts/{id}` changes the fields you send. `sections` replaces every section.
- `DELETE /v1/call-scripts/{id}` retires a script. A call that already used it still names it.
```bash
curl https://api.mos.getbrokerplus.ca/v1/call-scripts \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Renewal check-in",
"estimated_minutes": 10,
"sections": [
{
"key": "today",
"label": "Where they are today",
"questions": [
{ "key": "lender", "prompt": "Who is your mortgage with now?" },
{ "key": "plans", "prompt": "Any plans to move in the next five years?", "note": "Ask about a cottage too." }
]
}
]
}'
```
```json
{
"success": true,
"data": {
"object": "call_script",
"id": "cmscript0001",
"name": "Renewal check-in",
"description": null,
"estimated_minutes": 10,
"sections": [
{
"key": "today",
"label": "Where they are today",
"description": null,
"questions": [
{ "key": "lender", "prompt": "Who is your mortgage with now?", "note": null },
{ "key": "plans", "prompt": "Any plans to move in the next five years?", "note": "Ask about a cottage too." }
]
}
],
"created_by_user_id": "cmuser0001",
"created_at": "2026-10-09T15:00:00.000Z",
"updated_at": "2026-10-09T15:00:00.000Z"
}
}
```
## Errors
| Status | Code | What it means |
| ------ | ------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `400` | `VALIDATION_ERROR` | Fix the fields in `details.fieldErrors`. A call needs a `contact_id`, a `deal_id`, or the client's number. |
| `404` | `NOT_FOUND` | This key can't write to that `contact_id` or `deal_id`. |
| `409` | `CALL_EXISTS` | The call is already in the log of a broker whose calls this key can't read. Don't send it again. |
Every other code is on [Errors](/errors).
---
## Document: Authentication and keys
API keys, the limits you can put on a key, signing in an assistant without a key, and the rate limit.
URL: /authentication
# Authentication and keys
Every request carries an API key in the `Authorization` header, as a bearer token:
```bash title="curl"
curl https://api.mos.getbrokerplus.ca/v1/me \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```ts title="TypeScript"
const res = await fetch("https://api.mos.getbrokerplus.ca/v1/me", {
headers: { Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}` },
});
```
```python title="Python"
import os
import requests
res = requests.get(
"https://api.mos.getbrokerplus.ca/v1/me",
headers={"Authorization": f"Bearer {os.environ['BROKERPLUS_API_KEY']}"},
)
```
A missing, malformed, or revoked key, or a key whose broker was deactivated, all get the same
answer. The message is identical on purpose, so someone guessing keys learns nothing from it.
```json
{ "success": false, "error": { "code": "UNAUTHORIZED", "message": "Invalid API key" } }
```
## Create a key
An admin of the brokerage creates keys.
1. In BrokerPlus, go to **Settings > Developer**.
2. Select **New key**, name it after the integration that will use it, and pick any
[limits](#limits-on-a-key).
3. Copy the key. BrokerPlus stores only a hash, so this is the one time you can see it.
A live key starts with `bp_live_`. A [sandbox](/sandbox) key starts with `bp_test_`. A brokerage
can have ten live keys at a time; revoke one to make an eleventh. If you lose a key, revoke it and
make a new one.
## What a key can do
There are no scopes. **A key acts as the broker who created it**, on the team they were working in
when they created it. It sees what that broker sees and can change what that broker can change,
and nothing more. If the broker's access shrinks, so does the key's. Keys made by a view-only
member can read, and every write returns `403 VIEW_ONLY`.
So protect a key the way you would protect that broker's password:
- Keep keys on a server. Never ship one in a browser bundle, a mobile app, or a public repository.
- Give each integration its own key, so you can revoke one without breaking the others.
- Revoke a key the moment you think it leaked. Revocation applies to the very next request.
`GET /v1/me` shows the brokerage, the broker, the team, and the limits of the key you are using.
Call it first when something does not behave the way you expect.
## Limits on a key
A limit takes access away from a key. No limit adds access, so a limited key never does more than
its broker. You choose limits when you create the key, and you cannot change them later: revoke the
key and make another.
| Limit | What the key cannot do |
| -------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `read_only` | Anything but `GET`. |
| `leads_only` | Anything outside the lead endpoints, `GET /v1/me`, and `GET /v1/custom-fields`. It also cannot convert a lead to a deal, or change the lead stages, lead channels, or referral partners. |
| `no_sending` | Anything that puts something in front of a client: a message, a booking, a campaign, an intake email, an equity report, documents to sign. Without this limit, those requests go out at once. See [Reaching clients](/reaching-clients). |
| `no_documents` | Any document endpoint, and message attachments. |
Pick the smallest set that works:
| Integration | Limits |
| --------------------------------------------- | -------------- |
| A website lead form | `leads_only` |
| A report, a dashboard, or a warehouse sync | `read_only` |
| An assistant that drafts work for the broker | `no_sending` |
| Anything that must not contact clients | `no_sending` |
| Anything that must not see client files | `no_documents` |
A request must pass every limit on the key. A refused request returns `403`, with the limit in
`details.limit`:
```json
{
"success": false,
"error": {
"code": "KEY_LIMITED",
"message": "This API key can only reach leads. Use a key without the leads-only limit for this request",
"details": { "limit": "leads_only" }
}
}
```
A `read_only` key gets `READ_ONLY_KEY` instead, without `details`. Limits also apply to
[webhooks](/webhooks): a key cannot subscribe to events it could not read.
## Connect an assistant without a key
Claude on the web, ChatGPT, and other apps that support MCP connectors don't need a key. They sign
in to BrokerPlus with OAuth 2.1, and the broker picks the team and whether the app can only read.
The connection then acts exactly like a key with the same team and read-only choice. It shows up
with the keys in **Settings > Developer**, and revoking it there disconnects the app.
See [Run an AI assistant](/mcp) for the steps.
## Rate limit
Each key can make **600 requests a minute**. Every response tells you where you stand:
| Header | Meaning |
| --------------------- | ------------------------------------------------------------- |
| `RateLimit-Limit` | Always `600`. |
| `RateLimit-Remaining` | Requests left in the current window. |
| `Retry-After` | Seconds until the window resets. Sent only with a `429`. |
Over the limit, the API returns `429 RATE_LIMITED`. Wait `Retry-After` seconds and send the request
again.
The window is fixed, not rolling. It starts at your first request and resets 60 seconds later. Each
item in a [batch write](/idempotency#batch-writes) counts as one request. If a sync keeps hitting the
limit, read only what changed: see [Staying in sync](/sync).
---
## Document: Ask a client for something
Request documents, explain conditions, send documents to sign, and send generated letters, each in the broker's name.
URL: /client-asks
# Ask a client for something
This page covers the four things you can ask of the people on a deal: send documents, understand
their conditions, sign forms, and read a letter such as an approval letter. Each request does exactly
what the same button does in BrokerPlus.
Each request goes out now, as the broker your key acts as. A key with the `no_sending` limit can't
make these requests (`403 KEY_LIMITED`). If that broker may not contact clients, you get
`403 FORBIDDEN`. If BrokerPlus can't send, you get `409 NOT_SENT`, and nothing goes out.
| Request | Response |
| ------------------------------------------------------------- | --------------------------------- |
| `POST /v1/deals/{id}/document-requests` | `201` with the document requests |
| `POST /v1/deals/{id}/condition-requests` | `201` with the draft it went out |
| `POST /v1/deals/{id}/signing-documents/send` | `200` with the signing documents |
| `POST /v1/deals/{id}/signing-documents/{documentId}/resend` | `200` with the signing document |
| `POST /v1/deals/{id}/generated-documents/{documentId}/send` | `201` with the draft it went out |
## Request documents
`POST /v1/deals/{id}/document-requests` is the Request Documents window in BrokerPlus. Each document
is added to the client's portal now, and each applicant it is for gets an email with their own link.
The deal needs a client portal, which the client gets when they send their intake back. Without one
the request returns `409 NO_CLIENT_HUB`, so send an [intake](/intake) first.
Document codes come from `GET /v1/document-types`. Set `contact_id` on a document to ask one
applicant, or leave it out to ask every applicant on the portal.
```bash
curl -X POST "https://api.mos.getbrokerplus.ca/v1/deals/cmsdl4k2p/document-requests" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"documents": [
{ "document_type": "t4", "note": "The last two years." },
{ "document_type": "paystub", "contact_id": "cmsclk2a9", "required": false }
],
"message": "Hi Sam, these are the last items the lender needs."
}'
```
```json
{
"success": true,
"data": [
{
"object": "document_request",
"id": "cmsdi9t3q0004:doc_paystub:cmsclk2a9",
"label": "Pay stub",
"document_type": "paystub",
"required": false,
"contact_id": "cmsclk2a9",
"contact_name": "Sam Client",
"is_primary_applicant": false,
"rejected": false,
"waiting_on_intake": false,
"requested_at": "2026-10-07T14:20:00.000Z"
}
],
"pagination": { "total": 3, "page": 1, "page_size": 3, "total_pages": 1 }
}
```
The response is `201` and is shortened. It has one line for each document and each applicant this
request asked: here the T4 for both applicants and the pay stub for Sam.
`GET /v1/deals/{id}/document-requests` lists everything the client still owes.
A wrong `document_type` or `contact_id` returns `422 VALIDATION_ERROR`, and `details.fields` names
each one, for example `documents.1.contact_id`.
### Ask from a request template
Send `template_id` to ask for the documents on one of the broker's
[request templates](#request-templates). Each document goes to the applicants the template names for
it, and you can add more in `documents` in the same request.
```bash
curl -X POST "https://api.mos.getbrokerplus.ca/v1/deals/cmsdl4k2p/document-requests" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "template_id": "cmsrt0a1b0002", "message": "Hi Sam, here is what the lender needs." }'
```
The template must ask only for documents. A template that also has questions, a form to sign, or an
item that shows only for some answers returns `422 TEMPLATE_NOT_SENDABLE`: send it from BrokerPlus.
## Explain conditions to somebody on the deal
`POST /v1/deals/{id}/condition-requests` writes a plain-language letter about a deal's conditions.
It is Request From Participant on the deal's Conditions tab in BrokerPlus. A borrower gets a request
for what the lender needs. A professional on the deal, such as a realtor or a lawyer, gets an update
on the file.
Take the conditions from `GET /v1/deals/{id}/conditions` and the person from
`GET /v1/deals/{id}/participants`. They need an email address.
```bash
curl -X POST "https://api.mos.getbrokerplus.ca/v1/deals/cmsdl4k2p/condition-requests" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "condition_ids": ["cmsdc0n1d0007"], "contact_id": "cmsclk2a9" }'
```
```json
{
"success": true,
"data": {
"object": "draft",
"id": "cmsd0dr7k0010",
"contact_id": "cmsclk2a9",
"deal_id": "cmsdl4k2p",
"channel": "email",
"status": "sent",
"subject": "One document we need for your mortgage",
"communication_id": "cmsd0cm2v0011"
}
}
```
The response is shortened. The letter goes out now, from the broker's connected email. BrokerPlus
sends it through a draft in the broker's name, so the broker sees it in their drafts folder and in
Sent. The response is `201` with that draft. To write your own words, send `subject` and `body`;
the letter goes out exactly as written.
## Send documents to sign
A deal's signing package is the set of forms people on the deal sign, such as a consent form. Each
document is for one person. It is the Signing Package on the deal's Compliance tab in BrokerPlus.
1. List the presets (saved sets of documents) with `GET /v1/deals/{id}/signing-presets`.
2. Add documents with `POST /v1/deals/{id}/signing-documents`: the signer's `contact_id`, plus a
`preset_id` or one `requirement_id`. Adding sends nothing.
3. Preview one with `POST /v1/deals/{id}/signing-documents/{documentId}/preview`. You get the filled
PDF in `pdf_base64`, or `422` if it can't be sent as it is.
4. Send with `POST /v1/deals/{id}/signing-documents/send`. BrokerPlus fills in each document again
from the deal as it is now, checks it, and emails each signer one link to their documents. The
response is `200` with the documents, each with `status` `sent`.
```bash
curl -X POST "https://api.mos.getbrokerplus.ca/v1/deals/cmsdl4k2p/signing-documents" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "contact_id": "cmsclk2a9", "preset_id": "cmsdp2r7s0003" }'
curl -X POST "https://api.mos.getbrokerplus.ca/v1/deals/cmsdl4k2p/signing-documents/send" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "document_ids": ["cmsdo1b8n0006"], "note": "Two short forms for your file." }'
```
`GET /v1/deals/{id}/signing-documents` shows where each signing is in `status`: `not_sent`, `sent`,
`viewed`, `borrower_signed`, `signed`, `declined`, `voided`, or `failed`.
If a signer lost the email, `POST /v1/deals/{id}/signing-documents/{documentId}/resend` sends them a
new link now. The response is `200` with the document.
If a document can't be filled in or checked, you get `409 NOT_SENT`, and nothing is sent. A signing
can't be canceled through the API.
## Send a generated document
A generated document is a letter BrokerPlus makes from a template, such as an approval letter with
the brokerage's logo and the broker's signature. Making one sends nothing.
1. List the templates with `GET /v1/document-templates`.
2. Make the document with `POST /v1/deals/{id}/generated-documents`, with `contact_id` set to the
person it is addressed to. BrokerPlus files the PDF on the deal and returns `status` `final` and
its `document_id`.
3. Send it with `POST /v1/deals/{id}/generated-documents/{documentId}/send`. BrokerPlus emails it
now, from the broker's connected email, with the PDF attached. The response is `201` with the
draft it went out through, with `status` `sent`.
```bash
curl -X POST "https://api.mos.getbrokerplus.ca/v1/deals/cmsdl4k2p/generated-documents" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "template_id": "cmsdt3m1p0002", "contact_id": "cmsclq7w3" }'
curl -X POST "https://api.mos.getbrokerplus.ca/v1/deals/cmsdl4k2p/generated-documents/cmsdg8k4r0005/send" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "contact_id": "cmsclk2a9" }'
```
If the broker has to fix something first, such as a missing signature, the document stays a `draft`
and `issues` says what. A draft can't be sent (`409 DOCUMENT_NOT_FINAL`) until the broker fixes it in
BrokerPlus.
To write your own email, send `subject` and `body`. A key with the `no_documents` limit can't use
generated documents at all.
## Ask a client for a score
A Net Promoter Score (NPS) survey asks one question: how likely is the client to recommend the
broker, from 0 to 10. `POST /v1/nps-surveys` emails it to a client now, from the broker's connected
email. Send `email` to use an address other than the one on the contact; that address replaces the
contact's email, as it does in BrokerPlus.
```bash
curl -X POST "https://api.mos.getbrokerplus.ca/v1/nps-surveys" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "contact_id": "cmsclq7w3", "deal_id": "cmsdl4k2p", "message": "Thanks for trusting us with your mortgage." }'
```
The response is `201` with the survey. After the client answers,
`GET /v1/nps-surveys?responded_since=2026-10-01T00:00:00Z` lists each answer with its `score`,
`band` (`detractor`, `passive`, or `promoter`), and `comment`. The brokerage's
[NPS alert rules](/workspace#nps-alert-rules) decide who hears about each score.
A client with no email address and no `email` gets `400 NO_EMAIL`. A broker with no email
connected gets `412 EMAIL_NOT_CONNECTED`.
## Ask a client to declare PEP status
FINTRAC asks the broker to find out if a client is a politically exposed person (PEP), a family
member of one, or the head of an international organization. The PEP declaration form asks the
client and records the client's signature. `POST /v1/pep-forms` emails the form to a client now,
from the broker's connected email. The form goes to the email address on the contact, and the link
works for 30 days.
```bash
curl -X POST "https://api.mos.getbrokerplus.ca/v1/pep-forms" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "contact_id": "cmsclq7w3", "application_number": "APP-20431" }'
```
The response is `201` with the form. `GET /v1/pep-forms?status=submitted` lists the
forms that came back, with `is_pep` and `pep_type`. Only the broker who sent the form and a
compliance reviewer get the other answers, in `answers`, as in BrokerPlus.
Only the broker assigned to the client, or an admin, can send the form (`403 FORBIDDEN`). A client
with no email address gets `400 MISSING_EMAIL`, and a client with no first or last name gets
`400 MISSING_NAME`.
## Ask a borrower for consent
A borrower who did not fill in an intake has not given the consents that the intake collects.
`POST /v1/deals/{id}/consent-forms` emails a borrower on the deal a form for one `scope` now, for
example `credit_pull` or `privacy_processing`. The borrower signs by typing their name. Send `email`
to use an address other than the one on the contact; that address replaces the contact's email, as
it does in BrokerPlus.
```bash
curl -X POST "https://api.mos.getbrokerplus.ca/v1/deals/cmsdl4k2p/consent-forms" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "contact_id": "cmsclq7w3", "scope": "credit_pull" }'
```
The response is `201` with a `consent_request`. Its `kind` is `consent_form`, and its `id` is the
form. When the borrower signs, the form's `status` is `submitted`, and `consent_id` names the consent
that `GET /v1/deals/{id}/consents` now lists. For `credit_pull`, when the brokerage uploaded its own
credit consent form in BrokerPlus, the borrower gets that form to sign in an intake instead. Then
`kind` is `intake`, `id` is the intake, and no consent form is listed.
A borrower with no email address and no `email` gets `400 NO_EMAIL`. A broker with no email
connected gets `412 EMAIL_NOT_CONNECTED`.
## Request templates
A request template is a list of things to ask a client for: answers, documents, and forms to sign.
The broker keeps them in the request library in BrokerPlus, and a team shares its broker's library.
Building a template sends nothing to anyone. To ask a client for the documents on one, send its
`template_id` to [`POST /v1/deals/{id}/document-requests`](#ask-from-a-request-template).
| Request | What it does |
| ------------------------------------------------------ | -------------------------------------------------------------------- |
| `GET /v1/request-templates` | Lists the library. |
| `GET /v1/request-templates/{id}` | Reads one template, with its items in order. |
| `POST /v1/request-templates` | Adds an empty template. |
| `PATCH /v1/request-templates/{id}` | Renames it, moves it to a shelf, or turns its public link on or off. |
| `DELETE /v1/request-templates/{id}` | Removes it from the library. |
| `POST /v1/request-templates/{id}/duplicate` | Copies it, with all its items. |
| `POST /v1/request-templates/{id}/favourite` | Stars it for the broker your key acts as. `unfavourite` removes it. |
| `POST /v1/request-templates/{id}/reset` | Puts a template that BrokerPlus supplies back the way it came. |
| `POST /v1/request-templates/{id}/items` | Adds an item. |
| `PATCH /v1/request-templates/{id}/items/{itemId}` | Changes an item. |
| `DELETE /v1/request-templates/{id}/items/{itemId}` | Removes an item. |
| `POST /v1/request-templates/{id}/items/reorder` | Sets the order of the items. |
| `POST /v1/request-templates/{id}/items/{itemId}/reset` | Puts one item back the way BrokerPlus wrote it. |
Make a template, then add a document to ask for. A `doc_request` needs a `document_type` from
`GET /v1/document-types`:
```bash
curl -X POST "https://api.mos.getbrokerplus.ca/v1/request-templates" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Self-employed renewal", "category": "self_employed" }'
curl -X POST "https://api.mos.getbrokerplus.ca/v1/request-templates/cmsrt0a1b0002/items" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "key": "doc_noa", "kind": "doc_request", "label": "Notice of assessment", "document_type": "noa" }'
```
A changed template applies to the next request sent from it. Requests that were sent before do not
change. To add a form to sign, use BrokerPlus: the form needs its PDF.
---
## Document: Pagination
How to page through a list, and the order each list comes back in.
URL: /pagination
# Pagination
Every list returns one page at a time, with a `pagination` object that says where you are:
```json
{
"success": true,
"data": [/* ... */],
"pagination": { "total": 412, "page": 1, "page_size": 50, "total_pages": 9 }
}
```
Two query parameters pick the page:
| Parameter | Default | Notes |
| ----------- | ------- | ------------------------------------------------------------------ |
| `page` | `1` | Pages start at `1` and go up to `1000000`. |
| `page_size` | `50` | Up to `100`. A larger value is treated as `100`, not refused. |
A `page` outside that range, or a `page` or `page_size` that is not a whole number, returns `400`
`VALIDATION_ERROR` naming the parameter.
```bash title="curl"
curl "https://api.mos.getbrokerplus.ca/v1/contacts?page=2&page_size=100" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```ts title="TypeScript"
const res = await fetch("https://api.mos.getbrokerplus.ca/v1/contacts?page=2&page_size=100", {
headers: { Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}` },
});
const { data, pagination } = await res.json();
```
```python title="Python"
import os
import requests
res = requests.get(
"https://api.mos.getbrokerplus.ca/v1/contacts",
params={"page": 2, "page_size": 100},
headers={"Authorization": f"Bearer {os.environ['BROKERPLUS_API_KEY']}"},
)
body = res.json()
data, pagination = body["data"], body["pagination"]
```
Page numbers are for looking at a list. **To keep a copy of a list up to date, don't page through it
again and again.** Use `updated_since` with `after_id`, or the change feed, as
[Staying in sync](/sync) explains. A row that changes while you page moves, and page numbers then
skip it.
The change feed (`GET /v1/changes`) pages with a checkpoint instead of a page number. Its
`pagination` holds `next_checkpoint` and `has_more`.
## The order of a list
The main record lists (contacts, leads, deals, submissions, intakes, and tasks) come newest first,
by when they were created. With `updated_since`, every list switches to oldest change first. The
other lists follow the order BrokerPlus shows:
| List | Order |
| --------------------------------- | ------------------------------------- |
| Appointments | By start time, earliest first. |
| Pipelines, tags, meeting types | The order the broker set. |
| Task notes | Pinned first, then newest first. |
| Teams | Oldest first. |
| Team members | By role, then by start date. |
| Lenders | By name. |
| Opportunities | Most recently changed first. |
| Communications, users, consents | Newest first. |
Don't rely on a position in a list to find a record. Read it by `id`.
---
## Document: Run an intake
Send a client the application they fill in themselves, follow their progress, read the answers, and approve it or ask for changes.
URL: /intake
# Run an intake
An intake is the mortgage application a client fills in themselves. Over the API you can hand out a
link to start one, watch each applicant's progress as they type, read every answer, and approve the
intake or send it back for changes.
## The two links
| | The standing link | A client's link |
| -------------- | ----------------------------------------------- | -------------------------------------------------- |
| Who it is for | Anybody. A website, an email signature, a card. | One named client. |
| How many | One for each broker. | One for each client, and more on request. |
| Expires | Never. | Yes, and opening it moves the date later. |
| Readable later | Yes, `GET /v1/intake-link`. | **No.** BrokerPlus keeps only a fingerprint of it. |
Because a client's link can't be read back, "send me the link I mailed her last week" has no answer.
Issue a new one instead: every link issued for the same intake works until it expires.
## The standing link
```sh
curl https://api.mos.getbrokerplus.ca/v1/intake-link \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```json
{
"success": true,
"data": { "enabled": true, "url": "https://mos-portal.getbrokerplus.ca/i/alex-broker-3f9c21a8" }
}
```
`url` is `null` until the link has been turned on once. To turn it on or off:
```sh
curl -X PATCH https://api.mos.getbrokerplus.ca/v1/intake-link \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "enabled": true }'
```
Turning it off stops new intakes but keeps the URL, so a link already printed on a card works again
the moment you turn it back on.
## Ask one client
The client must already be a contact. A deal is optional: leave `deal_id` out for someone who isn't
on a file yet, and BrokerPlus opens the deal when they send the intake back.
```sh
curl -X POST https://api.mos.getbrokerplus.ca/v1/intakes \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "contact_id": "cmscllt4h01xyp3iuq7dk2m8z", "delivery": "link" }'
```
```json
{
"success": true,
"data": {
"object": "intake",
"id": "cmsctw7kb0001p3x3zpyhkjv3",
"contact_id": "cmscllt4h01xyp3iuq7dk2m8z",
"deal_id": null,
"co_applicant_contact_ids": [],
"status": "in_progress",
"delivered_at": null,
"first_opened_at": null,
"last_activity_at": "2026-10-01T15:04:00.000Z",
"submitted_at": null,
"approved_at": null,
"created_at": "2026-10-01T15:04:00.000Z",
"updated_at": "2026-10-01T15:04:00.000Z",
"applicants": [
{
"contact_id": "cmscllt4h01xyp3iuq7dk2m8z",
"name": "Jordan Client",
"role": "primary",
"submitted_at": null,
"answered": 4,
"total": 31,
"unanswered": [
{
"section_key": "goal",
"section_label": "Goal",
"questions": [{ "key": "loan_purpose", "label": "Loan purpose" }]
}
]
}
],
"url": "https://mos-portal.getbrokerplus.ca/qz8...",
"url_expires_at": "2027-03-30T15:04:00.000Z"
}
}
```
`delivery` decides how the client gets it:
- `link` (the default) returns the link in `url` and sends nothing. You deliver it yourself.
- `email` has BrokerPlus email each applicant their link now, from the broker's own mailbox. The
response has `url` set to `null`. If the broker's mailbox isn't connected, the request returns
`412 EMAIL_NOT_CONNECTED`. A key with the `no_sending` limit can't use `email`.
Add a second applicant with `co_applicant_contact_ids`. Each one gets their own link, and
`POST /v1/intakes/{id}/link?contact_id=...` issues another for one of them.
If the client already has an open intake, the request returns `409 DUPLICATE_INTAKE`. Find it with
`GET /v1/intakes?contact_id=...` instead of creating a second.
## Issue another link
```sh
curl -X POST https://api.mos.getbrokerplus.ca/v1/intakes/cmsctw7kb0001p3x3zpyhkjv3/link \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```json
{
"success": true,
"data": {
"url": "https://mos-portal.getbrokerplus.ca/h2k...",
"expires_at": "2027-03-30T15:40:00.000Z"
}
}
```
**The link is the client's credential.** Anyone who holds it can act as them, so send it to the
client and nowhere else.
## Follow an intake
```sh
curl "https://api.mos.getbrokerplus.ca/v1/intakes?status=submitted&updated_since=2026-09-30T00:00:00Z" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
`status` is `in_progress`, `submitted`, `approved`, or `needs_changes`. The timestamps tell the rest:
`delivered_at` is when the client first had it, `first_opened_at` when they first looked,
`submitted_at` when they sent it back, and `approved_at` when the broker accepted it.
Filtering by `contact_id` finds intakes where that person is a co-applicant, as well as their own.
To be told instead of polling, listen for the `intake.started` and `intake.submitted`
[webhooks](/webhooks).
## See each applicant's progress
Every intake has `applicants`: the primary client first, then each co-applicant. Answers save as the
client types, so progress moves before anyone submits.
```json
{
"contact_id": "cmscllt4h01xyp3iuq7dk2m8z",
"name": "Jordan Client",
"role": "primary",
"submitted_at": null,
"answered": 18,
"total": 31,
"unanswered": [
{
"section_key": "income",
"section_label": "Income",
"questions": [{ "key": "employments", "label": "Employment" }]
}
]
}
```
- `answered` and `total` count required questions only, the same count the client sees.
- `total` can move. Some answers add questions and some remove them: a client who refinances gets no
down payment questions, for example.
- `unanswered` lists each section that still has a required question with no answer.
- A question for the whole household, such as the property or the lawyer, belongs to the primary
client. A co-applicant doesn't owe it.
- An applicant's `submitted_at` is when that applicant sent their part. The intake's `submitted_at` is
when the primary client sent the whole application.
## Read the answers
```sh
curl https://api.mos.getbrokerplus.ca/v1/intakes/cmsctw7kb0001p3x3zpyhkjv3/answers \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```json
{
"success": true,
"data": {
"intake_id": "cmsctw7kb0001p3x3zpyhkjv3",
"status": "submitted",
"applicants": [
{
"contact_id": "cmscllt4h01xyp3iuq7dk2m8z",
"name": "Jordan Client",
"role": "primary",
"submitted_at": "2026-10-03T19:12:00.000Z",
"sections": [
{
"key": "goal",
"label": "Goal",
"answers": [
{
"key": "loan_purpose",
"label": "Loan purpose",
"kind": "field",
"required": true,
"answered": true,
"value": "3",
"value_label": "Purchase",
"withheld": false
}
]
}
]
}
]
}
}
```
You get every question each applicant is asked, answered or not, the same view the broker has in
BrokerPlus. Answers are live: before the intake is submitted you see what the client has filled in so
far, so check `submitted_at` before you treat an answer as final.
- `value` is the answer as BrokerPlus stores it. For a choice question, `value` is the option code
and `value_label` is the option name.
- A `repeater` answer is a list of rows, for example one row for each job.
- A `consent` or `signature` answer is `true` or `false`.
- The API never returns the Social Insurance Number (SIN). That answer has `withheld: true` and
`value: null`, and `answered` tells you whether the client gave one.
## Read what the client said on a call
When a client answers intake questions on a call or in an email, BrokerPlus keeps each answer and
fills it in on the next intake the client is sent. `GET /v1/contacts/{id}/captured-answers` lists
them. Add `pending=true` for the answers still waiting for an intake: that is what the next one
will carry.
```json title="Response (shortened)"
{
"success": true,
"data": [
{
"object": "captured_answer",
"id": "cmscap81k",
"contact_id": "cmscllt4h01xyp3iuq7dk2m8z",
"intake_key": "annual_income",
"value": "90000",
"heard_as": "about ninety thousand",
"source": "call",
"communication_id": "cmscom2b1",
"occurred_at": "2026-10-01T15:00:00.000Z",
"applied_at": null
}
]
}
```
`heard_as` is what the client said, word for word. The client sees it beside the filled-in answer.
These answers are read-only.
## Read what the client was asked for
```sh
curl https://api.mos.getbrokerplus.ca/v1/intakes/cmsctw7kb0001p3x3zpyhkjv3/items \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
An item is one thing on the client's portal other than a question: a document, a form to sign, a
consent, an identity check, or where the down payment comes from. The questions are on the answers
endpoint. Each item has a `key` and lists the applicants it is for. For each applicant you get:
- `doc_status`: where the checks on their file are, for example `verified` or `rejected`.
- `broker_stage`: the tray the broker put it in on the document board.
- `submitted_at`, and `dismissed_at` with `dismiss_reason` when they said they can't provide it.
- `uploads`: the files they sent for it, newest first.
An item that is only for one applicant, or that an earlier answer ruled out, lists only the people it
is for. For only what the client still owes on a deal, use `GET /v1/deals/{id}/document-requests`.
`GET /v1/intakes/{id}/uploads` lists every file the client sent. `GET /v1/intakes/{id}/uploads/{uploadId}`
returns one with a download URL that works for five minutes. A file the client replaced is not
listed. A key with the `no_documents` limit can't reach items or files.
## Accept a file
```sh
curl -X POST https://api.mos.getbrokerplus.ca/v1/intakes/cmsctw7kb0001p3x3zpyhkjv3/uploads/cmsu1a2b30001p3x3k9q8w7e6/stage \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "stage": "verified", "folder": "income" }'
```
This is a drag on the broker's document board. `verified` accepts the file: BrokerPlus reads it and
copies it to the deal's documents, and `document_id` on the file then names the copy. `pending` and
`review` take it back. Nothing goes to the client.
To accept an item that has no file, for example a document the broker already has, send
`POST /v1/intakes/{id}/items/{key}/stage` with the applicant's `contact_id` and the `stage`.
To refuse a file, ask for changes with `uploads`, below.
## Review what the checks raised
`GET /v1/intakes/{id}/flags` lists what BrokerPlus's checks found on the intake, the most serious
first: an answer that disagrees with a document, a gap in income, a file that may not be genuine.
Filter with `status`: `active`, `resolved`, `overridden`, or `stale`. A flag with `audience`
`never_disclose` is for the broker only. Never pass it on to the client.
```sh
curl -X POST https://api.mos.getbrokerplus.ca/v1/intakes/cmsctw7kb0001p3x3zpyhkjv3/flags/cmsv7c2d10001p3x3m4n5b6v7/override \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "reason": "Explained by the employer letter on file." }'
```
This is **Override** on the flag in BrokerPlus. The flag becomes `overridden`, and the reason is kept on
it and in the audit log. The reason needs at least 10 characters. Nothing goes to the client.
## Write notes on an intake
Brokers can leave notes for each other on one file or one item of a client's intake. The client
never sees them. `GET /v1/intakes/{id}/notes` lists them, newest first, replies included; filter
with `upload_id` or `item_key`.
```sh
curl -X POST https://api.mos.getbrokerplus.ca/v1/intakes/cmsctw7kb0001p3x3zpyhkjv3/notes \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "item_key": "t4", "body": "Ask for the 2025 slip as well." }'
```
Send `upload_id` to put the note on a file, or `item_key` to put it on an item. For a co-applicant's
item, add their `contact_id`. To reply, send `parent_id` alone: the reply goes where the note it
answers is. A reply cannot be replied to. A teammate mentioned in the note is told, as in BrokerPlus.
A note cannot be changed or deleted afterwards.
## Shape the income calculation
When a client is self-employed, BrokerPlus works out their income from their bank statements. The
broker decides what that calculation counts, on the stated income card.
`GET /v1/intakes/{id}/stated-income-scope` reads those choices: the dates it counts, the statements
and bank accounts left out, and the yearly income the client declared. `accounts` lists every bank
account on the client's statements, each with an `account_key`.
```sh
curl -X PATCH https://api.mos.getbrokerplus.ca/v1/intakes/cmsctw7kb0001p3x3zpyhkjv3/stated-income-scope \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "date_range_start": "2025-10-01", "excluded_account_keys": ["RBC|9091"], "declared_annual_income": 85000 }'
```
A field you leave out keeps its value. `excluded_upload_ids` and `excluded_account_keys` replace the
whole list, so send `[]` to put everything back. BrokerPlus then works the income out again, which
takes a moment. Nothing goes to the client. An account number is only ever the last 4 digits.
### Take out or recategorize a transaction
`GET /v1/intakes/{id}/transactions` lists every transaction BrokerPlus read off the client's bank
statements, newest first. Each one says what BrokerPlus read it as (`category`, `subcategory`), what
the calculation counts it as (`effective_category`), and whether the broker took it out (`excluded`).
Filter with `q`, `upload_id`, `category`, `subcategory`, `applicant_role`, `date_from`, `date_to` and
`excluded`.
```sh
curl -X POST https://api.mos.getbrokerplus.ca/v1/intakes/cmsctw7kb0001p3x3zpyhkjv3/transactions/override \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "transaction_ids": ["cmsu1a2b30001p3x3k9q8w7e6:2026-02-09:950:2"], "excluded": true, "note": "Personal spending." }'
```
This changes up to 2000 transactions at once, as selecting rows on the stated income card does. A
field you leave out keeps each transaction's own value, so changing a category does not put back a
transaction the broker took out. An id you send that is not on the intake comes back in
`skipped_transaction_ids`. `DELETE /v1/intakes/{id}/transactions/{transactionId}/override` undoes the
broker's change. A transaction id contains `:`, so encode it in the path. Nothing goes to the client.
## Approve an intake
```sh
curl -X POST https://api.mos.getbrokerplus.ca/v1/intakes/cmsctw7kb0001p3x3zpyhkjv3/approve \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
This is **Approve** on the intake in BrokerPlus: BrokerPlus builds the submission package and locks
the answers. The response is the intake with `status` `approved`, and the client gets no message.
The intake must be `submitted` or `needs_changes`; otherwise the response is `409 WRONG_STATE`.
## Ask for changes
```sh
curl -X POST https://api.mos.getbrokerplus.ca/v1/intakes/cmsctw7kb0001p3x3zpyhkjv3/request-changes \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"message": "Two small fixes before I send this to the lender.",
"changes": [
{ "question_key": "date_of_birth", "comment": "This date is in the future." }
]
}'
```
This emails the client now. The intake becomes `needs_changes`, and each applicant with a reopened
answer gets an email with your `message` and a link to fix it, with your `comment` beside each
answer. The response is the intake. A key with the `no_sending` limit can't ask for changes.
- Use the `key` values from the answers endpoint for `question_key`.
- Set `contact_id` to reopen a co-applicant's answer. Leave it out for the primary client.
- The client must have submitted first. Before that the request returns `409 NOT_SUBMITTED`, because
every answer is still open to them.
- A key that isn't on the intake, or a contact who isn't an applicant, returns `422 VALIDATION_ERROR`
with each one named in `details.fields`.
To refuse a file the client sent, name it in `uploads` with an `upload_id` from the files list and a
`comment`. You can send `uploads` alone, or with `changes`. The client who sent each file gets your
comment and is asked for a new copy. The new copy replaces the refused one.
```json
{
"message": "One page is missing.",
"uploads": [{ "upload_id": "cmsu1a2b30001p3x3k9q8w7e6", "comment": "Please send the whole T4." }]
}
```
### Follow up on the changes you asked for
`GET /v1/intakes/{id}/change-requests` lists every answer and file you asked the client to fix, with
`status` `open` or `resolved`. Each one is `target` `answer`, with its `question_key`, or `upload`,
with its `upload_id`. None of these sends anything to the client:
- `PATCH /v1/intakes/{id}/change-requests/{crId}` with `comment` rewords it. The client sees the new
words the next time they open their link.
- `POST .../resolve` marks it done, and `POST .../reopen` puts it back to `open`.
- `DELETE /v1/intakes/{id}/change-requests/{crId}` withdraws it.
An AI assistant can read an intake, its items, its files, its flags and its change requests, move
items and files on the board, override a flag, and reword, resolve or reopen a change request. It can't download a file, approve an intake, ask for changes or withdraw one. Those are the broker's
decisions.
## What you can't do yet
- Change what an intake asks for.
- Revoke a link. It stops working on its own when it expires.
If you need either, tell us and we'll tell you where it is in the queue.
---
## Document: BrokerPlus API
Connect your website, your data warehouse, your old CRM, or an AI assistant to BrokerPlus. Start from the job you want done.
URL: /introduction
# BrokerPlus API
The BrokerPlus API gives your software the same book a broker works in BrokerPlus: leads,
contacts, deals and their mortgage applications, documents, tasks, meetings, messages, campaigns,
automations, and more, about 300 operations in all. It is JSON over HTTPS, with one API key.
```
https://api.mos.getbrokerplus.ca/v1
```
New here? Make your [first call](/quickstart) in two minutes, then pick the job below that matches
what you are building.
## Start from the job
Send every website form and ad lead to BrokerPlus once, matched to the person if they are already in the book.
Keep a copy of your contacts, leads, and deals in your own database or warehouse, without missing a change.
Connect an AI assistant to the book through our MCP server, so it can answer questions and draft work for the broker.
Move contacts, deals, notes, and custom fields across in batches of 100, with no duplicates if you run it twice.
Build the same automations as the Pulse builder: when a deal moves or a date comes up, email or text the right people.
Get a signed POST seconds after a lead arrives, a deal moves, or a document lands.
Build against a private brokerage full of sample data that records every email and text instead of sending it.
## What you can do
Every area below is in the [API reference](/reference), and in the [MCP server](/mcp) for
assistants.
| Area | What you can do | Read |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
| **Leads and contacts** | Create, find, update, merge, and archive people and companies, with addresses, extra emails and phones, consent, relationships, and tags. | [Capture leads](/capture-leads) |
| **Deals and pipelines** | Create, move, copy, archive, and restore deals. Set the team, participants, referral partner, commission, consents, flags, and checklist. | [How the objects fit together](/model) |
| **Mortgage application** | Read and write the loan terms, property, applicants, jobs, income, assets, debts, and other properties, one row at a time. | [The application](/model#applicants-and-financials) |
| **Documents** | List, upload, rename, verify, and delete files. Ask a client for documents, generate letters from templates, and send documents to sign. | [Documents](/files) |
| **Conditions and submissions** | Track what a lender still needs, satisfy or waive it, and read what went to each lender and what they decided. | [How the objects fit together](/model#conditions) |
| **Tasks and notes** | Create, assign, complete, and reopen tasks, one at a time or in bulk. Write notes on contacts, deals, and tasks. | [How the objects fit together](/model#tasks) |
| **Scheduling** | Read open times, book a client into a meeting type, and move or cancel the booking. Manage meeting types, hours, and blocked days. | [How the objects fit together](/model#scheduling) |
| **Messages, mail, and calls** | Read the inbox and call log, act on a conversation, log calls from another phone system, and ask the broker to send a message. | [Mail](/mail), [Log calls](/log-calls) |
| **Campaigns and automations** | Build audiences, campaigns, templates, and automations, and follow each run. | [Automate client follow-up](/automations) |
| **Intake** | Send a client the application to fill in, follow their progress, read the answers, and approve them or ask for changes. | [Run an intake](/intake) |
| **Equity reports and opportunities** | Track mortgages for renewal and refinance, and enroll past clients in recurring equity reports. | [Equity reports](/equity-reports) |
| **What the broker sees** | Read and act on suggestions and notifications, and read a deal's compliance checklist, credit summary, and activity trail. | [Broker view](/broker-view) |
| **Workspace setup** | Change custom fields, tags, pipelines and stages, lead stages and channels, referral partners, task categories, and the email signature. | [Configure your workspace](/workspace) |
| **People and reports** | Read brokers, teams, lenders, referral partners, the open pipeline, and funded volume. | [API reference](/reference) |
| **Change feed and webhooks** | Follow every change in order, and get a signed POST for 23 kinds of event. | [Staying in sync](/sync), [Webhooks](/webhooks) |
A request that reaches a client, such as an email, a text, a booking, or documents to sign, goes
out at once, as the broker. A key with the `no_sending` limit cannot make these requests. See
[Reaching clients](/reaching-clients).
## What is never in the API
Some things stay in the BrokerPlus app, where a person with the right role does them. No key can
reach them.
- **Submitting a deal to a lender.** You can read submissions and lender decisions, but only the
broker sends a deal.
- **Pulling credit.** You can read the summary of a credit report that is already on a deal, not
order one.
- **Billing**, plans, and payments.
- **Users and roles**: inviting people, changing roles, or minting API keys.
- **Integration credentials**, phone numbers, deal locks, and compliance overrides.
- **Sensitive identifiers**: social insurance numbers, liability account numbers, and ID documents
are never returned.
A liability that came from a credit report carries `from_credit_bureau: true`. Consumer report data
has its own redistribution rules, so check your obligations before you store or forward it.
## This API is in beta
**Any endpoint can change in a way that breaks your integration, without notice.** We publish an
endpoint when it is useful, not when it is frozen, which is why the surface is this wide. Build
defensively: ignore fields you don't recognize, and don't assume today's shape is next month's.
The [changelog](/changelog) records every change. It is the one page to watch. If you need a promise
that a shape will not move, talk to your account contact before you build on it.
## Docs for AI agents
Every page is also plain Markdown: add `.md` to its URL, for example
[`/introduction.md`](https://docs.brokerplus.ca/introduction.md).
[`/llms.txt`](https://docs.brokerplus.ca/llms.txt) indexes every page,
[`/llms-full.txt`](https://docs.brokerplus.ca/llms-full.txt) has them all in one file, and the
[OpenAPI description](https://docs.brokerplus.ca/openapi.json) has every endpoint and field. Give
an agent these URLs instead of the HTML pages.
---
## Document: Units and formats
How the API writes rates, money, dates, times, ids, and names, so you can do arithmetic on a field without guessing.
URL: /units
# Units and formats
Every field in the API follows the same handful of conventions. Learn them once here, and each
field in the [API reference](/reference) only has to say which one it uses.
## Rates and ratios are percents
A rate or a ratio is a plain number in percent: `5.25` means 5.25%, not 525%.
This covers interest rates, prime adjustments, loan-to-value (LTV), gross and total debt service
(GDS and TDS), insurance premium rates, the LTV on an equity report, and a referral partner's
`conversion_rate`.
```json
{ "interest_rate": 4.99, "ltv": 48.8, "gds": 31.2 }
```
Do not multiply by 100 before you show one, and do not divide by 100 before you send one. Some
older fields were decimal fractions, such as `0.0499`. They all changed to percents, and the
[changelog](/changelog) records each one.
## Money is in dollars
An amount is a number in Canadian dollars, with cents after the decimal point: `450000` is
$450,000 and `1234.56` is $1,234.56. The API never uses cents as an integer.
## Terms are in months
A term or an amortization is a whole number of months: `60` is a five-year term, and `300` is a
25-year amortization.
## Timestamps and dates
| Kind | Format | Example |
| --------- | --------------------------------- | ---------------------------- |
| Timestamp | ISO 8601, in UTC | `2026-10-07T14:20:00.000Z` |
| Date | `YYYY-MM-DD`, with no time or zone | `2026-11-30` |
A date, such as a closing date or a maturity date, is a calendar day. It has no time zone, so
`2026-11-30` is November 30 wherever your server runs. Don't parse it into a timestamp at
midnight UTC: in Toronto that is still November 29.
When you send a date, send `YYYY-MM-DD`. Each date field in the reference says if it also accepts a
full timestamp.
## Null, and leaving a field out
In a response, a value that BrokerPlus does not have is `null`. The field is still there.
In an update (`PATCH`), the two are different. Leave a field out to keep its value, and send `null`
to clear it.
## Names and ids
- Every field name is `snake_case`.
- Every record has an `id`. Ids are opaque strings. Don't parse them or sort by them.
- Every record has an `object` field that names its type, such as `"deal"`, `"contact"`, or
`"task"`. It never changes for a type, so you can route on it.
- Where a record points at another one, it usually carries a readable name next to the id, for
example `assigned_broker_id` and `assigned_broker_name`. The name is for display. Use the id to
look the record up.
A lender-coded value, such as a property type, comes as a `code` you can store and a `label` you
can show. Send the numeric code when you update it, not a quoted number or the label. The request
schema in the [API reference](/reference) lists the accepted codes. An invalid code returns the
accepted codes and their labels in `error.details.fieldErrors`.
A province is the one coded value that you can also send as its two-letter code, for example `ON`
instead of `50`. On read, a coded province also has `abbreviation`, the two-letter code.
For `PATCH /v1/deals/{id}/application`, `mortgage_insurer` uses `10` for CMHC, `20` for Sagen,
and `30` for Canada Guaranty. `payment_frequency` uses `26` for bi-weekly and `52` for weekly.
Acceleration is separate: send `payment_accelerated: true` with either frequency for accelerated
payments, or `false` for regular payments. Send `null` to clear the acceleration setting.
## The response envelope
Every response is JSON with the same outer shape. A success has `success: true` and the record, or
the list, in `data`:
```json
{ "success": true, "data": { "object": "contact", "id": "cmsclk2a9...", "first_name": "Sam" } }
```
A list adds `pagination`. See [Pagination](/pagination). A failure has `success: false` and an
`error` object. See [Errors](/errors).
## Unknown fields
We add fields often, and the beta label means a field can also change. Ignore any field you do not
recognize, instead of failing on it.
---
## Document: Compliance setup
Read and change the brokerage's compliance setup through the API - package templates, a deal's compliance package, the compliance program, and e-signing rules.
URL: /compliance
# Compliance setup
A brokerage sets up its compliance work in the compliance hub in BrokerPlus. This page shows the
parts of that setup that the API reads and changes. The API does not make a requirement easier to
meet: anything that turns a check off, waives an item, or clears a finding stays in BrokerPlus,
with a named person behind it.
This API is in beta. Any endpoint can change in a breaking way without notice, so watch the
[changelog](/changelog).
## Who can change the setup
A key can do what its broker can do in the compliance hub, and nothing more. A broker changes the
templates of the teams they can change. Only a compliance administrator changes the setup of the
whole brokerage. A change that the broker cannot make gets `403`, with a message that says why.
A key with the `read_only` limit can read all of it. See [Authentication](/authentication).
## Package templates
A compliance package is one PDF that holds the documents of a deal for a lender or an auditor. A
package template sets the layout of the package: the cover page, and the documents in it, in
order. Each document is a slot. A team has its own templates. The brokerage can also set templates
for every team, and only a compliance administrator can change those.
`GET /v1/compliance/package-templates` lists the templates, the brokerage's first.
`GET /v1/compliance/package-requirements` lists the requirements that a slot can hold: put a `key`
in the slot's `requirement_key`.
```bash title="curl"
curl -X POST "https://api.mos.getbrokerplus.ca/v1/compliance/package-templates" \
-H "Authorization: Bearer $BROKERPLUS_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"team_id": "cm...team",
"name": "Purchase file",
"slots": [
{ "requirement_key": "on_borrower_disclosure", "display_name": "Disclosure", "position": 0 },
{ "requirement_key": "custom-library:cm...", "display_name": "Brokerage checklist", "position": 1, "required": false }
]
}'
```
```json
{
"success": true,
"data": {
"object": "compliance_package_template",
"id": "cm...",
"owner": "team",
"team_id": "cm...team",
"team_name": "Purchase team",
"name": "Purchase file",
"include_cover": true,
"cover_title": "Compliance package",
"slots": [
{
"object": "compliance_package_template_slot",
"requirement_key": "on_borrower_disclosure",
"display_name": "Disclosure",
"required": true,
"included": true,
"max_age_days": null,
"position": 0
}
]
}
}
```
Send `team_id: null` to make a brokerage template. `PATCH /v1/compliance/package-templates/{id}`
changes the fields that you send, and `slots` replaces all the slots. The owner of a template cannot
change. `DELETE` archives the template, as the hub does. A package that is already on a deal keeps
the layout that it was made from.
A template sets a layout only. A required slot stops a package build while it has no document, but
a template does not change what a deal must have before it goes to a lender.
## A deal's package
A deal has one compliance package at most. `POST /v1/deals/{id}/compliance/package` with a
`template_id` makes it from one of the deal team's templates, or the brokerage's, and fills each
slot with the deal's documents for that requirement. A new package answers `201`. Send it again to
make the package again from a template: that answers `200`.
`GET /v1/deals/{id}/compliance/package` reads it: the cover, the items in order, and the
`findings` of the package's own checks. It answers `404` when the deal has no package.
`PATCH /v1/deals/{id}/compliance/package` changes the cover, and the name, position and
`included` of each item that you send by `id`.
`POST /v1/deals/{id}/compliance/package/build` merges the package into one PDF. It answers `202`,
and `status` is `building` until the PDF is ready. A finding with `severity: "blocker"` stops the
build with `409 PACKAGE_BLOCKED`, and `details.findings` lists them: a required document that is
missing or left out, or a package with nothing in it. A blocker is cleared in the deal, not here.
A change after a build sets `stale: true` until you build again.
`GET /v1/deals/{id}/compliance/package/download` answers a link to the PDF that works for five
minutes. It answers `409` while the package is not built, or is stale.
## The compliance program
`GET /v1/compliance/program` reads the brokerage's compliance program: the compliance officers, how
often a client is screened again, the ways a client proves their identity, who gets an email for
each alert, and whether each of the five program documents is there.
Through the API, a compliance administrator changes only the facts about the brokerage that the
borrower disclosure prints:
```bash title="curl"
curl -X PATCH "https://api.mos.getbrokerplus.ca/v1/compliance/program" \
-H "Authorization: Bearer $BROKERPLUS_KEY" \
-H "Content-Type: application/json" \
-d '{ "representation_basis": "borrower_only", "lenders_acted_for_previous_year": 12, "was_lender_previous_year": false }'
```
Send `null` to clear a fact: the disclosure then prints it unanswered. Any other field of the program
gets `400`. A change to how often clients are screened, how a client proves their identity, or who
hears an alert can make a check easier to pass, so it is made in BrokerPlus.
These read the rest of the setup. None of them change it.
| Endpoint | What it returns |
| --- | --- |
| `GET /v1/compliance/trainings` | The compliance training that each broker finished. Only a brokerage administrator can read it. |
| `GET /v1/compliance/retention-policies` | How many years each kind of record is kept, by province. |
| `GET /v1/compliance/verifier-policy` | Whether the instant document check is on, who can run it, and how strict it is. |
| `GET /v1/compliance/monitoring-alerts` | The alerts that screening raised about clients. A broker sees the alerts on their own clients. |
An alert is dismissed or resolved in BrokerPlus, by a person who reviewed it.
## E-signing rules
A team that connected an e-signing provider in BrokerPlus, such as DocuSign or BoldSign, sends its
documents through it. `GET /v1/signing-policies` lists the rules of your teams: the `default`
provider, and a `lender` exception for a lender that does not accept it.
```bash title="curl"
curl -X POST "https://api.mos.getbrokerplus.ca/v1/signing-policies" \
-H "Authorization: Bearer $BROKERPLUS_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "scope": "lender", "lender_slug": "scotiabank", "provider": "boldsign", "note": "Scotia asks for BoldSign" }'
```
A rule names the provider, and the rule goes on the team that connected it. When more than one of
your teams connected the provider, send `team_id`. A team has one default and one rule per lender,
so sending the same rule again changes it. `DELETE /v1/signing-policies/{id}` removes a rule. A
provider is connected in BrokerPlus, never through the API.
## In an AI assistant
The [MCP server](/mcp) has a tool for every operation on this page, the download link and the
monitoring alerts included. An assistant can repeat what it reads to anyone it talks to, so treat a
key you give an assistant the way you treat the client's file. Archiving a template and deleting an
e-signing rule are marked destructive, so the assistant asks you first.
---
## Document: Automate client follow-up
Build the same Pulse automations a broker builds in BrokerPlus, turn them on, and read their runs.
URL: /automations
# Automate client follow-up
Automations do the follow-up a broker would otherwise remember to do: email the client and the
realtor when a deal moves into a stage, make a call task the next day, wish a past client happy
birthday. Over the API you build exactly what the Pulse builder in BrokerPlus builds, and you turn
it on the same way the broker does.
The words an automation sends live in [templates](/templates). Write those first.
## How an automation works
An automation has one trigger and one or more steps.
- The **trigger** (`type`) is the event that starts it. `stage.transitioned` fires when a deal moves
into a stage, `deal.status_changed` when the lender approves a deal, and `birthday` on a client's
birthday. `config` holds the trigger's settings, such as which stage.
- **Checks** in `filters` decide which deals or contacts it starts for, for example purchases only.
- Each **step** does one thing: send an email or a text from a template, create a task, add a tag,
move the deal, and more. `delay_days` makes a step wait, and `only_if` adds checks for that step.
- A **graph** holds an automation that forks or waits. A `branch` node sends the run down one of two
paths, and a `waitFor` node waits for the client to reply. A graph compiles to steps, so an
automation with a graph has `steps` too.
`GET /v1/automations/catalog` is the builder's whole vocabulary: every trigger type and its `config`,
every step `kind` and its `params`, who a message can go to, the fields a check can compare, and the
node kinds of a graph. Read it before you build, and you'll only name things that exist.
## What a key can do
A key sees the automations on its broker's Pulse board, and no others. Creating, changing, turning
on, or deleting one needs the broker to have permission to change automations in BrokerPlus.
Without it, the API returns `403 AUTOMATION_MANAGE_FORBIDDEN`.
| Request | What it does |
| ------------------------------------- | ----------------------------------------------------- |
| `POST /v1/automations` | Creates an automation, switched off. |
| `PATCH /v1/automations/{id}` | Changes it. A change to what it does switches it off. |
| `POST /v1/automations/{id}/duplicate` | Makes a copy, switched off. |
| `POST /v1/automations/{id}/turn-on` | Turns it on now. |
| `POST /v1/automations/{id}/turn-off` | Turns it off now. |
| `DELETE /v1/automations/{id}` | Turns it off and removes it from the board. |
Because every write leaves the automation off, you can draft and refine one safely. Nothing runs
until it is turned on.
## Turn an automation on
An automation that is on runs with nobody watching. `POST /v1/automations/{id}/turn-on` turns it on
now, the same as its switch in BrokerPlus, and returns `200` with the automation. When
`can_message_clients` is `true`, its steps can reach clients from that moment. Read each step before
you turn it on.
A change to what the automation does switches it off again, and you turn it on again. A key with the
`no_sending` limit can't turn on an automation that messages clients (`403 KEY_LIMITED`). That limit
is the switch that keeps a key from reaching clients.
## Ask before sending
Each step has `ask_before_sending`. When it is `true`, the step puts a draft in the broker's queue
and the client gets nothing until the broker approves that draft. When it is `false`, the step sends
with no review.
Leave it out and BrokerPlus uses the catalog's `default_ask_before_sending` for that kind, which is
`true` for every step that reaches a client. Some steps always ask (`always_asks_before_sending` in
the catalog), and they refuse `false`.
## Example: email the client and the realtor when a deal moves into a stage
This is the automation brokers ask for most. When a deal moves into the "Conditions removed" stage,
BrokerPlus emails the client and the realtor from a saved template, held in the broker's queue for
review. A day later, it creates a task to call the client.
### 1. Get the ids
Get the stage id from `GET /v1/pipelines` and the template id from `GET /v1/templates`. The
recipients come from the catalog's `recipients`: `primary_applicant` is the client, and
`partner:realtor` is the realtor on the deal.
An email step can also go to a teammate by their role on the deal, for example
`deal-role:Closing Officer`, `deal-role:@underwriter` or `deal-role:@submitting`. Use the role name
as your team has it in BrokerPlus. The email goes to whoever holds that role on each deal. In
`ccRecipients` or `bccRecipients`, an empty role is left off. In `toRecipients`, a step with nobody
left to send to gets the status `needs_attention`, and its reason names the empty role.
### 2. Create the automation
```bash title="curl"
curl -X POST "https://api.mos.getbrokerplus.ca/v1/automations" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7b1d0c1e-3f8a-4a51-9a63-2c5f8a0e4d21" \
-d '{
"name": "Conditions removed",
"type": "stage.transitioned",
"config": { "stageId": "cmt4au1kd0000p3iuh6c0q2re" },
"steps": [
{
"kind": "email",
"template_id": "cmt4atz7t0000p3iuy1r9c3fe",
"params": { "toRecipients": ["primary_applicant", "partner:realtor"] },
"ask_before_sending": true
},
{
"kind": "task",
"delay_days": 1,
"params": { "title": "Call the client about next steps", "priority": "medium" }
}
]
}'
```
```ts title="TypeScript"
const res = await fetch("https://api.mos.getbrokerplus.ca/v1/automations", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
name: "Conditions removed",
type: "stage.transitioned",
config: { stageId: "cmt4au1kd0000p3iuh6c0q2re" },
steps: [
{
kind: "email",
template_id: "cmt4atz7t0000p3iuy1r9c3fe",
params: { toRecipients: ["primary_applicant", "partner:realtor"] },
ask_before_sending: true,
},
{ kind: "task", delay_days: 1, params: { title: "Call the client about next steps", priority: "medium" } },
],
}),
});
const { data } = await res.json();
```
```python title="Python"
import os
import uuid
import requests
res = requests.post(
"https://api.mos.getbrokerplus.ca/v1/automations",
json={
"name": "Conditions removed",
"type": "stage.transitioned",
"config": {"stageId": "cmt4au1kd0000p3iuh6c0q2re"},
"steps": [
{
"kind": "email",
"template_id": "cmt4atz7t0000p3iuy1r9c3fe",
"params": {"toRecipients": ["primary_applicant", "partner:realtor"]},
"ask_before_sending": True,
},
{
"kind": "task",
"delay_days": 1,
"params": {"title": "Call the client about next steps", "priority": "medium"},
},
],
},
headers={
"Authorization": f"Bearer {os.environ['BROKERPLUS_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
)
data = res.json()["data"]
```
The response is `201` and the automation, switched off (`"enabled": false`).
```json
{
"success": true,
"data": {
"object": "automation",
"id": "cmt4au7q90001p3iu2b8x1k7d",
"name": "Conditions removed",
"description": null,
"type": "stage.transitioned",
"type_label": "Deal Moved into a Stage",
"config": { "stageId": "cmt4au1kd0000p3iuh6c0q2re" },
"filters": { "match": "all", "rules": [] },
"enabled": false,
"can_message_clients": true,
"repeat_policy": "once_per_subject",
"team_id": "cmt49zz1v0000p3iub7d3e5qa",
"canvas_id": null,
"canvas_name": null,
"sort_order": null,
"anchor_offset_days": null,
"drawn_on_canvas": false,
"graph": null,
"steps": [
{
"object": "automation_step",
"id": "cmt4au7qa0002p3iu8u0v2h1m",
"position": 0,
"kind": "email",
"label": "Send an email",
"delay_days": 0,
"send_at_time": null,
"ask_before_sending": true,
"template_id": "cmt4atz7t0000p3iuy1r9c3fe",
"template_name": "Conditions removed",
"params": {
"templateId": "cmt4atz7t0000p3iuy1r9c3fe",
"toRecipients": ["primary_applicant", "partner:realtor"],
"ccRecipients": [],
"bccRecipients": []
},
"only_if": { "match": "all", "rules": [] },
"created_at": "2026-10-07T15:02:11.000Z",
"updated_at": "2026-10-07T15:02:11.000Z"
},
{
"object": "automation_step",
"id": "cmt4au7qa0003p3iuw2n6c4ve",
"position": 1,
"kind": "task",
"label": "Create a task",
"delay_days": 1,
"send_at_time": null,
"ask_before_sending": false,
"template_id": null,
"template_name": null,
"params": { "title": "Call the client about next steps", "priority": "medium" },
"only_if": { "match": "all", "rules": [] },
"created_at": "2026-10-07T15:02:11.000Z",
"updated_at": "2026-10-07T15:02:11.000Z"
}
],
"created_at": "2026-10-07T15:02:11.000Z",
"updated_at": "2026-10-07T15:02:11.000Z"
}
}
```
### 3. Turn it on
The email step can reach a client, so read the steps before you turn it on.
```bash
curl -X POST "https://api.mos.getbrokerplus.ca/v1/automations/cmt4au7q90001p3iu2b8x1k7d/turn-on" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Idempotency-Key: 0c9e4f8b-52a7-4f1e-8d3b-6a2e1f7c9b40"
```
The response is `200` with the automation, and `enabled` is `true`. The next deal that moves into the
stage gets an email waiting in the broker's drafts queue, because the step asks before sending.
## Errors tell you how to fix the definition
BrokerPlus checks an automation exactly as the builder does. A refusal is `400` (or `404` for a
record that isn't in the brokerage), `details.fieldErrors` names each field at fault, and each
message says how to fix it.
```json
{
"success": false,
"error": {
"code": "INVALID_STEP",
"message": "steps.1.delay_days: Step 2 waits 0 days, less than step 1 (2 days). The delay counts from the start, so list the steps in the order they run, or set this delay_days to 2 or more.",
"details": {
"formErrors": [],
"fieldErrors": {
"steps.1.delay_days": [
"Step 2 waits 0 days, less than step 1 (2 days). The delay counts from the start, so list the steps in the order they run, or set this delay_days to 2 or more."
]
}
}
}
}
```
| `code` | What is wrong |
| ---------------------------- | -------------------------------------------------------------------------------------- |
| `VALIDATION_ERROR` | A field has the wrong shape, or `type` is not in the catalog. |
| `INVALID_STEP` | A step kind is unknown, a setting in `params` is missing or wrong, or the steps are out of order. |
| `INVALID_TRIGGER_CONFIG` | A setting in `config` is missing or wrong for the trigger. |
| `INVALID_TRIGGER_FILTERS` | A check names a field that the trigger cannot read. |
| `INVALID_GRAPH` | The graph cannot compile, for example a branch with one path. |
| `TEMPLATE_SUBJECT_MISMATCH` | A template uses a merge field that the trigger cannot fill, or one that does not exist. |
| `NOT_FOUND` | A template, stage, tag, or other record is not in the brokerage. |
A step that writes its own words to a client, such as `draft_email`, is refused. Save the words as a
[template](/templates) and send it from an `email` or `sms` step instead.
## Read the automations
`GET /v1/automations` lists automations, newest first. Filter with `type` (for example
`stage.transitioned`) and `enabled=true`, or sync with `updated_since` (see [Sync](/sync)).
`GET /v1/automations/{id}` returns one.
## Change an automation
`PATCH /v1/automations/{id}` changes only the fields you send. `steps` replaces every step, and
`graph` replaces the whole graph.
- A change to `config`, `stage_id`, `filters`, `repeat_policy`, `anchor_offset_days`, `steps`, or
`graph` switches the automation off.
- A change to only `name`, `description`, or `canvas_id` leaves it as it was.
- `type` can't change. Create a new automation, or send a graph whose trigger node has the new type.
- When the automation has a graph (`drawn_on_canvas` is `true`), send a changed `graph`. A `steps`
change returns `409 DRAWN_ON_CANVAS`, because a list of steps cannot hold its branches.
`POST /v1/automations/{id}/duplicate` makes a copy named ` copy`. `DELETE /v1/automations/{id}`
turns it off and removes it from the board, and its runs stay readable.
## Group automations on the canvas
On the automation canvas, a broker files automations in groups. A group belongs to one broker team,
and only its members see it. Over the API:
| Request | What it does |
| ------------------------------------------------- | --------------------------------------------------------------- |
| `GET /v1/automations/canvases` | Lists the groups, highest `position` first. |
| `POST /v1/automations/canvases` | Makes a group on the broker's active team. |
| `PATCH /v1/automations/canvases/{id}` | Renames, describes, or moves a group. |
| `DELETE /v1/automations/canvases/{id}` | Deletes the group and turns off and deletes its automations. |
| `POST /v1/automations/canvases/{id}/turn-on` | Turns on every automation in the group, as described below. |
| `POST /v1/automations/canvases/{id}/turn-off` | Turns off every automation in the group now. |
File an automation in a group with `canvas_id` on `POST /v1/automations` or
`PATCH /v1/automations/{id}`. `automation_count` and `enabled_count` tell you whether the group is on,
off, or partly on.
A group has no switch of its own: turning it on turns on each automation in it now, the same as
`POST /v1/automations/{id}/turn-on`. `results` has a line for each automation: `on` or `unchanged`.
If an automation that is off can message clients and your key has the `no_sending` limit, the
response is `403 KEY_LIMITED` and nothing changes.
## Stop automations messaging a client
A stop keeps automations from messaging one client, the same as Stop automated outreach on a contact
in BrokerPlus. `POST /v1/automations/stops` with `contact_id` stops every automation for every deal of
the client. Add `deal_id` to stop them for one deal, or `automation_types` to stop only some kinds.
This only means fewer messages, so it happens now.
`GET /v1/automations/stops` lists the stops. A stop can also come from a stage that stops automated
messages (`held_by_stage`) or from a client tag automation (`held_by_tag_ids`). Those end by
themselves when the client leaves the stage or loses the tag.
`POST /v1/automations/stops/{id}/lift` lifts the stop now and returns `200`. Every automation it held
back can message the client again. A key with the `no_sending` limit can't lift a stop
(`403 KEY_LIMITED`).
## Read the runs
A run is one automation working through its steps for one deal or contact. `GET /v1/automation-runs`
lists them. Filter by `automation_id`, `deal_id`, or `contact_id`.
`POST /v1/automation-runs/{id}/start` starts a `scheduled` run now, the same as Run now in
BrokerPlus, and returns the run. Its steps can reach the client, so a key with the `no_sending`
limit gets `403 KEY_LIMITED`. `POST /v1/automation-runs/{id}/cancel` removes a `scheduled` run
before it starts.
```bash
curl "https://api.mos.getbrokerplus.ca/v1/automation-runs?automation_id=cmt4au7q90001p3iu2b8x1k7d&deal_id=cmscllt4h01xyp3iuq7dk2m8z" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```json
{
"success": true,
"data": [
{
"object": "automation_run",
"id": "cmt4c1v2e0007p3iu0d6k8q3x",
"automation_id": "cmt4au7q90001p3iu2b8x1k7d",
"automation_name": "Conditions removed",
"status": "running",
"subject_type": "deal",
"subject_id": "cmscllt4h01xyp3iuq7dk2m8z",
"owner_user_id": "cmsck2v8d0000p3iu5f9d8k1a",
"fired_at": "2026-10-08T13:40:02.000Z",
"created_at": "2026-10-08T13:40:02.000Z",
"updated_at": "2026-10-08T13:40:05.000Z"
}
],
"pagination": { "total": 1, "page": 1, "page_size": 50, "total_pages": 1 }
}
```
`GET /v1/automation-runs/{id}/steps` lists what each step of a run did or will do, in the order they
fall due: `draft_id` for a draft it wrote, `communication_id` for a message it sent, `task_id` for a
task it made, and `reason` when a step was skipped, suppressed, failed, or needs the broker.
## See who a deal's mail comes from
`GET /v1/deals/{id}/sender` tells you who the client sees on mail about a deal, worked out exactly
as a real send works it out: from the broker assigned to the deal.
```bash title="curl"
curl "https://api.mos.getbrokerplus.ca/v1/deals/cmscllt4h01xyp3iuq7dk2m8z/sender" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```ts title="TypeScript"
const res = await fetch("https://api.mos.getbrokerplus.ca/v1/deals/cmscllt4h01xyp3iuq7dk2m8z/sender", {
headers: { Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}` },
});
const { data } = await res.json();
```
```python title="Python"
import os
import requests
res = requests.get(
"https://api.mos.getbrokerplus.ca/v1/deals/cmscllt4h01xyp3iuq7dk2m8z/sender",
headers={"Authorization": f"Bearer {os.environ['BROKERPLUS_API_KEY']}"},
)
data = res.json()["data"]
```
```json
{
"success": true,
"data": {
"deal_id": "cmscllt4h01xyp3iuq7dk2m8z",
"sender_user_id": "cmsck2v8d0000p3iu5f9d8k1a",
"sender_name": "Alex Broker",
"assigned_broker_id": "cmsck2v8d0000p3iu5f9d8k1a",
"assigned_broker_name": "Alex Broker",
"mailbox": "alex@example.com",
"team_id": "cmt49zz1v0000p3iub7d3e5qa",
"team_name": "Example Mortgage Group",
"brand": {
"source": "team",
"primary_color": "#1f3a5f",
"secondary_color": "#c9a227",
"logo_url": "https://cdn.example.com/logo.png"
},
"legal_name": "Example Mortgage Group Inc."
}
}
```
- `sender_user_id` is the broker whose name and signature the client sees. A team member who does
not send from their own mailbox sends as the team's broker.
- `mailbox` is the address the mail leaves from. `null` means no connected mailbox can send for this
deal, so client email can't go out until one is connected.
- `brand` is the brand of the team that owns the deal. `source` is `team` when the team set its own
colors or logo, and `brokerage` when it uses the brokerage brand.
- `legal_name` is the business name in the footer of marketing mail. It is the same for every team.
The API doesn't set the sender directly. To send a deal's mail as another broker, assign that broker
to the deal with `PATCH /v1/deals/{id}`. The team's brand is changed in BrokerPlus.
---
## Document: Changelog
Every change to the API, newest first.
URL: /changelog
# Changelog
The API is in beta, and it can change without advance notice. This page records every change. Watch
it, and you see what moved, and when it moved.
The newest entry is first. Each date is the day that the change reached production.
## 2026-10-27
- **Added:** `buydown_reason` on a commission, the broker's note on why they bought the rate down.
You can read it, and you can set it with `POST` or `PATCH /v1/deals/{id}/commission`. It is optional.
## 2026-10-21
- **Added:** a contact relationship's `type` can be a professional the client keeps: `realtor`,
`lawyer`, `appraiser`, `accountant`, `financial_planner`, `insurance` or `builder`. The `label` is
worded from the contact you read, so the professional reads `Lawyer` and the client reads `Client`.
- **Added:** `GET /v1/opportunities` takes the filters from the book in BrokerPlus: `dismissed`,
`archived`, `maturity_from` and `maturity_to` (a maturity window, `YYYY-MM-DD`), `opportunity_type`,
`product_type`, `start_year`, and `assigned_broker_id`. It also takes `sort`, which orders the list
by maturity date or by the client's last message. A list filter takes values separated by commas,
and an unknown value gets `400 VALIDATION_ERROR`.
- **Added:** `mode` on a `field`, `checkpoint`, or `custom_field` requirement of a pipeline stage.
`cumulative` is the default and applies the requirement to the stage and to every later stage, as
before. `this_stage` applies it to the stage only. `optional` asks the broker for it when they move
a deal into the stage, but does not stop the move. If you read requirements and send them back,
keep `mode`, or an optional requirement becomes required.
- **Changed:** a `checkpoint` requirement with `required: false` now reads as `mode: "optional"`. The
broker sees the step when they move a deal into the stage, and the move still goes ahead. A `mode`
that you send on a `checkpoint` wins over `required`.
- **Added:** a stage requirement of kind `custom_field`. Its `key` is one of your deal custom
fields, and the deal must have an answer before it moves into the stage. A checkbox counts only
when it is ticked. `GET /v1/deals/{id}/stage-requirements` and a `409 STAGE_REQUIREMENTS_UNMET`
name a missing custom field with `kind: "custom_field"`.
- **Added:** each `province` that takes a numeric code also takes the two-letter code, for example
`ON` in place of `50`. This applies to the subject property, an employment, and an applicant
address. On read, these provinces also have `abbreviation`, the two-letter code.
- **Changed:** an other property's `province` takes only a province: the two-letter code, or the
numeric code, which is saved as the two-letter code. Any other value gets `400 VALIDATION_ERROR`,
and the error lists the accepted values. It used to save any text.
- **Added:** `PATCH /v1/deals/{id}` takes `type`, with the same values as `POST /v1/deals`. The change
is the same as in the dashboard: BrokerPlus calculates the total loan amount and the mortgage
insurance premium again, and a change that crosses a renewal type replaces the renewal disclosures.
The deal stays on its pipeline and in its stage.
- **Added:** `GET /v1/deals` takes `stage_id` and `board_id`. `stage_id` returns only the deals in that
stage, and `board_id` returns only the deals on that pipeline. Use them with `updated_since` to sync
one stage.
- **Changed:** `POST /v1/opportunities` no longer requires `term_months`. Without a term, BrokerPlus
does not estimate the balance. The opportunity uses the `balance` that you send, or `mortgage_amount`
when you do not send `balance`.
- **Added:** an automation's email step can name a role on the deal in `toRecipients`,
`ccRecipients` and `bccRecipients`, for example `deal-role:Closing Officer`. The email goes to the
teammate who holds that role on the deal. Only a trigger that runs on a deal accepts it.
## 2026-10-20
- **Changed:** an automation's `type_label`, and each trigger's name in `GET /v1/automations/catalog`,
is now in title case, the same as on screen. `Deal moved into a stage` reads `Deal Moved into a Stage`.
The `type` values did not change, so match on `type` rather than on the name.
## 2026-10-19
- **Added:** a captured answer's `source` can be `meeting`. It is an answer the client gave in a
meeting that the notetaker recorded, and its `communication_id` is that meeting.
- **Added:** a lead's `source` and a contact's `created_via` can be `assistant`: a person the broker's
assistant added to their book.
- **Changed:** `POST /v1/contacts` with a name and a phone number fills in the contact that holds only
that number, if there is one, and returns it. It used to add a second contact beside it.
- **Changed:** a communication folder from an Outlook mailbox can now have a `parent_id`. Outlook
folders used to arrive with `parent_id` set to null. They now name the folder they sit in, the same
as in Outlook, and that folder can be a system folder such as the inbox.
## 2026-10-17
- **Changed:** an employment's `type` no longer has the temporary codes. The codes are now 1 (permanent
full time), 2 (permanent part time), 5 (seasonal) and 7 (not applicable). A job saved as temporary full
time (3) now reads as 1, temporary part time (4) as 2, and temporary seasonal (6) as 5. A request that
sends 3, 4 or 6 is still accepted, and saves 1, 2 or 5 in the same way.
- **Added:** a lead's `source` and a contact's `created_via` can be `meeting`: a person the broker
met in a meeting and then added to their book.
## 2026-10-13
- **Added:** an outreach template returns `body_html`, the formatted version of an email template.
`PATCH /v1/outreach-templates/{id}` takes `body_html` to change it, or null to go back to plain
text. A new `body` without a new `body_html` still removes the formatted version.
- **Added:** a credit report's `status` can be `no_score`. It is a credit check that the system the deal
came from ran, which returned no score. You can still pull credit for that borrower.
- **Changed:** on a second or later charge against a home the borrower already owns, `ltv` on
`GET /v1/deals/{id}/application` is the combined loan to value (CLTV). It counts the balance of each
mortgage and home line of credit ahead of the loan, the way a lender reads it. On a first charge it
is the loan alone, as before. When the deal cannot say which charge is ahead, `ltv` stays the loan
alone.
- **Added:** `GET` and `PATCH /v1/deals/{id}/property` return `lending_value_basis` and
`estimated_value`. When a file names the value it lends against (`price`, `estimate`, or
`appraisal`), `ltv` on `GET /v1/deals/{id}/application` is worked out on that value while the deal
holds it, on a refinance, switch or renewal. A purchase, and any file that names no value, is still
worked out on the lower of the purchase price and the appraised value.
- **Changed:** when `loan_amount` on `GET /v1/deals/{id}/application` already holds the default
insurance premium, `total_loan_amount` is `loan_amount` and the premium is not added a second time.
A loan holds it when it is within one dollar of the purchase price less the down payment plus the
premium, or of Finmo's own total for the loan. `insurance_premium_rate` is then a percent of the loan
before the premium, which is `total_loan_amount` minus `insurance_premium`. With no property value, a
premium on file is added only on a deal that started in Finmo. On any other deal `total_loan_amount` is
then `loan_amount`.
- **Fixed:** ChatGPT finishes connecting to the [MCP server](/mcp). A client whose metadata document
names `private_key_jwt` and also lists `none` in `token_endpoint_auth_methods_supported` can get
its token with PKCE alone. If it sends a `client_assertion`, the assertion is checked.
- **Fixed:** a connected app keeps its connection while its metadata document can't load. A public
client's refresh no longer reads the document. A `private_key_jwt` client uses the last keys
that loaded, for up to 24 hours.
## 2026-10-12
- **Breaking:** a key with the `no_sending` limit now gets `403 KEY_LIMITED` on the requests that
show a client something at once: `POST /v1/hub-resources`, `POST /v1/tracker-stages`,
`PATCH /v1/tracker-stages/{id}`, `POST /v1/tracker-stages/reorder`, `POST /v1/tracker-stages/seed`,
and `PATCH /v1/tracker-stages/note`. Reading and removing stays open. See
[Reaching clients](/reaching-clients).
- **Changed:** the [MCP server](/mcp) covers the whole API. Every operation is now a tool, deletes,
setup, webhooks, batch writes, signed links, and compliance records included. The tool's MCP
annotations tell your assistant what to confirm with you: a delete is marked destructive, and a
tool that reaches a client or anybody else outside the brokerage is marked destructive and
open-world.
- **Changed:** the MCP server hides every tool that the key's limits refuse. A key with the
`no_sending` limit no longer sees the tools that reach a client, and a read-only key still sees only
the tools that read. The API refuses these calls the same way as before.
- **Changed:** the MCP tool list is under 1 MB, so Claude and ChatGPT connectors load every tool. A
listed `outputSchema` names only `data` and `pagination`, and descriptions stop after their first
sentences. `brokerplus_describe_operation` returns the full contract of any operation.
- **Changed:** ChatGPT connects to the [MCP server](/mcp). The authorization server now accepts
`private_key_jwt` client authentication from a client that declares it in its Client ID Metadata
Document, and it lists the method in `token_endpoint_auth_methods_supported`.
- **Added:** reply to a client. `POST /v1/communications/{id}/reply` answers an email or text in its
own conversation, from the mailbox or number the conversation is on, with `mode` `reply`,
`reply_all`, or `forward`, and files in `attachments`. It reaches the client, so a key with the
`no_sending` limit is refused it. `POST /v1/communications/{id}/drafts` writes the same reply into
the broker's drafts and sends nothing.
- **Added:** `PATCH /v1/drafts/{id}` edits a draft, `POST /v1/drafts/{id}/discard` discards one, and
`POST /v1/drafts/{id}/schedule` and `POST /v1/drafts/{id}/unschedule` set and stop a send time.
Scheduling reaches the client, so a key with the `no_sending` limit is refused it.
- **Added:** `send_at` on `POST /v1/contacts/{id}/messages` and `POST /v1/communications/{id}/reply`
sends the message later.
- **Added:** the draft object returns `to`, `cc`, `bcc`, `reply_to_communication_id`, `forward`, and
`attachments`.
- **Added:** a forward on `POST /v1/communications/{id}/reply` and `POST /v1/communications/{id}/drafts`
carries the files of the email it forwards. Send `include_attachments: false` to leave them out.
- **Changed:** the draft object's `contact_id` is null for a reply or forward to an address that is
not a contact. It was always a contact before.
- **Changed:** `POST /v1/drafts/{id}/send` also sends a draft with no contact, such as a reply to a
lender.
## 2026-10-11
- **Added:** a campaign can go to several audiences. `segment_ids` on `POST /v1/campaigns` and
`PATCH /v1/campaigns/{id}` takes up to 20 more audiences next to `segment_id`, and a person in more
than one of them gets one message. The campaign object returns every audience in `segment_ids`;
`segment_id` is now the first of them.
- **Changed:** a recipient variable such as `{{borrower.firstName}}` no longer needs a fallback. A
contact with no value gets a blank.
- **Changed:** `POST /v1/campaigns/{id}/schedule` returns `422` with `SENDER_DETAIL_MISSING` when the
campaign names a sender detail, such as `{{broker.phone}}`, that the broker's profile leaves empty.
## 2026-10-10
- The notetaker can be sent from the API: `POST /v1/meetings` sends it into a call,
`POST /v1/meetings/{id}/stop` takes it out, and `POST /v1/meetings/{id}/sync` pulls the transcript
again. Sending it reaches clients, so a key with the `no_sending` limit is refused it.
- Date triggers can be set up and switched on and off: `POST /v1/pulse-triggers` (lands off),
`PATCH /v1/pulse-triggers/{id}` (moves `days_offset`), `POST /v1/pulse-triggers/{id}/turn-on`, and
`POST /v1/pulse-triggers/{id}/turn-off`. Turning one on reaches clients, so a key with the
`no_sending` limit is refused it.
- The reference now describes the `urgent`, `normal`, and `low` fields of a compliance program's
`alert_routing`, and `GET /v1/intakes/{id}/notes/{noteId}`. Nothing about them changed.
- **Breaking: requests that reach a client act at once, and approvals are gone.** A request that
emails, texts, books, or otherwise reaches a client no longer waits for the broker's approval. It
runs now, as the broker your key acts as, and returns the record it made or changed. A key with
the `no_sending` limit is still refused every one of these requests, so use that limit for any
key that must not contact clients. See [Reaching clients](/reaching-clients). What changed:
- `GET /v1/approvals`, `GET /v1/approvals/{id}`, and `POST /v1/approvals/{id}/cancel` are removed,
with the approval object and the `approval.approved` and `approval.rejected` webhooks.
- These returned `202` with an approval, and now return `201` with the new record:
`POST /v1/contacts/{id}/messages` (the sent draft), `POST /v1/deals/{id}/condition-requests`
(the sent draft), `POST /v1/deals/{id}/generated-documents/{documentId}/send` (the sent draft),
`POST /v1/mail-merges` (the mail merge), `POST /v1/appointments/book` (the booking),
`POST /v1/nps-surveys` (the survey), `POST /v1/pep-forms` (the form),
`POST /v1/deals/{id}/consent-forms` (what was sent), `POST /v1/deals/{id}/document-requests`
(the document requests it added), and `POST /v1/deals/{id}/funding-package/send` (the signing
envelope).
- These returned `202` with an approval, and now return `200` with the record they changed:
`POST /v1/drafts/{id}/send` (the draft), `POST /v1/automation-runs/{id}/start` (the run),
`POST /v1/campaigns/{id}/schedule` (the campaign, with `warnings`),
`POST /v1/appointments/{id}/reschedule` and `POST /v1/appointments/{id}/cancel` (the booking),
`POST /v1/intakes/{id}/request-changes` (the intake),
`POST /v1/deals/{id}/signing-documents/send` (the signing documents),
`POST /v1/deals/{id}/signing-documents/{documentId}/resend` (the signing document),
`POST /v1/deals/{id}/tracker/{stageId}/reach` (the tracker),
`POST /v1/automations/stops/{id}/lift` (`{ id, deleted: true }`), and
`POST /v1/equity-reports/enroll`, `/resume`, and `/send` (a line for each deal, `changed`,
`unchanged`, or `refused`).
- `POST /v1/automations/{id}/turn-on` and `POST /v1/lead-forms/{id}/turn-on-auto-reply` always
return `200` with the automation or the form. `POST /v1/automations/canvases/{id}/turn-on` turns
on every automation in the group, and its results no longer carry `approval_id` or
`approval_requested`.
- `POST /v1/intakes` with `delivery: "email"` emails the intake at once, and the response no
longer has `approval_id`.
- Two new errors: `403 FORBIDDEN` when the person your key acts as cannot send this in BrokerPlus
either, and `409 NOT_SENT` when the request could not go out as it stands. Nothing was sent in
either case.
- Your brokerage's automation policy still applies: where it does not allow messages to clients to
be asked for this way, each of these requests answers `403 POLICY_BLOCKED` and sends nothing.
- MCP tools that reach a client are now marked `destructiveHint` and `openWorldHint`, so an
assistant asks its user before it calls one.
- **A merged deal stays archived.** When BrokerPlus merges a spreadsheet copy of a Finmo file into
the Finmo deal, the copy is archived and its work moves to the Finmo deal. `POST /v1/deals/{id}/restore`
on that copy returns `409` with the code `MERGED`, and the copy stays archived.
- **Every Canada Post street type.** `street_type` on an address now takes and returns every Canada
Post street type, English and French, 182 in all, where it took 26. The
numeric codes it already used keep their meaning.
- **Notes on an intake.** `GET /v1/intakes/{id}/notes` lists the notes brokers wrote on an
intake's files and items, `GET /v1/intakes/{id}/notes/{noteId}` reads one, and
`POST /v1/intakes/{id}/notes` writes one or replies to one. The client never sees them. The MCP
server has a tool for each. See [Write notes on an intake](/intake#write-notes-on-an-intake).
- **Bank statement transactions.** `GET /v1/intakes/{id}/transactions` lists the transactions on a
client's bank statements with the broker's changes, `POST /v1/intakes/{id}/transactions/override`
takes them out of the income calculation or recategorizes them, and
`DELETE /v1/intakes/{id}/transactions/{transactionId}/override` undoes that. Nothing goes to the
client, and an account number is only ever its last 4 digits. The MCP server has a tool for each
but the undo. See [Take out or recategorize a transaction](/intake#take-out-or-recategorize-a-transaction).
- **What the income calculation counts.** `GET /v1/intakes/{id}/stated-income-scope` reads the
broker's choices on the stated income card (the dates, the statements and accounts left out, the
income the client declared) and the bank accounts on the statements, and `PATCH` changes them.
Nothing goes to the client. The MCP server has a tool for each. See
[Shape the income calculation](/intake#shape-the-income-calculation).
- **Tracker links.** `GET /v1/deals/{id}/tracker/links` lists the links that still show a deal's
tracker to a realtor or a lawyer, and `POST /v1/deals/{id}/tracker/links/{linkId}/revoke` takes one
back. BrokerPlus no longer makes new ones: a partner gets a partner hub. The MCP server has a tool
for each. See [Tracker links](/workspace#tracker-links).
- **Media library.** `/v1/media-assets` lists the pictures and video stills the brokerage keeps for
outreach emails, uploads a picture, adds a video link, and deletes one. The MCP server has a tool
for each but delete. See [Media library](/templates#media-library).
- **Outreach templates.** `/v1/outreach-templates` reads and writes the first-touch emails and
texts a broker sends by hand from the refinance, renewal and lead lanes: the broker's own set and
their team's, with set-primary, duplicate, share and copy-to-personal, as the outreach templates
editor has. Nothing sends. The MCP server has a tool for each but delete. See
[Outreach templates](/templates#outreach-templates).
- **Retention signals.** `GET /v1/pulse-signals` lists the reasons BrokerPlus raised to reach a
past client now, an equity drop or a date of theirs, and `GET /v1/pulse-triggers` reads the
broker's date triggers. Both are read-only. The MCP server has a tool for each. See
[Retention signals](/equity-reports#retention-signals).
- **A locked deal refuses opportunity changes.** `PATCH /v1/opportunities/{id}` on a deal in a stage
that locks it now answers `403 DEAL_LOCKED` for every figure, not only `segments`, as BrokerPlus
does. `name` and the rate assumptions (`comparison_rate`, `effective_penalty`, `penalty_type`,
`cashback_enabled`, `cashback_override`) can still change.
- **Recorded meetings.** `GET /v1/meetings` lists the video meetings BrokerPlus took notes in, with
its summary, and `GET /v1/meetings/{id}` adds the transcript, who spoke and for how long, the
follow-ups, and the meeting chat. The recording itself is not on the API. The MCP server has a
tool for each. See [Read recorded meetings](/log-calls#read-recorded-meetings).
- **An automation run's steps.** `GET /v1/automation-runs/{id}/steps` lists what each step of a run
did or will do: the draft it wrote, the message it sent, the task it made, or why it was skipped.
The MCP server has a tool for it. See [Read the runs](/automations#read-the-runs).
- **Creditor life insurance.** `GET /v1/deals/{id}/life-insurance` reads the deal's creditor life
insurance, and `POST /v1/deals/{id}/life-insurance/outcome` records what the client said when the
broker offered it. Nothing is sent to the insurer. The MCP server has a tool for each. See
[Creditor life insurance](/model#creditor-life-insurance).
- **Credit consents lapse after 30 days.** A `credit_pull` consent now always has an `expires_at`,
30 days after `granted_at`, however it was captured. On `POST /v1/deals/{id}/consents`, an earlier
`expires_at` is kept and a later one is cut to 30 days. Credit consents that had no expiry took the
same 30 days from their own grant date, so one given more than 30 days ago now reads as expired.
- **Clients' equity report corrections.** `GET /v1/equity-report-changes` lists the corrections
clients made to their equity reports, and `POST /v1/equity-report-changes/{id}/accept` and
`/decline` answer one waiting for the broker, as the report changes queue does. Neither sends
anything to the client. The MCP server has a tool for each. See
[Answer a client's correction](/equity-reports#answer-a-clients-correction).
- **Send a funding package for signature.** `POST /v1/deals/{id}/funding-package/send` asks the
broker to send the package. The response is `202` with a pending `funding_package.send` approval,
bound to the package as it is. `GET /v1/deals/{id}/signing-envelopes` and
`GET /v1/deals/{id}/signing-envelopes/{envelopeId}` read the envelopes that went out, each with
its `signing_envelope_recipient` list. The MCP server has a tool for each. See
[Send it for signature](/files#send-it-for-signature).
- **Mail merges.** `POST /v1/mail-merges` asks the broker to send one email to each address on a
list, each person on their own copy. The response is `202` with a pending `mail_merge.send`
approval that covers the whole list. `GET /v1/mail-merges`, `GET /v1/mail-merges/{id}` and
`GET /v1/mail-merges/{id}/recipients` show what became of each address. An approval's
`target.type` can now be `user`. The MCP server has a tool for each. See
[Mail merges](/mail#mail-merges).
- **Credit notes.** `GET /v1/deals/{id}/credit-notes` lists the broker's notes on the credit of the
people on a deal, and `PATCH /v1/deals/{id}/credit-notes/{contactId}` writes one. Only the broker's
words are returned: no credit score or report. The MCP server has a tool for each. See
[Credit notes](/model#credit-notes).
- **Consent forms.** `GET /v1/deals/{id}/consent-forms` and `GET /v1/deals/{id}/consent-forms/{formId}`
read the consent forms emailed to the borrowers on a deal: the scope, the words, the status, and
the consent that a signature recorded. `POST /v1/deals/{id}/consent-forms` asks the broker to email
one: the response is `202` with a pending `consent_form.send` approval. The MCP server has a tool for
each. See [Ask a borrower for consent](/client-asks#ask-a-borrower-for-consent).
- **What a client said on a call.** `GET /v1/contacts/{id}/captured-answers` lists the intake
answers BrokerPlus heard on a call or in an email, with what the client said word for word in
`heard_as`. Add `pending=true` for the ones the next intake will fill in. The MCP server has a
tool for it. See [Read what the client said on a call](/intake#read-what-the-client-said-on-a-call).
- **Goals.** `GET`, `POST /v1/goals` and `GET`, `PATCH`, `DELETE /v1/goals/{id}` read and set the
broker's goals from the Business pages: funded volume, funded deals or commission over a period,
for the whole book or one lender. Each goal carries `pace`, where the broker stands today. The MCP
server has a tool for each, except delete. See [Goals](/broker-view#goals).
- **Introductions to a partner.** `GET`, `POST /v1/partner-introductions` and `GET`,
`DELETE /v1/partner-introductions/{id}` read and record the clients and leads a broker introduced to
a referral partner. Nothing is sent. The MCP server has a tool for each, except delete. See
[Introductions to a partner](/workspace#introductions-to-a-partner).
- **Partner firm notes.** `GET`, `POST /v1/partner-company-notes` and `GET`, `PATCH`,
`DELETE /v1/partner-company-notes/{id}` read and write the brokerage's note on each partner firm,
as on the Partners page. The MCP server has a tool for each, except delete. See
[Partner firm notes](/workspace#partner-firm-notes).
- **Imports.** `GET /v1/import-runs` and `GET /v1/import-runs/{id}` follow the imports on the
broker's teams, and `POST /v1/import-runs/files` adds a deals file to the team's open import.
Nothing reaches the book until the broker reviews the import in BrokerPlus.
`GET` and `PATCH /v1/import-status-routes` read and save where an import puts a deal by its status
word. `GET /v1/import-mappings`, `GET`, `PATCH` and `DELETE /v1/import-mappings/{id}` read and
change the column mappings saved from past imports; only an admin's key changes one. The MCP
server has a tool for each, except delete. See [Imports](/reports-and-data#imports).
- **Data exports.** `POST /v1/data-exports` requests an export of one package of the broker's
records, `GET /v1/data-exports` and `GET /v1/data-exports/{id}` follow it, and
`POST /v1/data-exports/{id}/cancel` stops it. `GET /v1/data-exports/packages` lists the packages
you can export.
The broker downloads the files in BrokerPlus. The MCP server has a tool for each. See
[Data exports](/reports-and-data#data-exports).
- **Team chat.** `GET /v1/direct-threads` lists the broker's team chat conversations, and
`POST /v1/direct-threads` opens one with a teammate or starts a group.
`GET` and `POST /v1/direct-threads/{id}/messages` read and post messages, and
`POST /v1/direct-threads/{id}/read` marks a conversation read. `GET /v1/direct-threads/roster`
lists who the broker can message. A key reaches only the conversations its broker is in. The MCP
server has a tool for each. See [Team chat](/workspace#team-chat).
- **Documents on a condition.** A condition now lists the documents attached to it in `documents`,
and `POST /v1/conditions/{id}/documents` attaches more from the same deal. Nothing is sent. The MCP
server has a tool for it. See [Conditions](/model#conditions).
- **Report templates and bulk reports.** `GET`, `POST /v1/report-templates` and `GET`, `PATCH`,
`DELETE /v1/report-templates/{id}` read and write the brokerage's report builder templates.
`POST /v1/bulk-report-runs` makes one PDF report for each deal from a template or a layout, and
`GET /v1/bulk-report-runs` and `GET /v1/bulk-report-runs/{id}` follow the runs.
`GET /v1/bulk-report-runs/{id}/files` gives links to the PDFs and the ZIP file. Nothing is sent
to a client. The MCP server has a tool for each, except delete and the file links. See
[Reports and data](/reports-and-data).
- **Files that came in by email.** `GET /v1/deals/{id}/email-attachments` lists the files from a
client's email that wait for the broker before they go on the deal file, with a `status` filter.
`POST .../accept` puts one on the deal file as a document, and `POST .../dismiss` keeps it off.
The MCP server has a tool for each. See [Files that came in by email](/files#files-that-came-in-by-email).
- **Addresses and phone numbers on an applicant.** `GET /v1/deals/{id}/applicants/{applicantId}` now
returns `addresses`, the applicant's address history with dates and a mailing address, and
`phones`, the numbers the lender sees. `POST /v1/deals/{id}/applicants/{applicantId}/addresses`
adds an address, and `PATCH` and `DELETE` on `.../addresses/{addressId}` change or remove one. The
same three work on `.../phones`. The MCP server has a tool for each, except delete. See
[Edit the application one row at a time](/model#edit-the-application-one-row-at-a-time).
- **PEP declaration forms.** `GET /v1/pep-forms` and `GET /v1/pep-forms/{id}` read the
politically exposed person (PEP) forms sent to clients: the status, and the client's answers.
`POST /v1/pep-forms` asks the broker to email the form to a client: the response is `202` with a
pending `pep_form.send` approval. The MCP server has a tool for each. See
[Ask a client to declare PEP status](/client-asks#ask-a-client-to-declare-pep-status).
- **Lender commitments.** `GET /v1/deals/{id}/commitments` and
`GET /v1/deals/{id}/commitments/{commitmentId}` return the terms BrokerPlus read from the lender's
commitment PDF, with the broker's corrections and the cost of borrowing. `POST /v1/deals/{id}/commitments`
reads a PDF, and `POST .../review` marks the terms as checked, with corrections. The MCP server
can read commitments, but it cannot read a PDF or review one. See
[Lender commitments](/model#lender-commitments).
- **Value history of a deal's property.** `GET /v1/deals/{id}/property-valuations` lists each
valuation of the property the deal's mortgage is on, newest first: the home value trend on the
client's equity report. The MCP server has a tool for it. See
[Value history of the deal's property](/model#value-history-of-the-deals-property).
- **Split mortgages.** An opportunity returns `segments`: the parts of a split mortgage, such as a
fixed portion beside a line of credit, with each part's balance, rate, term and lender. Rates are
percent. `PATCH /v1/opportunities/{id}` takes `segments` and replaces every segment. See
[Change what the report shows](/equity-reports#change-what-the-report-shows).
- **Assistant instructions.** `GET`, `POST /v1/agent-instructions` and `GET`, `PATCH`,
`DELETE /v1/agent-instructions/{id}` read and write the standing instructions a broker gives their
assistant, the same list as Assistant settings in BrokerPlus. Only an admin can write a brokerage
instruction. The MCP server has a tool for each, except delete. See
[Assistant instructions](/workspace#assistant-instructions).
- **E-signing rules.** `GET`, `POST /v1/signing-policies` and `DELETE /v1/signing-policies/{id}`
read and set which connected e-signing provider a team's documents go out through: the default,
and an exception per lender. A rule names the provider. The MCP server can list and set rules,
but not delete one. See [E-signing rules](/compliance#e-signing-rules).
- **The compliance program.** `GET /v1/compliance/program` reads the brokerage's compliance program,
and `PATCH` changes the facts about the brokerage that the borrower disclosure prints:
whom it acts for, how many lenders it acted for last year, and whether it lends. The rest of
the program changes only in BrokerPlus. `GET /v1/compliance/trainings`,
`GET /v1/compliance/retention-policies`, `GET /v1/compliance/verifier-policy` and
`GET /v1/compliance/monitoring-alerts` read the training records, how long records are kept, the
document check, and the alerts that screening raised. The MCP server has a tool for each, except
the alerts. See [The compliance program](/compliance#the-compliance-program).
- **A deal's compliance package.** `GET`, `POST`, `PATCH /v1/deals/{id}/compliance/package` read,
make from a template, and change the one PDF that holds a deal's documents for a lender or an
auditor. `POST /v1/deals/{id}/compliance/package/build` builds it, and is refused while a required
document is missing or left out. `GET /v1/deals/{id}/compliance/package/download` answers a link
to the PDF. The MCP server has a tool for each, except the download. See
[A deal's package](/compliance#a-deals-package).
- **Compliance package templates.** `GET`, `POST /v1/compliance/package-templates` and `GET`,
`PATCH`, `DELETE /v1/compliance/package-templates/{id}` read and change the layouts that a deal's
compliance package is built from: a team's own templates, and the brokerage's, which only a
compliance administrator changes. `GET /v1/compliance/package-requirements` lists what a slot can
hold. The MCP server has a tool for each, except delete. See
[Package templates](/compliance#package-templates).
- **Evidence for a custom requirement.** In `GET /v1/deals/{id}/compliance`, each of the
`document_requirements` now has `custom_obligation_id` and `evidence`, the documents that answered
it. `POST /v1/deals/{id}/compliance/custom-obligations/{obligationId}/documents` uploads a file
for a custom requirement, and `DELETE` on `.../documents/{documentId}` takes one off. The MCP
server can upload, but it cannot take a document off. See
[Evidence for a custom requirement](/broker-view#evidence-for-a-custom-requirement).
- **Identity verifications.** `GET /v1/deals/{id}/identity-verifications` lists the identity
verifications of the people on a deal, with the evidence each one examined. A document number
shows only its last four characters, and the images and the reports are not published. The MCP
server has no tool for it. See [Identity verifications](/broker-view#identity-verifications).
- **Deal risk assessment.** `GET` and `PATCH /v1/deals/{id}/risk-assessment` read and save the
FINTRAC risk assessment of a deal: the transaction types, the fraud questions, the FINTRAC report,
and the risk level. A save adds the answers you send to the answers on file. The MCP server has no
tools for it. See [Deal risk assessment](/broker-view#deal-risk-assessment).
- **Know-your-client records.** `GET /v1/deals/{id}/kyc-records` and `GET`,
`PATCH /v1/deals/{id}/kyc-records/{contactId}` read and save the FINTRAC information record of each
party on a deal, as the KYC editor does. `GET /v1/deals/{id}/entity-verifications` and `GET`,
`PATCH /v1/deals/{id}/entity-verifications/{contactId}` do the same for the entity verification of
a company. An imported ID number shows only its last four characters. The MCP server has no tools
for these records. See [Know-your-client records](/broker-view#know-your-client-records).
- **The changes asked of a client.** `GET /v1/intakes/{id}/change-requests` lists every answer and
file the broker asked a client to fix, with a `status` filter. `PATCH` rewords one,
`POST .../resolve` and `POST .../reopen` mark it done or open again, and `DELETE` withdraws it.
None of these sends anything. The MCP server has a tool for each, except delete. See
[Follow up on the changes you asked for](/intake#follow-up-on-the-changes-you-asked-for).
- **Strategy analyses.** `GET`, `POST /v1/strategy-analyses` and `GET`, `PATCH`,
`DELETE /v1/strategy-analyses/{id}` read and write the strategy board's analyses: up to 8 mortgage
options compared against one, with the client's plans and the note that explains the
recommendation. Rates are percent. The MCP server has a tool for each, except delete. See
[Strategy analyses](/planning#strategy-analyses).
- **Booked meetings are leads.** A lead's `source` and a contact's `created_via` take the new value
`booking`: a person who booked a meeting through one of the broker's booking links. It fires
`lead.created` like a form lead. A lead sent with `POST /v1/leads` now also lands on the broker's
Where New Leads Go column, the same as a form lead.
- **Maturity date on the application.** `PATCH /v1/deals/{id}/application` takes `maturity_date`.
This is the date that stage requirements check. The `maturity_date` on `PATCH /v1/deals/{id}` is a
different field, and stage requirements do not read it.
- **Ground rent and the condo fee.** A subject property has `annual_ground_rent`, the yearly ground
rent on leasehold land. When `condo_fee_tds_pct` is `null`, BrokerPlus counts all of the condo fee
in the debt service ratios. Before this change, it counted half.
- **Secured lines of credit.** A liability has `secured_by_home`, `true` when the broker marked a line
of credit as secured against a home. The debt service ratios count such a line at the higher of its
stated payment and its balance repaid over 25 years at the qualifying rate.
- **How BrokerPlus calculates `gds` and `tds` changed.** These rules are new, and each one can change
`gds` and `tds` on a deal that already has them:
- A subject property with no property tax is charged the tax bill on file, when one matches its
address as the application holds it now. Otherwise it is charged 1% a year of the purchase price or the appraisal, whichever is
lower.
- A subject property with no heating is charged the heating the client gave. Otherwise it is charged
$100 a month, or $0.75 a square foot a year if that is higher.
- Every other property that counts in `tds` and has no property tax is charged 1% of its value a
year. One with no heating is charged $100 a month.
- Each rental property's rent is offset or added back by its own stated treatment and percent.
Before this change, the first rental in the list set the treatment for all of them.
- An instalment or student loan that owes money and has no payment, or a $0 payment, is charged 3%
of its balance a month.
- A credit card or an unsecured line of credit is charged the higher of its stated payment, 3% of the
balance and $10.
- Child benefit counts as at most 30% of the qualifying income.
- Ground rent counts in full.
A zero tax, heating or payment counts as blank in each of these rules.
## 2026-10-09
- **Files on snippets.** `POST /v1/snippets/{id}/attachments` adds a file to a snippet, and
`DELETE /v1/snippets/{id}/attachments/{attachmentId}` removes one. The file's type is read from its
content, and content with no recognisable type is taken only as CSV. The MCP server has a tool to add
a file, but not to remove one. See [Snippets](/mail#snippets).
- **What the checks raised on an intake.** `GET /v1/intakes/{id}/flags` lists the flags BrokerPlus's
checks raised on a client's intake, the most serious first, with a `status` filter.
`POST /v1/intakes/{id}/flags/{flagId}/override` sets one aside with the broker's reason, as
**Override** does in BrokerPlus. The MCP server has a tool for both. See
[Review what the checks raised](/intake#review-what-the-checks-raised).
- **Participant seats.** `GET`, `POST`, `PATCH`, and `DELETE` on `/v1/participant-seats` read, add,
pin, and remove the seats that a team's deals keep for a participant, with a default person for
each. `GET /v1/deals/{id}/participant-seats` shows which seats one deal has, and
`POST /v1/deals/{id}/participant-seats/{seatId}/adopt` and `.../dismiss` change that deal only.
The MCP server has a tool for each read and for adopt and dismiss. See
[Participant seats](/workspace#participant-seats).
- **Offers and scenarios on a deal.** `GET`, `POST /v1/deals/{id}/offers` and `GET`, `PATCH`,
`DELETE /v1/deals/{id}/offers/{offerId}` read and write the lender offers on a deal's offers tab,
with their fees. `GET /v1/deals/{id}/offers/compare` is the tab's side-by-side comparison: payment,
insurance, penalty, fees and APR for each offer, and each scenario priced at its rates.
`/v1/deals/{id}/scenarios` reads and writes the deal's what-if scenarios the same way. Rates are
percent. The MCP server has a tool for each, except delete. See
[Offers and the comparison](/planning#offers-and-the-comparison).
- **Lead forms.** `GET`, `POST`, `PATCH`, and `DELETE` on `/v1/lead-forms` build, edit, and delete the
forms a broker puts on a website, with the whole form in `config`, its page in `url`, and how many
leads it brought in. `GET /v1/leads` takes `lead_form_id` to list one form's leads. A write cannot
turn on a form's auto-reply: `POST /v1/lead-forms/{id}/turn-on-auto-reply` asks the broker, who
approves it in BrokerPlus. `GET /v1/approvals` has the new action `lead_form.auto_reply` and the
target type `lead_form`. The MCP server has a tool for each, except delete. See
[Lead forms](/capture-leads#lead-forms).
- **Who referred whom.** `GET`, `POST /v1/contact-referrals` and `GET`, `PATCH`,
`DELETE /v1/contact-referrals/{id}` read and write the referral between two contacts: the person
who sent a client to the broker, with its kind, status, note, and linked deal. The MCP server can
list, read, record, and edit them. See [Who referred whom](/workspace#who-referred-whom).
- **Stop automations messaging a client.** `POST /v1/automations/stops` stops automations from
messaging a client, for every deal or one, and for every kind of automation or some. It happens now.
`GET /v1/automations/stops` lists the stops. `POST /v1/automations/stops/{id}/lift` asks the broker
to lift one, with the new approval action `automation_stop.lift`, and the new approval target type
`automation_stop`. The MCP server has a tool for each. See
[Stop automations messaging a client](/automations#stop-automations-messaging-a-client).
- **Automation canvas groups.** `GET`, `POST`, `PATCH`, and `DELETE /v1/automations/canvases` read
and change the groups that automations are filed under on the automation canvas.
`POST /v1/automations/canvases/{id}/turn-off` turns a group off now.
`POST /v1/automations/canvases/{id}/turn-on` turns a group on: an automation that can message
clients waits for the broker's approval, the same as one turned on by itself. The MCP server has a
tool for each operation except the delete. See [Group automations on the canvas](/automations#group-automations-on-the-canvas).
- **Call lists.** `/v1/call-lists` makes, renames, and deletes the broker's call lists, from contacts,
deals, or rows of names and numbers (`POST /v1/call-lists/import`). `/v1/call-lists/{id}/entries`
lists the people on a list, adds more, records each call's outcome and note, and takes a person off.
Nothing is sent to anybody. The MCP server has a tool for each, except the deletes. See
[Call lists](/log-calls#call-lists).
- **Mentions, reactions, and files on notes.** A deal note and a contact note now have `mentions`,
`reactions`, and `attachments`. `GET /v1/deals/{id}/notes/mentionable` lists who a note can
mention. `POST /v1/deals/{id}/notes/{noteId}/reactions` adds or takes off an emoji reaction.
`POST /v1/deals/{id}/notes/{noteId}/attachments` attaches a PDF, and
`GET /v1/deals/{id}/notes/{noteId}/attachments/{attachmentId}` gives a link to it. The same paths
work under `/v1/contacts`. The MCP server has a tool for each, except the download link. See
[Mentions, reactions, and files](/model#mentions-reactions-and-files).
- **Debt consolidation analysis.** `POST /v1/deals/{id}/consolidation-analyses` compares paying out
the client's debts with a refinance, a line of credit, or a reverse mortgage, the same as the Debts
tab. `GET /v1/deals/{id}/consolidation-analyses` and
`GET /v1/deals/{id}/consolidation-analyses/{analysisId}` read the saved analyses. A debt that you add
or change through `/v1/deals/{id}/liabilities` now takes `debt_type`, which puts it in the analysis.
The MCP server has a tool for each. See [Debt consolidation analysis](/model#debt-consolidation-analysis).
- **Who a deal is waiting on.** `GET /v1/work-watches` and `GET /v1/work-watches/{id}` read what each
deal is waiting for, who owes the next move, since when, and how overdue it is: the waiting-on
chips on a deal card. They are read-only. The MCP server has a tool for each. See
[Who a deal is waiting on](/broker-view#who-a-deal-is-waiting-on).
- **Notes on a document.** `GET` and `POST` on `/v1/documents/{id}/notes`, and `PATCH` and `DELETE` on
`/v1/documents/{id}/notes/{noteId}`, read and write the notes brokers keep on one deal document. Only the
author changes or deletes a note. The MCP server has a tool for each, except delete. See
[Notes on a document](/files#notes-on-a-document).
- **PDFs in the client hub.** `GET`, `POST` and `DELETE` on `/v1/hub-resources` list, add and remove
the PDFs on the Resources tab of a client's hub, for every client or for one deal's client. The MCP
server can list them. See [PDFs in the client hub](/files#pdfs-in-the-client-hub).
- **The team's own document types.** `GET`, `POST` and `DELETE` on `/v1/custom-document-types` read,
add and remove the documents a team asks for that BrokerPlus does not know, each with the `custom:`
code a document package names. Nothing reaches a client. The MCP server has a tool for each, except
remove. See [The team's own document types](/files#the-teams-own-document-types).
- **Equity report send and open history.** `GET /v1/equity-reports/sends` lists each time a report
went out to a client, with its channel, cadence, and whether it left BrokerPlus.
`GET /v1/equity-reports/interactions` lists what clients did with their reports: opened the email,
opened the report, asked for a call, or corrected a figure. Both take `updated_since` and
`after_id`. The MCP server has a tool for each. See
[Read the send and open history](/equity-reports#read-the-send-and-open-history).
- **Valuation history of an other property.** `GET /v1/deals/{id}/other-properties/{propertyId}/valuations`
lists each valuation of the property, newest first, and the change in its estimate over the last
year as a percent. `POST /v1/deals/{id}/other-properties/{propertyId}/refresh-valuation` asks for a
new estimate in the background. The MCP server has a tool for each. See
[Valuation history of an other property](/model#valuation-history-of-an-other-property).
- **Call scripts.** `GET`, `POST`, `PATCH`, and `DELETE` on `/v1/call-scripts` read, make, change, and
retire the broker's own call scripts, the questions they read on a call. The MCP server has a tool for
each, except retire. See [Call scripts](/log-calls#call-scripts).
- **Note types.** `GET /v1/note-types` lists the kinds that a deal note or a contact note can have.
`POST /v1/note-types`, `PATCH /v1/note-types/{scope}/{slug}` and `DELETE /v1/note-types/{scope}/{slug}`
add, change and remove kinds for the broker's team. `POST /v1/deals/{id}/notes` and
`POST /v1/contacts/{id}/notes` accept `type`, so a new note can have a kind other than `general`.
The MCP server has a tool for each. See [Note types](/workspace#note-types).
- **The broker's own rates.** `GET` and `POST /v1/me/rate-overrides` read and set the rates a broker
keeps on the Rates page, in percent. `DELETE /v1/me/rate-overrides/{id}` removes one, and
`POST /v1/me/rate-overrides/reset` goes back to live rates. `POST /v1/me/custom-lenders` adds
custom lenders with their rates, and `PATCH /v1/me/custom-lenders/{lenderSlug}` renames one.
`/v1/me/lender-offsets` reads, sets, and removes per-lender offsets in basis points. The MCP server
has a tool for each read and set. See [Rates](/rates).
- **Funding packages.** `GET /v1/deals/{id}/funding-package` returns the lender's funding forms that
the broker is preparing on a deal. Add forms with `POST /v1/deals/{id}/funding-package/items` or
`POST /v1/deals/{id}/funding-package/upload`, map their fields with
`POST /v1/deals/{id}/funding-package/items/{item_id}/map`, and set the signer, fields and state
with `PATCH /v1/deals/{id}/funding-package/items/{item_id}`. The broker sends the package in
BrokerPlus. The MCP server has a tool for the reads, the add, the map and the update. See
[Funding packages](/files#funding-packages).
- **Partner roles on a contact.** `GET`, `POST /v1/contacts/{id}/partner-roles` and `PATCH`,
`DELETE /v1/contacts/{id}/partner-roles/{roleId}` list, add, edit, and remove what a contact does
for the broker's clients, such as `realtor` or `lawyer`, with a title, website, and booking link.
The MCP server can list, add, and edit them. See [Partner roles](/workspace#partner-roles).
- **Snippets.** `GET /v1/snippets` lists the saved replies of the broker your key acts as, and
`POST`, `PATCH` and `DELETE` on `/v1/snippets` add, edit and delete them, the same as the snippet
manager in the inbox. `GET /v1/snippets/{id}/attachments/{attachmentId}` gives a link to a
snippet's file. The MCP server can list, read, add and edit them. See [Snippets](/mail#snippets).
- **Mail folders.** `GET /v1/communication-folders` lists the folders and labels of a connected
mailbox. `POST /v1/communications/{id}/folders` files a whole conversation into a folder, and
`DELETE /v1/communications/{id}/folders/{folderId}` takes it out; the move also happens in the
broker's mail app. Each folder in a communication's `folders` now has its `id`. The MCP server has a
tool for each, except taking a conversation out. See
[File a conversation in a folder](/mail#file-a-conversation-in-a-folder).
- **Inbox tags.** `GET /v1/communication-tags` lists the brokerage's inbox tags, and `POST`, `PATCH`
and `DELETE` on `/v1/communication-tags` add, rename and archive them.
`POST /v1/communications/{id}/tags` puts a tag on a whole conversation, and
`DELETE /v1/communications/{id}/tags/{tagId}` takes it off. A communication now lists its tags in
`tags`, each with its `id`, `name` and `color`. The MCP server has a tool for each, except taking a
tag off and archiving one. See [Tag a conversation](/mail#tag-a-conversation).
- **Mortgage plans.** `GET /v1/mortgage-plans`, `GET /v1/mortgage-plans/{id}`, `POST /v1/mortgage-plans`,
`PATCH /v1/mortgage-plans/{id}`, and `DELETE /v1/mortgage-plans/{id}` read and build the plans
from the plan builder, on a deal or a contact. Rates are percent. The MCP server has a tool for
each, except delete. See [Plans and comparisons](/planning#mortgage-plans).
- **Team note.** `GET /v1/teams/{id}/note` reads the note that a team's members share on their
overview, and `PATCH /v1/teams/{id}/note` replaces it. Only a member of the team can reach it. The
MCP server has a tool for each. See [Team note](/workspace#team-note).
- **Document packages.** `GET`, `POST`, `PATCH` and `DELETE` on `/v1/doc-packages` read and change
the named sets of documents that the broker's team asks for together. `GET /v1/doc-packages/samples`
lists the situations BrokerPlus recognizes, and `POST /v1/doc-packages/samples/{key}` adds one as a
package. `GET /v1/doc-packages/{id}` reads one. Nothing reaches a client. The MCP server has a tool for
each, except delete. See
[Document packages](/files#document-packages).
- **The client tracker.** `/v1/tracker-stages` lists, adds, changes, reorders, and removes the stages
a client sees on their hub, and sets the note from the broker. `GET /v1/deals/{id}/tracker` shows
one deal's tracker as the client sees it. `POST /v1/deals/{id}/tracker/{stageId}/reach` returns a
pending `tracker.reach` approval: the client sees the stage only when the broker approves.
`POST /v1/deals/{id}/tracker/{stageId}/retract` takes stages back down at once. The MCP server can
read the tracker and ask to reach a stage. See [Client tracker](/workspace#client-tracker).
- **What a client was asked for, item by item.** `GET /v1/intakes/{id}/items` lists each document,
form, consent and identity check on the client's portal, with each applicant's state and the files
they sent. `GET /v1/intakes/{id}/uploads` and `GET /v1/intakes/{id}/uploads/{uploadId}` read the
files. `POST /v1/intakes/{id}/uploads/{uploadId}/stage` and `POST /v1/intakes/{id}/items/{key}/stage`
accept a file or an item, as a move on the document board does. `POST /v1/intakes/{id}/request-changes`
now takes `uploads` to refuse a file and ask the client for a new copy, and `changes` is optional.
See [Read what the client was asked for](/intake#read-what-the-client-was-asked-for).
- **Request templates.** `GET /v1/request-templates` lists the broker's request library, and
`GET /v1/request-templates/{id}` reads one template with its items. You can create, change, copy,
star, reset and delete a template, and add, change, order, reset and remove its items. Building a
template sends nothing. The MCP server has a tool for each, except the two deletes. See
[Request templates](/client-asks#request-templates).
- **Ask for documents from a template.** `POST /v1/deals/{id}/document-requests` accepts
`template_id`, and asks for the documents on that request template. `documents` is now optional
when you send `template_id`. See [Ask from a request template](/client-asks#ask-from-a-request-template).
- **The brokerage's rates.** `/v1/brokerage/rate-overrides` and `/v1/brokerage/lender-offsets` read,
set, and remove the rates and offsets a brokerage sets for every broker, in percent and basis
points. `POST /v1/brokerage/rate-overrides/reset` goes back to live rates. Only a brokerage admin's
key can call them. The MCP server has a tool for each read and set. See
[The brokerage's rates](/rates#the-brokerages-rates).
- **Lender cashback.** `GET /v1/lender-cashback` lists the cashback each lender pays, in percent, as
the opportunity figures use it. `POST /v1/lender-cashback` and `DELETE /v1/lender-cashback/{lenderSlug}`
set and remove the brokerage's own, with a brokerage admin's key. The MCP server has a tool to read
and to set. See [Lender cashback](/rates#lender-cashback).
- **Rate sheets.** `POST /v1/rate-sheets` uploads a lender's rate sheet and answers with the rates
BrokerPlus read from it, in percent, and the cells they fit. `POST /v1/rate-sheets/{id}/apply` puts
the rows you choose on the broker's own rates. `GET` lists and reads sheets, and `DELETE` removes
one. The MCP server has a tool for each, except delete. See [Rate sheets](/rates#rate-sheets).
- **Partner hub links.** `POST /v1/partner-hubs` opens a referral partner's hub and issues a link that
the partner opens to see every file they are on with the broker. `POST /v1/partner-hubs/{id}/link`
issues another link to a hub. `GET /v1/partner-hubs` and `GET /v1/partner-hubs/{id}` list the hubs
and their working links. BrokerPlus sends nothing: you give the link to the partner. The MCP server
has a tool for each. See [Partner hub links](/workspace#partner-hub-links).
- **NPS alert rules.** `GET`, `POST`, `PATCH`, and `DELETE` on `/v1/nps-alert-rules` read and change
the rules that alert brokers and add a task when a client answers a survey. The MCP server has a
tool for each, except delete. See [NPS alert rules](/workspace#nps-alert-rules).
- **NPS surveys.** `GET /v1/nps-surveys` and `GET /v1/nps-surveys/{id}` read the surveys sent to
clients, with each score, its band, and the comment. `POST /v1/nps-surveys` asks the broker to
email a client the survey; it waits for the broker's approval, under the new approval action
`nps.send`. The MCP server has a tool for each. See
[Ask a client for a score](/client-asks#ask-a-client-for-a-score).
- **Choose the finished files on a partner hub.** A partner hub has `show_funded` and
`show_not_proceeding`. They choose if the hub lists the files that funded and the files that ended
without funding. Both are `true` by default. `POST /v1/partner-hubs` accepts either one.
## 2026-10-08
- **Record a funding date on a closed deal.** `PATCH /v1/deals/{id}` accepts `funded_at` on a deal
that is already `closed`. The status stays `closed`. An imported file that its source system marked
funded takes the date as is. On any other closed deal, the date counts the deal as funded, so it
needs the same anti-money laundering (AML) clearance as a change to `funded`. You can't close a deal
and date it in one request.
- **Write accelerated payments.** `PATCH /v1/deals/{id}/application` now accepts `payment_accelerated`:
`true`, `false`, or `null`. Pair it with `payment_frequency: 26` for bi-weekly or `52` for weekly.
- **Invalid numeric codes name the choices.** Validation errors now list each accepted code and
label. The API reference also lists the accepted numeric codes. See [Units and formats](/units).
- **You can cancel an approval you asked for.** `POST /v1/approvals/{id}/cancel` withdraws a
`pending` approval before the broker answers. It becomes `rejected` with the reason
`Cancelled by the requester.` Only the key that asked can cancel it.
- **Asking twice files one approval.** A repeat of a request that is still `pending` (same action,
record, recipients, sender and words, from the same person) returns the existing approval instead
of a second card for the broker.
- **An approval whose record is deleted is voided.** Deleting the campaign, appointment, automation,
automation run or deal an approval is about now voids it at once, with `"changed": ["missing"]`,
instead of leaving it `pending`.
- **Registering the same webhook twice is refused.** `POST /v1/webhooks` with a URL and events you
already registered returns `409` `WEBHOOK_EXISTS`, with the existing endpoint in
`details.webhook_id`. The same URL for different events, or another broker's endpoint on it, is
still allowed.
- **Every authenticated response carries `Cache-Control: no-store`**, so no proxy or HTTP cache keeps
a broker's data.
- **The MCP server refuses an argument a tool does not take.** It used to drop it, so a misspelled
filter returned every row. The error names the argument and lists the ones the tool takes.
- **An unknown filter value is refused.** `status` on deals and on a deal's conditions, `type` on
automations, and `include_archived` on templates now return `400` `VALIDATION_ERROR` with the
values they take. A value such as `status=BOGUS` used to return an empty page. The filters on
approvals, automation runs, custom fields, and `archive_deals` on a pipeline delete now name the
parameter in `details.fieldErrors` too.
- **`after_id` works on every list that documents it.** Templates, automations, meeting types, a
deal's documents, and a deal's terms history now resume a sync after the row you name, and return
`400` `VALIDATION_ERROR` when `after_id` comes without `updated_since`. They used to ignore it.
`GET /v1/contacts/duplicates` refuses `after_id`, because a set has no position to resume after.
- **Fixed: an equity report's `ltv` could read 100 times too high.** Some reports returned `5170`
where the loan-to-value was `51.7`. Every report now returns its `ltv` as a percent.
- **A control character in a query value is refused.** A NUL or another control character in any
query parameter, such as `lender_file_ref=%00`, now returns `400` `VALIDATION_ERROR` naming the
parameter. It used to return `500`. Tabs and line breaks are still accepted.
- **A `page` out of range is refused.** Every list takes `page` from `1` to `1000000`. A larger
`page`, or a `page` or `page_size` that is not a whole number, now returns `400`
`VALIDATION_ERROR` naming the parameter. A very large `page` used to return `500`, and a `page`
such as `abc` was read as page `1`. See [Pagination](/pagination).
- **A date that does not exist is refused.** `updated_since`, `from` and `to` on availability, and a
report's `start` and `end` now return `400` `VALIDATION_ERROR` for a date such as `2026-02-30`
or a time such as `24:00`. These used to be read as the next real date, so
`updated_since=2026-02-30T00:00:00Z` quietly meant March 2.
- **`GET /v1/changes` refuses a checkpoint sent with more `types` than it was issued for.** A
checkpoint moves past every entry its own `types` covered, so reading more types from it skipped
their entries without a word. It now returns `400 INVALID_CHECKPOINT`. The same `types`, or
fewer, still work. A checkpoint from `after=now` belongs to the `types` sent with it, or to
every type when none were sent. A `types` value that names no type, such as `,,,`, now returns
`400` instead of `500`, and so does a malformed checkpoint. See [Sync](/sync).
- **Breaking: a referral partner's `conversion_rate` is now a percent.** It was a decimal fraction,
such as `0.25`. It is now `25`, like every other rate. This is on `GET /v1/referral-partners`
and `GET /v1/referral-partners/{id}`. See [Units and formats](/units).
## 2026-10-07
**Summary: the API grew to about 300 operations, with three breaking changes.**
If you built on the API before today, check these three first:
1. **Rates on an opportunity, and the `ltv` on an equity report, are now percents.**
`target_new_rate`, `breakeven_rate`, and `ltv` were decimal fractions, such as `0.0499`. They are
now `4.99`. Remove any step that multiplies them by 100. See [Units and formats](/units).
2. **Booking, moving, or cancelling a meeting, asking for intake changes, and emailing an intake now
wait for the broker's approval.** `POST /v1/appointments/book`, `.../reschedule`, `.../cancel`,
and `POST /v1/intakes/{id}/request-changes` return `202` and a pending approval instead of acting
at once. `POST /v1/intakes` with `delivery: "email"` still returns `201`, with an `approval_id`,
and emails nobody until the broker approves. See [Approvals](/approvals).
3. **The action on many tasks moved to `POST /v1/tasks/bulk`.** `POST /v1/tasks/batch` now creates or
updates tasks, like the other batch endpoints.
What is new, in short: batch writes for contacts, leads, deals, and tasks; the mortgage application
one row at a time; document uploads, requests, generated letters, and signing; workspace setup
(custom fields, tags, pipelines, lead stages, task categories, meeting types, hours); automations
and templates; equity report enrollment; the call log; suggestions, notifications, compliance and
credit reads, and activity trails; sandboxes; key limits; and sign-in for MCP connectors. The
entries below have the detail.
These docs were reorganized around the same release: a home page by job, one
[Concepts](/authentication) section, and the old Claude command center page merged into
[Run an AI assistant](/mcp). Old links still work.
**Added: flags on a deal.**
- `GET /v1/deals/{id}/flags` lists the flags that are up on a deal, with each reason in plain words.
- `POST /v1/deals/{id}/flags` raises the broker's flag, and `POST /v1/deals/{id}/flags/{flagId}/clear` takes it down. A flag from an automation or from BrokerPlus is read-only. Refer to [Flags](/model#flags).
**Changed: an approval task cannot be cancelled or reopened with the API.**
- `POST /v1/tasks/{id}/cancel` and `POST /v1/tasks/{id}/reopen` now answer `409` with `APPROVAL_TASK` for an approval task, as `POST /v1/tasks/{id}/complete` already did. Only the broker decides an approval, in BrokerPlus.
**Added: read and set the checklist on a deal.**
- `GET /v1/deals/{id}/checkpoints` lists the steps of the checklist and their values on the deal. `PATCH /v1/deals/{id}/checkpoints/{checkpointId}` sets one step. Refer to [The deal's checklist](/model#the-deals-checklist).
**Changed: the action on many tasks moved to `POST /v1/tasks/bulk`.**
- It was at `POST /v1/tasks/batch` for a few hours today. `POST /v1/tasks/batch` now creates or updates tasks, as it does for contacts, leads, and deals.
**Added: copy a deal, and restore an archived one.**
- `POST /v1/deals/{id}/duplicate` makes a new deal with the same people. `POST /v1/deals/{id}/restore` puts an archived deal back on its pipeline. Refer to [Copy, archive, and restore a deal](/model#copy-archive-and-restore-a-deal).
**Added: batch writes for contacts, leads, deals, and tasks.**
- `POST /v1/contacts/batch`, `POST /v1/leads/batch`, `POST /v1/deals/batch`, and `POST /v1/tasks/batch` create or update up to 100 records in one request. Refer to [Migrate from another CRM](/migrate-from-a-crm#send-records-in-batches).
- Send `external_id` with `namespace`, and a later batch with the same id updates the same record.
- An item that fails does not stop the other items. Each item counts as one request against the rate limit.
- The MCP server does not have batch tools. An assistant writes one record at a time.
**Added: complete, cancel, or reopen many tasks at once.**
- `POST /v1/tasks/bulk` applies one action to up to 200 tasks and lists the tasks it changed and skipped. Refer to [Tasks](/model#tasks).
**Added: the call log, and calls from other phone systems.**
- `POST /v1/calls` puts a call from another phone system or recorder in the call log of the broker, with its transcript. The call shows on the contact and the deal. Refer to [Log calls from your phone system](/log-calls).
- `GET /v1/calls` and `GET /v1/calls/{id}` read the call log, with each transcript and recap.
- The `call.logged` webhook event fires when a call lands in the call log.
- The MCP server has the `brokerplus_log_call`, `brokerplus_list_calls`, and `brokerplus_get_call` tools.
**Added: send documents to sign.**
- `GET /v1/deals/{id}/signing-documents` and `GET /v1/deals/{id}/signing-documents/{documentId}` read a deal's signing package, with where each signing is in `status`.
- `GET /v1/deals/{id}/signing-presets` lists the presets for a deal, and `POST /v1/deals/{id}/signing-documents` adds a saved document, or each one in a preset, for the person who signs it.
- `POST /v1/deals/{id}/signing-documents/{documentId}/preview` returns the document filled in, as a PDF.
- `POST /v1/deals/{id}/signing-documents/send` returns a pending approval with the new action `signing.send`. When the broker approves, each document is filled in and checked again, and each signer gets one email. `POST /v1/deals/{id}/signing-documents/{documentId}/resend` returns a pending approval with the new action `signing.resend`. A key with the `no_sending` limit cannot send or resend. Refer to [Ask a client for something](/client-asks).
**Added: change, copy, pause, and delete campaigns and audiences.**
- `PATCH /v1/campaigns/{id}` changes a draft. `POST /v1/campaigns/{id}/duplicate` copies a campaign as a new draft. `POST /v1/campaigns/{id}/pause` stops a campaign that is sending. `DELETE /v1/campaigns/{id}` deletes a campaign.
- `PATCH /v1/audiences/{id}` and `DELETE /v1/audiences/{id}` change and delete an audience. Refer to [Approvals](/approvals#campaigns).
**Added: act on a conversation in the inbox.**
- `PATCH /v1/communications/{id}` marks a conversation read or unread, marks it done, stars it, sets or clears a reminder, and files its mail on a deal. Refer to [Mail](/mail).
**Added: ask a client for documents, and explain conditions to the people on a deal.**
- `POST /v1/deals/{id}/document-requests` asks the client for documents, as the Request Documents window does. It returns `202` and a pending approval with the new action `document_request.send`. When the broker approves, the documents are added to the client's portal and each applicant gets an email.
- `POST /v1/deals/{id}/condition-requests` writes the letter that explains conditions in plain language, files it as the broker's draft, and returns `202` and a pending `message.send` approval.
- A key with the `no_sending` limit cannot make these requests. Refer to [Ask a client for something](/client-asks).
**Added: generated documents.**
- `GET /v1/document-templates` lists the document templates the key's user can use, for example an approval letter.
- `POST /v1/deals/{id}/generated-documents` makes a document from a template and files the PDF on the deal. If the broker must fix something first, the document stays a `draft` and `issues` says what.
- `GET /v1/deals/{id}/generated-documents` and `GET /v1/deals/{id}/generated-documents/{documentId}` read them.
- `POST /v1/deals/{id}/generated-documents/{documentId}/send` files an email to one person on the deal with the PDF attached, and returns `202` and a pending `message.send` approval. A key with the `no_sending` limit cannot send one, and a key with the `no_documents` limit cannot use any of these.
**Added: rename, refile, verify, and delete documents.**
- `PATCH /v1/documents/{id}` changes `display_name`, `contact_id`, and `excluded_from_submission`.
- `POST /v1/documents/{id}/stage` moves a document between `pending`, `review`, and `verified`, and files a verified document in a `folder`.
- `DELETE /v1/documents/{id}` deletes a document. Refer to [Files](/files).
**Added: change the tags, lead stages, lead channels, and referral partners of a brokerage.**
- `POST /v1/contact-tags` and `POST /v1/deal-tags` create a tag. `PATCH` changes its name or colour, and `DELETE` archives it and takes it off every record.
- `POST /v1/lead-stages` creates a column on the Leads board, `PATCH` renames it, and `POST /v1/lead-stages/reorder` sets the order. `DELETE /v1/lead-stages/{id}` moves the stage's leads to `move_to`, or to the first stage that stays.
- `POST`, `PATCH`, and `DELETE` on `/v1/lead-channels` create, change, and archive a lead channel.
- `POST /v1/referral-sources` creates a referral partner and its partner contact. `PATCH` changes it, and `DELETE` archives it.
- `GET /v1/lead-stages` and `GET /v1/lead-channels` now return the default stages and channels to a brokerage that did not open the Leads board yet.
- A key with the `leads_only` limit cannot change the lead stages, lead channels, or referral partners. Refer to [Configure your workspace](/workspace).
**Added: suggestions, notifications, compliance checklists, credit reports, and activity trails.**
- `GET /v1/suggestions` lists the changes that BrokerPlus suggests to the broker. `POST /v1/suggestions/{id}/approve` carries out a suggestion that stays inside BrokerPlus, and `POST /v1/suggestions/{id}/dismiss` turns one down. A suggestion that reaches a client returns `409 APPROVE_IN_BROKERPLUS`: the broker approves it in BrokerPlus. Refer to [Suggestions, notifications, and deal checks](/broker-view).
- `GET /v1/notifications` lists the notifications of the broker that your key acts as. `POST /v1/notifications/{id}/read` and `POST /v1/notifications/read-all` mark them read.
- `GET /v1/deals/{id}/compliance` returns the compliance checklist of the deal: the checkpoints, the open items, the identity and know-your-client checks on each party, the consents, the suitability answers, and the required documents. It does not contain ID numbers, ID images, or dates of birth.
- `GET /v1/deals/{id}/credit-reports` returns the score, the bureau, the report date, and the account totals of each credit report. It does not contain the report or the account numbers. The API cannot pull credit.
- `GET /v1/deals/{id}/activity` and `GET /v1/contacts/{id}/activity` return the activity trail: messages, calls, notes, and changes to the deal.
- The MCP server has tools for suggestions, notifications, and activity trails.
**Added: change meeting types, the broker's hours, blocked days, and the email signature.**
- `POST /v1/meeting-types` creates a meeting type. `PATCH /v1/meeting-types/{id}` changes its length, hours, buffers, notice, questions, form fields, and reminders. `DELETE /v1/meeting-types/{id}` deletes it. `POST /v1/meeting-types/reorder` sets their order.
- `GET /v1/me/hours` and `PATCH /v1/me/hours` read and change the time zone, phone hours, and booking hours of the key's broker.
- `GET /v1/me/blocked-days`, `POST /v1/me/blocked-days`, and `DELETE /v1/me/blocked-days/{date}` list, block, and unblock the days when clients cannot book the broker.
- `GET /v1/me/email-signature` and `PATCH /v1/me/email-signature` read and change the broker's email signature.
- A key changes only its own broker's hours and signature. Refer to [Configure your workspace](/workspace).
**Added: edit, pin, and delete notes.**
- `PATCH /v1/contacts/{id}/notes/{noteId}` and `PATCH /v1/deals/{id}/notes/{noteId}` change the words, the pin, or the kind of a note.
- `DELETE /v1/contacts/{id}/notes/{noteId}` and `DELETE /v1/deals/{id}/notes/{noteId}` delete a note and its replies.
- `PATCH /v1/tasks/{id}/notes/{noteId}` changes the words or the pin of a task note. `DELETE /v1/tasks/{id}/notes/{noteId}` deletes a task note that you wrote.
**Added: change the task categories of a team.**
- `POST /v1/task-categories` creates a category, `PATCH /v1/task-categories/{id}` renames it, and `DELETE /v1/task-categories/{id}` deletes it. Its tasks go back to their built-in category.
- `POST /v1/task-categories/hidden` hides built-in categories from the team. `GET /v1/task-categories` now returns `hidden` on each category.
- Only the team's broker or a brokerage admin can make these changes. Refer to [Configure your workspace](/workspace).
**Added: change the pipelines and stages of a team.**
- `POST /v1/pipelines` creates a pipeline. `PATCH /v1/pipelines/{id}` changes it, `POST /v1/pipelines/reorder` sets the order, and `DELETE /v1/pipelines/{id}` deletes it. A pipeline that has deals is deleted only with `archive_deals=true`.
- `POST /v1/pipelines/{id}/stages`, `PATCH /v1/pipelines/{id}/stages/{stage_id}`, `POST /v1/pipelines/{id}/stages/reorder`, and `DELETE /v1/pipelines/{id}/stages/{stage_id}` add, change, order, and archive its stages.
- Each stage returns `requirements`: what a deal must have before it can move into the stage. You can set them on a create or an update. Refer to [Configure your workspace](/workspace).
**Added: change the custom fields of a brokerage.**
- `POST /v1/custom-fields` creates a contact or deal field. `PATCH /v1/custom-fields/{id}` changes its label, options, group, or default. `DELETE /v1/custom-fields/{id}` archives it, and BrokerPlus keeps its answers.
- `POST /v1/custom-fields/reorder` sets the order of the fields.
- Only a key that belongs to a brokerage admin can make these changes. Refer to [Configure your workspace](/workspace).
**Added: a common-law relationship type.**
- `GET /v1/contacts/{id}/relationships` can return `common_law` as a `type`. It reads the same from both contacts.
**Added: equity report enrollment, and opportunities you add by hand.**
- `POST /v1/equity-reports/enroll` asks the broker to start a client's recurring equity reports. `POST /v1/equity-reports/resume` asks to restart paused ones. `POST /v1/equity-reports/send` asks to send one report now, by email or SMS. Each returns `202` with a pending approval and a line for each deal. Nothing starts or goes out until the broker approves it in BrokerPlus. Refer to [Equity reports](/equity-reports).
- `POST /v1/equity-reports/pause`, `POST /v1/equity-reports/unenroll`, and `POST /v1/equity-reports/frequency` pause reports, stop them, or change how often they go out. They happen immediately.
- A client who opted out of reports, or is under a sanctions freeze, is refused for each deal, the same as in BrokerPlus.
- `POST /v1/opportunities` adds a mortgage to the book, the same as Add Client in BrokerPlus.
- `PATCH /v1/opportunities/{id}` takes `name`.
- `GET /v1/opportunities` takes `monthly_reports_enabled`.
- An approval can have the action `equity_report.enroll`, `equity_report.resume`, or `equity_report.send`, and the target type `deal`.
**Added: build and run the same automations as the Pulse builder.**
- `GET /v1/automations/catalog` lists every trigger type and its `config`, every step kind and its `params`, who a message can go to, the fields a check can compare, and the node kinds of a graph.
- `POST /v1/automations` and `PATCH /v1/automations/{id}` take any trigger `type` with `config`, `filters`, `repeat_policy`, `anchor_offset_days`, and `canvas_id`, and any step `kind` with `params` and `only_if`. Send `graph` for an automation with branches and waits. The older body (`stage_id`, and steps with `channel` and `template_id`) still works.
- `POST /v1/automations/{id}/turn-on` turns an automation on. When it can message clients, the response is `202` and an approval with the new action `automation.turn_on`, bound to the automation as it is written. A change before the broker decides voids it. `POST /v1/automations/{id}/turn-off` turns it off now.
- `POST /v1/automations/{id}/duplicate` makes a copy, OFF. `DELETE /v1/automations/{id}` turns an automation off and removes it from the board.
- Each automation returns `can_message_clients` and `graph`.
- `GET /v1/automation-runs` takes `deal_id` and `contact_id`.
- A refused automation returns `details.fieldErrors` with the field at fault, for example `steps.1.params.templateId`, and a message that says how to fix it.
**Changed: what turns an automation OFF.**
- A `PATCH` that changes only `name`, `description`, or `canvas_id` now leaves the automation on or off. Before, every change turned it OFF. A change to what the automation does still turns it OFF.
- A step `kind` other than `email` or `sms`, and a trigger other than `stage.transitioned`, no longer return `409 UNSUPPORTED_STEPS` or `409 UNSUPPORTED_AUTOMATION`.
**Added: who recorded a contact relationship, and when it last changed.**
- `GET /v1/contacts/{id}/relationships` returns `created_by_user_id` and `updated_at` on each relationship.
**Added: more fields on keys, brokers, teams, webhooks, automations, templates, meeting types, appointments, mail, drafts, campaigns, and intakes.**
- `GET /v1/me` returns `api_key.created_at`.
- `GET /v1/organization` returns `has_book_of_business`.
- `GET /v1/users` returns `timezone` and `license_number` for each broker.
- `GET /v1/teams/{id}/members` returns `created_at` and `updated_at` for each seat.
- `GET /v1/webhooks` returns `failure_count`, the events in a row that failed, and `updated_at`.
- `GET /v1/automations` returns `sort_order` and `anchor_offset_days`, and `created_at` and `updated_at` on each step.
- `GET /v1/templates` returns `attachments`: the name, size, and type of each file that goes with an email.
- `GET /v1/meeting-types` returns `buffer_before_minutes`, `buffer_after_minutes`, `slot_interval_minutes`, `availability_hours`, `required_license_class`, `required_province`, `reminder_minutes`, `reminder_channels`, and `position`.
- `GET /v1/appointments` returns `ical_uid` and `shows_as_free`.
- `GET /v1/communications` returns `to_contact_ids`, `mailbox_user_id`, `done_at`, and `remind_at`.
- `GET /v1/drafts` returns `body_html`, `reviewed_by_user_id`, and `rejection_reason`.
- `GET /v1/campaigns` returns `series_id`, `follow_up_root_id`, `follow_up_step`, `follow_up_audience`, `started_at`, `paused_at`, `auto_paused_reason`, and `failure_reason`. Each recipient has `updated_at`.
- `GET /v1/intakes` returns `portal_type`, `broker_user_id`, `template_id`, `application_id`, `status_note`, `status_note_updated_at`, `needs_changes_at`, `last_reminder_at`, and `reminder_count`.
**Added: sandboxes for testing.**
- An admin can create a sandbox in **Settings > Developer**. A sandbox is a private brokerage with sample data. It records email, text messages, credit requests, and lender submissions, and it does not send them. Refer to [Test in a sandbox](/sandbox).
- A sandbox key starts with `bp_test_`. A sandbox key cannot get data from a live brokerage, and a live key cannot get data from a sandbox.
- `GET /v1/sandbox/outbox` lists what a sandbox recorded. Only a sandbox key can use it.
**Added: more of what a broker sees on deals, opportunities, submissions, lenders, pipelines and commission.**
- A deal has `stage_entered_at`, `application_date`, `archived_at`, `opportunity_type`, `origin`, `spawned_from_deal_id`, `realtor_contact_id`, `primary_application_id`, `submitting_broker_id`, and `channel_id`. Each entry in `external_references` has `url`, `remote_status`, `remote_status_at`, `last_pulled_at`, and `created_at`.
- An opportunity has `name`, `comparison_rate`, `collateral_charge`, `cashback_enabled`, `cashback_override`, `best_option_pin`, `dismissed_by_user_id`, `last_client_contact_at`, `client_perceived_value`, `client_perceived_value_at`, the property details (`property_bedrooms`, `property_bathrooms`, `property_sqft`, `property_year_built`, `property_type`), the report dates, `outreach_blocked_at`, and `report_frequency`. Rates are in percent.
- `PATCH /v1/opportunities/{id}` also takes `comparison_rate`, `collateral_charge`, `cashback_enabled`, `cashback_override`, and the five property details. A value you send replaces the figure BrokerPlus has.
- A submission has `cover_note`, `penalty_capitalized`, `simulated`, `validated_at`, and, on a Finmo submission, `goal`, `approval_type`, `gds`, `tds`, and `ltv` in percent. A `simulated` submission never reached a lender.
- A lender has `contact_email`, `contact_phone`, `equifax_accredited`, `transunion_accredited`, `cmhc_approved`, `under_maintenance`, and `lending_guidelines`.
- A pipeline has `key` and `broker_user_id`. A stage has `description`, `exclude_from_book`, `stops_automated_outreach`, `created_at`, and `updated_at`.
- A commission has `created_by_user_id`. Its line items have `created_at` and `updated_at`, and its splits have `created_at`.
**Changed: an intake that BrokerPlus emails now needs the broker's approval.**
- `POST /v1/intakes` with `delivery` `email` still creates the intake and returns `201`, but nobody is emailed until the broker approves it in BrokerPlus. The response has a new field, `approval_id`. Read the approval with `GET /v1/approvals/{id}`. For `delivery` `link`, `approval_id` is null and nothing changes.
**Added: apps connect to the MCP server by signing in.**
- Claude on the web, ChatGPT, and other connectors sign in with OAuth 2.1 and need no API key. The `401` from the MCP server names the protected resource metadata, and that names the authorization server. Refer to [MCP server](/mcp#sign-in-instead-of-a-key).
- The broker picks the team and whether the app may only read. The token resolves to the same authority as an API key with the same team and read only choice.
- A connection is listed with the API keys in **Settings > Developer**, and revoking it there disconnects the app. API keys work as before.
**Changed: MCP tool results are structured.**
- Each MCP tool declares an `outputSchema`. The schema is the response schema of its endpoint, without `success`.
- Each tool result has `structuredContent`: the `data` of the response, and the `pagination` of a list.
- The text of a result starts with one line that names what came back, by name and id. The JSON of the result follows it.
- The MCP server adds `brokerplus_search_operations`, `brokerplus_describe_operation`, and `brokerplus_call_operation`. They find, describe, and call each API operation that the server publishes. Refer to [MCP server](/mcp#find-and-call-any-operation).
- The MCP server sends `401` when the API refuses the key. Before, the first tool call failed instead.
- The MCP server does not show write tools to a read-only key.
**Changed: booking, moving, and canceling a meeting, and asking for intake changes, now need the broker's approval.** This is a breaking change.
- `POST /v1/appointments/book`, `POST /v1/appointments/{id}/reschedule`, `POST /v1/appointments/{id}/cancel`, and `POST /v1/intakes/{id}/request-changes` now return `202` and a pending approval. Before, they returned `201` or `200` and emailed the client immediately. Nothing changes and nobody is emailed until the broker approves the request in BrokerPlus. Refer to [Messaging and approvals](/approvals).
- A booking approval expires at `start_at`, or after seven days if that is sooner. If the time is taken before the broker approves, the approval is `failed` and nothing is sent.
- A second cancel of a canceled booking now returns `409 BOOKING_CANCELED`.
- `GET /v1/appointments/{id}/booking` reads the booking on an appointment, including after a move or a cancellation.
**Changed: the `ltv` on an equity report is a percent.**
- `ltv` on `GET /v1/equity-reports` was a decimal fraction, such as `0.488`. It is now a percent, such as `48.8`, the same unit as every other rate and ratio in the API. If you divide it by 100 or show it as a fraction, change that code.
**Added: how an equity report reached the client.**
- Each equity report has `channel`: `email` or `sms`.
**Added: the record type, and names beside ids.**
- Every record now has an `object` field that names its type, for example `"deal"`, `"contact"`, or `"task"`. It is always present and never changes for a type. Contact methods, relationships, approvals, and automation runs have it too.
- A deal adds `board_name`, `assigned_broker_name`, `underwriter_name`, and `lender_name`.
- A task adds `deal_name`, `contact_name`, and `assignee_name`.
- A contact adds `owner_name`. A note on a deal or a contact adds `author_name`.
- An appointment adds `owner_name` and `deal_name`. A draft adds `sender_name`.
- A submission adds `submitted_by_name`, and `lender_name` now has a value on a BrokerPlus submission.
- No field changed its name or its type. Each name is for display: keep the id as your reference.
**Added: more of what a broker sees on contacts, tasks, conditions, and their lists.**
- A contact has `nickname`, `date_of_birth`, `phone_extension`, `language`, `assigned_broker_id`, `not_a_borrower`, `is_entity`, `opt_out_milestones`, `created_via`, and `archived_at`. `GET /contacts/{id}` returns an archived contact; `GET /contacts` still leaves it out. `POST /contacts` and `PATCH /contacts/{id}` take `nickname`, `date_of_birth`, and `phone_extension`, and `PATCH` also takes `language`.
- A task has `parent_task_id`, `provenance`, `progress`, `snoozed_until`, and `completed_by_user_id`. `PATCH /tasks/{id}` takes `progress` and `snoozed_until`.
- A condition has `lender_condition_name`, `plain_title`, `plain_description`, `tags`, `needs_review`, `status_note`, `status_note_at`, `resolved_at`, `satisfied_by_user_id`, `auto_satisfied_document_id`, and `source_communication_id`. `PATCH /conditions/{id}` takes `status_note`.
- A document has `excluded_from_submission`.
- A lead has `lead_form_id` and `lead_form_name`.
- A note on a deal or a contact has `type`, the kind of note the brokerage filed it as.
- A tag has `is_type` and `updated_at`.
- A consent has `text`, `text_version`, `locale`, `created_at`, and `updated_at`.
- `GET /referral-sources` returns `type`, `company`, `contact_id`, `contact_details`, `notes`, `last_contact_at`, and `next_follow_up_at` for each partner.
- `GET /custom-fields` returns `id`, `default_value`, `position`, `created_at`, and `updated_at`.
- `GET /task-categories` returns `id`, `position`, `created_at`, and `updated_at` on a custom category.
- A referral source, a custom field, and a task category have `object`: `"referral_source"`, `"custom_field_definition"`, and `"task_category"`.
**Added: the rest of the mortgage application.**
- `GET /v1/deals/{id}/application` returns `label`, `loan_type`, `charge_type`, `repayment_type`, `requested_payment`, `down_payment_override`, `legal_and_closing_costs`, `type_of_buyer`, `loan_purpose_additional`, `mortgage_classification`, `product_code`, `rate_compound_frequency`, `lender_classification`, `early_payout_type`, `capitalize_payout_penalty`, `purchase_stage`, `purchase_timeline`, `improvement_description`, and `security_deposit_amount`. `PATCH` changes each of them except `product_code`.
- `GET /v1/deals/{id}/property` returns the heating, water, parking, zoning, construction and description types, `tenure`, the property and lot size with their units, `environmental_hazard`, `new_property`, `mls_listed`, the rental and condo fee rules, `annual_tax_estimated`, `tax_year`, `taxes_paid_by`, the original purchase price and date, seven monthly operating costs, `legal_description`, and `legal_pin`. `PATCH` changes each of them except `tenure`.
- An applicant returns `honorific`, `suffix`, `sex`, and `correspondence_language`. A job returns `industry_varies`, `business_type`, the employer's address and phone number, and `amount_basis` on each income line.
- An asset returns `applied_to_down_payment`. A liability returns `debt_type`, `description`, `original_loan_amount`, `closing_date`, `charge_type`, `repayment_type`, `redemption_status`, `rate_type`, `payoff_type`, `source_deal_id`, and `property_id`. An other property returns `street_type`, `street_direction`, `value_estimate`, `value_source`, the condo fee rules, `mortgage_rate_type`, `mortgage_payment_frequency`, `rental_calc_type`, and its monthly operating costs.
- The row endpoints accept the new fields that a broker can edit. Refer to [Applicants and financials](/model#applicants-and-financials).
**Changed: webhooks for brokerages with more than one team.**
- A brokerage with more than one team can now register and enable webhooks. The API does not
return `409 MULTI_TEAM_UNAVAILABLE` for webhooks.
- An endpoint receives an event only when the key that registered it can read the record of that
event now, with the same rules as `GET /v1/changes`. This is also true for a brokerage with one
team. For example, an `email.received` event from a mailbox that the broker cannot see does not
go to the endpoint. Refer to [Who receives an event](/webhooks#who-receives-an-event).
- When the broker is deactivated or leaves the team of the key, the endpoint stops receiving events.
- An endpoint belongs to the broker whose key registered it. A key of a different broker gets `404`
for it, and `GET /v1/webhooks` does not show it.
- The limits on a key apply to webhooks. A key with `no_documents` gets `403 KEY_LIMITED` when it
subscribes to a document event, and its endpoint never receives one. Refer to
[Who receives an event](/webhooks#who-receives-an-event).
**Added: limits on a key.**
- A key can now have the limits `leads_only`, `no_sending`, and `no_documents`, in addition to `read_only`. Set them when you create the key in **Settings > API keys**. Each limit removes access, and no limit can give a key more than its broker has. Refer to [Limits on a key](/authentication#limits-on-a-key).
- A request that a limit refuses returns `403 KEY_LIMITED`, with the limit in `details.limit`.
- `GET /v1/me` reports the limits of the key in `api_key.limits`.
**Changed: scheduling a campaign now needs the broker's approval.** This is a breaking change.
- `POST /v1/campaigns/{id}/schedule` now returns `202` and a pending approval. Before, it returned
`200` and scheduled the campaign immediately. The campaign stays a draft until the broker approves
the request in BrokerPlus. The response still carries `warnings`.
- The approval is bound to the campaign's words and its audience. A change to either one voids it.
It expires at `scheduled_at`.
**Added: messages and approvals.** See [Messaging and approvals](/approvals).
- `POST /v1/contacts/{id}/messages` asks the broker to send one email or SMS to a contact.
- `POST /v1/drafts/{id}/send` asks the broker to send a draft that waits for review.
- `GET /v1/approvals` and `GET /v1/approvals/{id}` read each request and the broker's decision.
- `GET /v1/automation-runs`, `GET /v1/automation-runs/{id}`, and
`POST /v1/automation-runs/{id}/start` read automation runs and ask to start one now.
`POST /v1/automation-runs/{id}/cancel` stops a run that has not started.
- New webhook events: `approval.approved` and `approval.rejected`.
- `PATCH /v1/tasks/{id}` and `POST /v1/tasks/{id}/complete` return `409 APPROVAL_TASK` for an
approval task.
- The MCP server exposes all of these except approval. Only a broker can approve.
**Added: who a deal's mail goes out as.**
- `GET /v1/deals/{id}/sender` returns the broker whose name the client sees, the mailbox the mail leaves from, the team brand, and the business name in the footer. BrokerPlus finds it the same way a send finds it, from the deal's assigned broker. Refer to [Automations](/automations#see-who-a-deals-mail-comes-from).
- The API cannot set the sender. To change it, assign another broker to the deal.
- The MCP server exposes it.
**Added: automations.**
- `GET /v1/automations` lists the Pulse automations on the broker's board, with their steps in order. Filter with `type`, `enabled`, and `updated_since`. `GET /v1/automations/{id}` reads one.
- Each step has `ask_before_sending`. When it is `true`, the step puts a draft in the broker's queue instead of sending.
- `POST /v1/automations` creates an automation that sends templates when a deal moves into a stage. `PATCH /v1/automations/{id}` changes one.
- The API saves an automation OFF, and a change turns it OFF again. The broker turns it on in BrokerPlus. Refer to [Automations](/automations).
- The MCP server exposes all four.
**Added: scheduling.**
- `GET /v1/meeting-types` lists the broker's meeting types, with the booking page URL, the form
fields, and the questions of each.
- `GET /v1/availability` returns the open times of a meeting type. BrokerPlus calculates them the
same way as the booking page, with each host's own time zone and split shifts.
- `POST /v1/appointments/book` books a contact into an open time. **It sends the client the booking
confirmation email**, the same one the booking page sends. A time that is not open returns
`409 SLOT_TAKEN`.
- `POST /v1/appointments/{id}/reschedule` and `POST /v1/appointments/{id}/cancel` move or cancel a
booked appointment. Each one sends the client an email about the change.
- The MCP server adds `brokerplus_list_meeting_types`, `brokerplus_get_availability`, and
`brokerplus_book_meeting`. It does not move or cancel a booking. Every read tool is now marked
read-only.
**Added: condition writes.**
- `POST /v1/deals/{id}/conditions` adds a condition to a deal.
- `POST /v1/conditions/{id}/satisfy` and `POST /v1/conditions/{id}/waive` change its status.
- A condition now returns `assignee_role`, `assignee_contact_id`, and `assignee_contact_name`: who
owes it. You can set the first two on a new condition and through `PATCH /v1/conditions/{id}`.
- A status change through the API now completes or reopens the task that decides the condition, as
it does in BrokerPlus.
**Added: task cancel, reopen, and notes.**
- `POST /v1/tasks/{id}/cancel` and `POST /v1/tasks/{id}/reopen`.
- `GET /v1/tasks/{id}/notes` and `POST /v1/tasks/{id}/notes`.
**Added: ten webhook events.** `contact.created`, `deal.updated`, `deal.status_changed`, `deal.archived`, `deal.restored`, `condition.created`, `condition.satisfied`, `task.updated`, `sms.received`, and `intake.document_uploaded`. A `deal.updated` delivery carries `changed_fields`, which names the deal fields that changed. See [Webhooks](/webhooks).
**Added: the webhook delivery log.** `GET /v1/webhooks/{id}/deliveries` lists what BrokerPlus sent to an endpoint in the last 30 days, with the status, the number of attempts, and the last error of each delivery. `POST /v1/webhooks/{id}/deliveries/{delivery_id}/retry` sends a `failed` delivery again.
**Changed: an endpoint is disabled after 20 failed events, not 20 failed attempts.** Before this change, each of the 6 attempts at one event counted as a failure, so 4 bad events could disable an endpoint.
**Fixed: webhook deliveries are no longer lost after an outage.** Before this change, BrokerPlus looked back only 5 minutes for events to send. Events older than that were never sent. Now BrokerPlus sends every event that it has not sent, for up to 72 hours. A busy brokerage also no longer loses events: internal activity can no longer fill a send cycle before your events.
**Changed: a re-enabled endpoint starts from the time you enable it.** BrokerPlus does not send the events from the time that the endpoint was disabled.
**Added: email and text templates.**
- `GET /v1/templates`, `GET /v1/templates/{id}`, `POST /v1/templates`, `PATCH /v1/templates/{id}` and
`DELETE /v1/templates/{id}` read and change the email and text templates in the Pulse library. A
delete archives the template. See [Templates](/templates).
- `GET /v1/templates/merge-fields` lists every merge field that a template can use, including your
brokerage's custom fields. A template that uses a merge field that is not in the list is refused
with `400 UNKNOWN_MERGE_FIELDS`.
- A template sends nothing by itself. A Pulse automation sends it.
- The MCP server exposes every template operation except the archive.
**Changed (breaking): every rate on an opportunity is a percent.** `target_new_rate` and
`breakeven_rate` were decimal fractions. They are now percents, the same as `interest_rate`. For
example, `0.0499` is now `4.99`. If your integration multiplies these two fields by 100, remove
that step.
**Added: actions on an opportunity.**
- `POST /v1/opportunities/{id}/convert` starts a new working deal from a tracked mortgage. The
deal gets the borrowers and the mortgage facts, and the opportunity stays on the book. The
response is the new deal. Each call creates a deal, so send `Idempotency-Key` to make a retry
safe.
- `POST /v1/opportunities/{id}/dismiss` takes a mortgage off the book and stops its outreach.
`POST /v1/opportunities/{id}/restore` puts it back.
- `POST /v1/opportunities/{id}/snooze` hides an opportunity until the date in `until`, and
reminds the broker that morning. `POST /v1/opportunities/{id}/unsnooze` ends the snooze.
- An opportunity now returns `dismissed_at`.
**Added: the client on an equity report.** An equity report now returns `contact_id` and
`contact_name`. `GET /v1/equity-reports` takes `contact_id`, to list the reports about one client.
**Added: the change feed.**
- `GET /v1/changes` lists every change to contacts, leads, deals, tasks, intakes, documents,
appointments, and communications, in order, with a checkpoint. BrokerPlus writes each entry in the
same database transaction as the change, so the feed has every change, from every source.
- Each entry gives `record_type`, `record_id`, `action` (`created`, `updated`, `deleted`, `merged`,
or `archived`), the names of the changed fields, and, for a merge, `merged_into_id`.
- Send `after=now` to get a checkpoint before a full sync. Filter with `types`.
- BrokerPlus keeps entries for 30 days. An older checkpoint gets `410 CHECKPOINT_EXPIRED`.
- Webhooks are hints. Use the feed as the source of truth. See
[Webhooks are hints](/webhooks#webhooks-are-hints).
- The MCP server exposes it as the read-only tool `brokerplus_list_changes`.
**Added: intake progress for each applicant.** An intake now contains `applicants`. Each entry gives
the applicant's `name`, `role`, `submitted_at`, the required questions `answered` out of `total`, and
the `unanswered` questions for each section. The progress moves as the client types, before they
submit. See [Intakes](/intake).
**Added: read an intake's answers.** `GET /v1/intakes/{id}/answers` returns every question each
applicant is asked, grouped by section, with the answer as it stands now. The SIN is never returned.
**Added: approve an intake, or ask the client for changes.**
- `POST /v1/intakes/{id}/approve` is the same action as **Approve** in BrokerPlus. The client gets no
message.
- `POST /v1/intakes/{id}/request-changes` reopens the answers you name and **emails the client**, the
same as a change request in BrokerPlus.
**Added: read the drafts you filed.** `GET /v1/drafts` and `GET /v1/drafts/{id}` return the drafts
that `POST /v1/contacts/{id}/drafts` filed, with what the broker did with each one. A draft now
returns `recipients`, `sender_user_id`, `created_by_user_id`, `scheduled_at`, `reviewed_at`,
`communication_id`, and `updated_at`.
**Changed: a draft's `status` can now be `scheduled`, `sent`, `discarded`, or `failed`.** A new draft
is still `pending_review`. Before this change, `status` was always `pending_review`.
**Changed: `POST /v1/tasks/{id}/complete` documents its body.** The optional `resolution` was always
accepted. It is now in the reference, and the MCP tool can send it.
**Changed: the MCP server.**
- An error now includes the API's `details`, such as the matching contacts on `DUPLICATE_CONTACT` or
the fields that failed on `VALIDATION_ERROR`.
- Every tool now has MCP annotations: `readOnlyHint`, `destructiveHint`, `idempotentHint`, and
`openWorldHint`.
- New tools: `brokerplus_list_task_categories`, `brokerplus_check_deal_stage_requirements`,
`brokerplus_list_deal_team`, `brokerplus_get_intake_link`, `brokerplus_list_intakes`,
`brokerplus_get_intake`, `brokerplus_get_intake_answers`, `brokerplus_list_drafts`, and
`brokerplus_get_draft`. The MCP server does not approve an intake or ask a client for changes.
**Added: edit the mortgage application one row at a time.** See
[Edit the application one row at a time](/model#edit-the-application-one-row-at-a-time).
- Applicants, jobs with their pay, other income, assets, liabilities, and other properties each have
a `POST`, a `PATCH`, and a `DELETE`. A write changes one row, and BrokerPlus then calculates the
totals and the ratios again.
- A liability from a credit report stays read-only, and a write to it returns
`409 FROM_CREDIT_BUREAU`.
- `GET /v1/deals/{id}/applicants/{applicantId}` reads one applicant, and
`GET /v1/deals/{id}/other-properties` lists the other properties.
**Added: the calculated figures on the application.**
- `GET /v1/deals/{id}/application` now returns `gds`, `tds`, `ltv`, and `ratio_qualifying_rate`, as
percents. It also returns `total_loan_amount`, `insurance_premium`, `insurance_premium_rate`,
`insurance_premium_added_to_loan`, `rate_capped`, `loan_purpose_renewal`,
`loan_purpose_improvements`, `improvement_cost`, `payment_accelerated`, `insurer_policy_number`,
and `stated_fees`, the fees that the lender stated.
- The property now returns `appraised_value` and `appraisal_date`.
- A job now returns `incomes`, its pay lines. An asset and a liability now return
`owner_contact_ids`.
- `GET /v1/deals/{id}/application/terms-history` lists each change to the loan terms, with who made
it and when.
**Fixed: a sync with `updated_since` can no longer skip a row.**
- Every list that takes `updated_since` now also takes `after_id`. Send the `updated_at` and `id` of
the last row you read, and keep `page` at 1. The next page starts after that row. Before this, a
row that changed while you paged moved to the end of the list and pushed the next row back onto a
page you had already read, so you never saw it.
- With `updated_since`, every list is in `updated_at` order and then `id` order, oldest first. Rows
with the same `updated_at` now come back in the same order on every request.
- `GET /v1/opportunities` with `updated_since` is now oldest first, as documented. It was newest
first.
- `GET /v1/campaigns` now filters on `updated_since`. It ignored it before.
- `GET /v1/communications` with `updated_since` now selects on the new `updated_at` field. It moves
when a message is read, starred, filed on a deal, or moved to a different folder. Before this the
filter used `created_at`, so a sync never saw those changes. Every message gets an `updated_at`
at the time of this release, so your next sync reads all your mail one time.
**Added: fields you write can be read back.**
- A task returns `priority`, `pinned` and `resolution`.
- A lead returns `first_name`, `last_name`, `email` and `phone`, from the lead's contact.
- A campaign returns `body_markdown`: the email body as Markdown that gives exactly the HTML in
`body`. It is null for a campaign written in BrokerPlus and for an SMS campaign.
**Fixed: a campaign written in BrokerPlus returns the HTML that is mailed in `body`.** It returned a
plain-text copy before this.
**Fixed: a refused `PATCH /v1/deals/{id}` saves nothing.** Before this, the `underwriter_id` and
`lender_id` in a request were saved even when the API refused the stage, status or another field in
the same request. Now the whole request is saved, or none of it is. A stage that needs a lender or an
underwriter is still entered by the request that sets it.
**Fixed: a key can no longer stay rate-limited after its minute ends.** A rare failure could leave
a key's request count with no end time, and every later request got `429 RATE_LIMITED`.
**Changed: a query parameter that the API cannot read gets `400 VALIDATION_ERROR`.**
- An `updated_since` that is not an ISO 8601 date-time gets `400 VALIDATION_ERROR`. Before this,
the API ignored it and returned the full list.
- A filter value that is not allowed, for example `GET /v1/tasks?status=x` or
`GET /v1/communications?channel=x`, gets `400 VALIDATION_ERROR` with the allowed values in
`message`. Some of these returned `500` before this.
- An invalid `deal_id`, `contact_id`, `source`, `status`, `starts_after` or `starts_before` filter
now gets `400 VALIDATION_ERROR`. Some lists returned `400 BAD_REQUEST`. `BAD_REQUEST` is now only
for an ID in the path.
- `details.fieldErrors` names the parameter.
- `GET /v1/intakes?contact_id=...` no longer returns intakes from teams the broker cannot see.
**Added: referral partners.**
- `GET /v1/referral-partners` reads the partner book, as the Partners page shows it. For each
partner, you see the people they referred, the deals that funded and their value, and the people
the broker introduced to them. The figures cover the whole history. Send `period` (`ytd`, `qtd`,
`mtd`), or `start` and `end`, to read a shorter period.
- `GET /v1/referral-partners/{id}` reads one partner, with each person, funded deal, and
introduction listed.
- A deal now returns `referral_partner`. `POST /v1/deals/{id}/referral-partner` sets it from a
contact or from a name. `DELETE /v1/deals/{id}/referral-partner` clears it.
- The MCP server exposes the two reads and the set.
**Added: reports.** `GET /v1/reports/pipeline` gives the open deals and their loan amounts on each
stage. `GET /v1/reports/funded` gives the deals that funded and their volume, in total and for each
month of a period. Both give the same figures as the Business pages. The MCP server exposes both.
**Added: merge, duplicates, archive, and more ways to reach a contact.**
- `POST /v1/contacts/{id}/merge` merges the contacts in `duplicate_ids` into this one, the same as
Merge on the Duplicates page. It returns the contact that survives. A merged id now returns
`410 CONTACT_MERGED` on `GET /v1/contacts/{id}`, with the survivor's id in
`details.merged_into_id`.
- `GET /v1/contacts/duplicates` lists the sets of contacts that look like the same person.
- `POST /v1/contacts/{id}/archive` and `POST /v1/contacts/{id}/unarchive` take a contact out of
the book and put it back. A contact now returns `archived_at`.
- `GET`, `POST` and `DELETE` on `/v1/contacts/{id}/methods` read, add and remove the extra emails,
phones and social profiles on a contact. A social profile returns its `url`.
- `GET /v1/contacts/{id}/relationships` lists the people a contact is related to.
- The MCP server exposes the merge and the three reads. The merge tool is marked destructive, so
an assistant asks before it merges.
**Added: a broker's licences.** A user now returns `licenses`: one entry for each province, with the
licence class and the day it expires.
**Docs: corrections and pages for AI agents.** The API did not change.
- [Pagination](/pagination) now gives the real order of each list. Not every list is newest first.
- The [Introduction](/introduction) now says that you can update the loan terms and the property
address on a deal.
- [Errors](/errors) now lists `409 MULTI_TEAM_UNAVAILABLE` and `503 TRY_AGAIN`.
- Every page is also available as Markdown at its URL plus `.md`. `/llms.txt` indexes the pages,
and `/llms-full.txt` contains all of them.
- The examples show curl, TypeScript, and Python.
- New guides: [Capture leads from your website or ads](/capture-leads),
[Sync your book](/sync-your-book), [Migrate from another CRM](/migrate-from-a-crm), and
[Build a Claude command center](/mcp).
**Added: document uploads.**
- `POST /v1/deals/{id}/documents` adds a file to a deal. Send it as `multipart/form-data` (50 MB maximum), or as JSON with `content_base64` (10 MB maximum). You can also send `document_type`, `contact_id`, and `name`. BrokerPlus stores, classifies, and checks the file the same way it does for a file that a broker uploads. See [Files](/files).
- A new `document.uploaded` webhook fires when a file is added to a deal by a broker, by the API, or from an email.
- `GET /v1/deals/{id}/document-requests` lists the documents that a client still owes on a deal.
- `GET /v1/document-types` lists the codes for `document_type`.
- The MCP server exposes the upload, in base64, and both lists.
**Added: more fields and filters on documents.**
- A document now returns `contact_name`, `display_name`, `document_type_label`, `stage`, `verified`, `folder`, `uploaded_by_user_id`, and `uploaded_by_name`.
- `uploaded_via` now returns a fixed set of values, and can be `api` for a file that this API uploaded.
- `GET /v1/deals/{id}/documents` takes `verified`, `document_type`, `contact_id`, and `updated_since`. An `updated_since` that is not a date returns `400`.
**Added: find a contact or a lead by email or phone.**
- `GET /v1/contacts` and `GET /v1/leads` take `email`, `phone`, and `q`. The `email` match ignores case. The `phone` match compares the last 10 digits, so any format works. Both match the main value and any additional email or phone on the contact. `q` is the same text search as the search bar in BrokerPlus.
- On `GET /v1/leads`, each filter matches the lead's contact. No match returns an empty list, not `404`.
**Added: update, archive, and convert a lead.**
- `PATCH /v1/leads/{id}` changes the stage, service type, first-time buyer flag, follow-up date, details, referral source, and channel. The name, email, and phone stay on the contact.
- `POST /v1/leads/{id}/archive` and `POST /v1/leads/{id}/unarchive` take a lead out of the funnel and put it back.
- `POST /v1/leads/{id}/convert` starts a deal from the lead. You can name the deal `type`, `board_id`, and `stage_id`. A second call returns the same deal.
- A lead now returns `status` (`open`, `archived`, or `converted`), `converted_deal_id`, `referral_source_id`, `channel_id`, and the name next to each id.
- `GET /v1/lead-stages`, `GET /v1/referral-sources`, and `GET /v1/lead-channels` list the ids that a lead takes.
**Added: custom fields on leads, and filters by custom field.**
- `POST /v1/leads` and `PATCH /v1/leads/{id}` take `custom_fields`, a map of field `key` to value. A lead's custom fields are the fields of its contact. One bad key or value refuses the whole request, and `details.custom_fields` names each one.
- `GET /v1/leads/{id}/custom-fields` and `PATCH /v1/leads/{id}/custom-fields/{key}` read and set them one at a time.
- `GET /v1/custom-fields` lists the field definitions. Send `entity=contact` or `entity=deal` to list one kind.
- `GET /v1/contacts`, `GET /v1/leads`, and `GET /v1/deals` take `custom_field[]=`.
- The MCP server exposes the lookup, the lead update, archive and unarchive, the conversion, and the lead custom fields.
## 2026-10-06
**Added: deal custom fields.**
- `GET /v1/deals/{id}/custom-fields` reads the custom fields your brokerage keeps on a deal. `PATCH /v1/deals/{id}/custom-fields/{key}` sets one. They work the same as the contact custom fields: address a field by its `key`, and send an empty string to clear a field.
- The MCP server exposes both.
**Added: the `short_text` custom field type.** A custom field can now have the `type` `short_text`. It holds one line of 200 characters or fewer. When you set one, line breaks in the value change to spaces. A longer value returns an error.
## 2026-10-05
**Added: you can correct what a tracked mortgage carries.**
- `PATCH /v1/opportunities/{id}` takes `balance`, `interest_rate`, `prime_adjustment`, `monthly_payment`, `property_value`, `effective_penalty`, and `penalty_type`. Send only the fields you are changing. `interest_rate` and `prime_adjustment` are percents, although the other rates on an opportunity are decimal fractions.
- A balance you send becomes the new anchor. BrokerPlus pays it down from that day onward, and neither the nightly recalculation nor an import overwrites it. Send null to hand the balance back to BrokerPlus.
- A rate you send also lands on the mortgage application, so the deal and the book agree.
- An opportunity now returns `balance_is_manual`, `interest_rate`, `prime_adjustment`, and `penalty_type`.
- `GET /v1/opportunities` takes `deal_id`. A deal does not carry the id of its opportunity, so this is how you go from a deal to the mortgage it tracks.
- `effective_penalty` now returns the penalty a broker entered from a lender payout statement, when there is one. Before this it returned only the penalty BrokerPlus calculates.
## 2026-10-03
**Added: `express_send` on campaigns.**
- `POST /v1/campaigns/{id}/schedule` takes an optional `express_send`. When it is `true`, the whole audience gets the campaign within about two hours of `scheduled_at`, and `send_window_days` is ignored. A fast send from one mailbox is more likely to go to spam, so use it only when speed matters more than delivery.
- A campaign now returns `express_send`.
**Changed: paced sends wait for daytime.** Without `express_send`, a campaign only sends between 8 AM and 8 PM. A recipient whose address places them in a Canadian province gets it on their own clock; everyone else goes by the time zone of the person who created the campaign. A `scheduled_at` outside those hours starts at the next 8 AM, and each day of a multi-day send stays inside those hours.
**Added: `skip_weekends` on campaigns.** `POST /v1/campaigns/{id}/schedule` takes an optional `skip_weekends`, which holds a paced send over Saturday and Sunday and carries on Monday at 8 AM. A campaign now returns `skip_weekends`.
**Added: `warnings` on a schedule.** `POST /v1/campaigns/{id}/schedule` now returns `warnings`, a list of plain sentences to pass on to the broker: one day of the send close to the daily sending limit of their connected mailbox (Google Workspace 2,000, Gmail 500, Microsoft 365 10,000, Outlook.com 1,000), or an Express send starting outside 8 AM to 8 PM. The campaign is scheduled either way.
**Changed: a busy database no longer fails a schedule with a bare `500`.** `POST /v1/campaigns/{id}/schedule` now answers `503 TRY_AGAIN` when BrokerPlus is too busy to finish. The campaign stays a draft and nothing is sent, so send the same request again after a short wait.
## 2026-10-01
**Added: the client intake works over the API.** See [Intakes](/intake).
- `GET /v1/intake-link` returns the broker's standing intake link, the one that never expires. `PATCH /v1/intake-link` turns it on or off, and the url stays the same through a disable.
- `GET /v1/intakes` lists the intakes a client is filling in, with filters for `status`, `deal_id`, `contact_id`, and `updated_since`. `GET /v1/intakes/{id}` reads one.
- `POST /v1/intakes` asks one client for an intake. A deal is optional, a second applicant can be named, and `delivery` of `email` has BrokerPlus send the link from the broker's own mailbox.
- `POST /v1/intakes/{id}/link` issues a fresh link for a client. BrokerPlus keeps only a fingerprint of a link it has sent, so a link cannot be read back. Every link issued for the same intake works until it expires.
- Two new webhooks: `intake.started` when a client first opens an intake, and `intake.submitted` when they send it back.
**Added: a connected mailbox is readable over the API.**
- A communication now returns `from`, `to`, and `cc`, so you can see who an email was between.
- A communication now returns `body_html`, which is the HTML the sender wrote. It arrives exactly as it was sent and it is never sanitized, so sanitize it before you render it.
- A communication now returns `attachments`, which names each attached file, its type, and its size.
- `GET /v1/communications/{id}/attachments/{attachment_id}` returns a signed link to one attached file, valid for five minutes. BrokerPlus gets the file from the mail provider the first time somebody asks for it. If the mailbox no longer holds the message, the response is `410 ATTACHMENT_UNAVAILABLE`.
- A communication now returns `read_at`, which is null for mail nobody has opened, and `starred`.
- A communication now returns `folders`, which is every folder and label the message sits in, as your mail provider names them, and `mailbox`, which is the connected address it arrived at.
- `GET /v1/communications` now filters on `thread_id` for one conversation, on `unread` for mail nobody has opened, and on `folder` by role (`inbox`, `sent`) or by the provider's own folder name.
- A new `email.received` webhook fires when mail arrives in a connected mailbox. Junk mail and the history BrokerPlus imports when a mailbox is first connected do not fire it.
A key reads the mailboxes its own broker can read in BrokerPlus. A teammate who has not shared their
mailbox is no more readable through the API than through the app.
## 2026-09-28
**Added: you can name a deal, record its lender file number, set the date it funded, and archive it.**
- `PATCH /v1/deals/{id}` now takes `name`, which is the name a broker gives a deal. Send null or an empty string to put the deal back on your brokerage's naming pattern. A deal now returns `name`.
- `PATCH /v1/deals/{id}` now takes `lender_file_ref`, which is the lender's own file number for the deal. Use it for a number that comes from somewhere other than BrokerPlus. `reference_number` stays ours, and stays read-only.
- `PATCH /v1/deals/{id}` now takes `funded_at`, so a deal that funded before it reached BrokerPlus carries its real funding date. It is only accepted on a deal that is funded, or in the same request that sets `status` to `funded`. On any other deal it returns `409 NOT_FUNDED`.
- `DELETE /v1/deals/{id}` archives a deal. The deal leaves every board and report, and a broker can restore it in BrokerPlus. It is the same archive the dashboard runs, so the deal's open tasks and queued messages stop with it.
**Added: the mortgage application takes the purchase price, the payment, the insurer, and two more dates.**
- `PATCH /v1/deals/{id}/application` now takes `purchase_price`, `payment_amount`, `payment_frequency`, `mortgage_insurer`, `interest_adjustment_date`, and `funding_date`.
- The application now returns `interest_adjustment_date` and `mortgage_insurer`.
- `PATCH /v1/deals/{id}/property` now takes `occupancy_type` and `occupancy_purpose`.
**Fixed: a stage requirement with a fixed list of choices now says what it takes.**
- Filling a requirement such as the property's province or the payment frequency with a value that is not on its list returned `500`. It now returns `400`, and the message names every value the field accepts.
## 2026-09-27
**Added: you can write the team roles, the mortgage terms, and the property address of a deal.**
- `GET /v1/deals/{id}/team` lists the users in your brokerage who hold a role on the deal, e.g. `Sales Manager`. `POST /v1/deals/{id}/team` fills a role, and `DELETE /v1/deals/{id}/team/{role}` empties it.
- A team role is one of your own users. A participant is a contact from outside your brokerage, e.g. a lawyer. They are different lists.
- `PATCH /v1/deals/{id}/application` changes the loan amount, rate, rate type, term, amortization, first payment date, and financing condition date. Rates are percents.
- `PATCH /v1/deals/{id}/property` changes the property address.
- `PATCH /v1/deals/{id}` now also takes `underwriter_id` and `lender_id`. A deal now returns both fields.
- The mortgage application now returns `financing_condition_date`.
**Added: `GET /v1/deals/{id}/stage-requirements` tells you what a deal needs before it moves to a stage.**
- Send the stage as `stage_id`. The response lists each missing requirement. It is the same check that a move runs, so an empty list means the move passes the stage requirements.
- Use it before a move, instead of reading the `missing` list from a `409` response.
**Added: a campaign can have follow-ups, and each one is a campaign with `follow_up_of` set.**
- A broker adds up to three follow-ups to a draft campaign in BrokerPlus. Each follow-up goes out a set number of days after the previous message, and only to people who did not reply.
- `GET /v1/campaigns` returns each follow-up as its own campaign. `follow_up_of` is the id of the campaign that it follows. `follow_up_delay_days` is the number of days that it waits. On an original campaign, both fields are null.
- `POST /v1/campaigns/{id}/schedule` refuses a follow-up with `NOT_A_ROOT`. A follow-up goes out with the campaign that it follows.
- The same endpoint refuses a `recurrence` on a campaign that has follow-ups, with `FOLLOW_UPS_WITH_RECURRENCE`. A campaign can repeat, or it can have follow-ups, but it cannot do both.
- The API cannot add or change follow-ups. The broker sets them in BrokerPlus.
## 2026-09-26
**Changed: a lead you send with `POST /v1/leads` now has the source `api`, and starts the same automations as a lead form.**
- Before this change, the lead had the source `manual`. A manual lead does not start the "New lead created" automation, and it does not send the `lead.created` webhook. A lead from the API now does both, the same as a lead from a BrokerPlus lead form.
- A lead that a broker types in BrokerPlus is still `manual`, and it still starts nothing.
- `source` on a lead can now be `api`. If your code reads `source`, accept the new value.
**Fixed: a deal with no type no longer fails `GET /v1/deals`.**
- `type` on a deal can now be `null`. A deal made from a lead has no type until the broker sets one.
- Before this change, a page of `GET /v1/deals` that held such a deal returned `500`, and so did `GET /v1/deals/{id}` for that deal.
**Fixed: a lead from a shared client link no longer fails `GET /v1/leads`.**
- `source` on a lead can be `portal`. The contract did not list it, so a page that held such a lead returned `500`.
## 2026-09-25
**Changed: a deal's `contacts` now carry `role_label`, which is what tells one realtor from the other.**
- Both agents on a purchase hold the same `realtor` role, so the seller's agent is told from the buyer's by the brokerage's own word for the seat - `Listing Realtor` and `Buying Realtor`.
- The participant endpoints already returned `role_label`. Reading a deal now returns it too, so naming one of the two agents takes one request instead of two.
- To record the distinction, attach the partner with `role: "other"` and your own `role_label`.
**Added: `POST /v1/campaigns/{id}/schedule` sets the time a campaign goes out.**
- The campaign must be a draft. Send `scheduled_at`, and optionally `send_window_days` to spread the send, or `recurrence` to repeat it.
- The recipient list is frozen at the moment you schedule. Until the campaign fires, the broker can still pause or delete it in BrokerPlus.
- **There is still no endpoint that sends a campaign immediately.** A schedule names a future time, which is what makes it safe to publish.
- The endpoint refuses a campaign whose audience resolves to nobody, and one whose brokerage has no CASL sender identity. The MCP server exposes it as `brokerplus_schedule_campaign`.
**Changed: `body` on `POST /v1/campaigns` is Markdown, and now becomes real formatting.**
- `**bold**`, `*italic*`, `[text](https://example.com)`, `#` headings, and `-` or `1.` lists render as formatting in the email. Before this change the asterisks reached the client as text.
- **Write Markdown, not HTML.** Tags in this field are shown to the client as text. This protects a broker's own mailbox from sending markup it did not write.
- An SMS campaign is unchanged: it sends the body as plain text, with no formatting.
- `body` on a campaign you read back is the HTML that is mailed, because that is what BrokerPlus stores.
## 2026-09-23
**Added: campaigns, audiences, contact custom fields, and equity reports.**
- `GET /v1/campaigns` and `GET /v1/campaigns/{id}` read your newsletters and mass messages. Each campaign carries a `results` object with the counts per outcome, which includes `opened` and `unsubscribed`. The counts come from the recipient rows, so they agree with what BrokerPlus shows you.
- `GET /v1/campaigns/{id}/recipients` reads the result for each person: the contact id, the status, `opened_at`, and `unsubscribed`. Filter it with `status` or with `opened`. **Clicks are not recorded.** BrokerPlus does not rewrite the links in your message, so no endpoint reports a click.
- `POST /v1/campaigns` creates a campaign as a **draft**. `POST /v1/audiences` saves the list of contacts to send it to, and `GET /v1/audiences` lists the ones you have. An audience is a fixed set of contacts, not a saved filter: a contact who matches your criteria tomorrow does not join a list that you save today.
- **The API never sends a campaign, and never schedules one.** The broker opens the draft in BrokerPlus, reads it, and sends it there. There is no endpoint that sends, and this release does not add one.
- `GET /v1/contacts/{id}/custom-fields` reads the custom fields your brokerage keeps on a contact. `PATCH /v1/contacts/{id}/custom-fields/{key}` sets one. Address a field by its `key`, which does not change when somebody renames the label. Send an empty string to clear a field.
- `GET /v1/equity-reports` and `GET /v1/equity-reports/{id}` read the monthly reports sent to past clients. The figures are the snapshot the client received, not today's numbers; read today's numbers from `GET /v1/opportunities`. `cta_clicked_at` is the client asking for a call.
- The MCP server exposes all of these, except the campaign send that does not exist.
## 2026-09-22
**Added: `POST /v1/contacts/{id}/drafts` files a message for the broker to review.**
- Send a `channel` of `sms` or `email`, and a `body`. The draft lands in the review queue of the broker your key acts as, with the status `pending_review`.
- **The API never sends a draft.** The broker reads it in BrokerPlus, and approves, edits, or rejects it there. There is no endpoint that sends, and this release does not add one.
- Use this endpoint when your own system, or an assistant that you run, writes the message and a person must read it before a client does. See [Drafts](/model#drafts).
- The MCP server exposes the endpoint as `brokerplus_create_contact_draft`.
## 2026-09-21
**Added: a key can be created with read-only access.**
- When you create a key in **Settings > Developer**, set its access to **Read only**. Such a key answers a `GET` request and refuses every other method with `403` and the code `READ_ONLY_KEY`. See [Authentication](/authentication).
- The access is set once, at creation, and you cannot change it afterward. Revoke the key and create another one.
- **Every key that existed before this date has full access, and its behaviour does not change.**
- `GET /v1/me` now reports `api_key.read_only`.
## 2026-09-13
**Changed: a deal's `status` is one of a fixed set of words.**
- `status` on a deal is now one of `active`, `submitted`, `approved`, `funded`, `closed`, `dead`, `declined`, `cancelled`, `expired`, `withdrawn`, or `unknown`. A deal that carried `new` now carries `active`. A word that the set does not contain now reads `unknown`. The field type is unchanged: it is a string.
- The `status` filter on `GET /deals` accepts the same words.
## 2026-09-10
**Added: an MCP server, contact statistics, deal lookup by external reference, deal commission, deal consents, communication preferences, deal participants, team members, contact addresses, Finmo lenders and submissions, stage history, deal and task creation, and task assignment.**
- `/deals/{id}/participants` lists, attaches, updates, and removes the contacts on a deal, with their role. Borrower and partner roles share one list, and `is_partner` separates them. A partner with role `other` carries your own `role_label`. The endpoint supports `Idempotency-Key` on create.
- `GET /teams/{id}/members` lists who is on a team, their role on it, and their title.
- The [MCP server](/mcp) lets an AI assistant call the API with your key. Each tool is one documented endpoint, with the same inputs. It publishes reads across the API and the supported creates and updates. It publishes no delete, no webhook administration, no consent or commission write, and no document URL.
- `GET /contacts/{id}/statistics` answers a contact's deal counts, funded volume with its basis, repeat-client standing, referrals, and last touch.
- A deal now carries `lender_file_ref` and `external_references`: its ids in Velocity, Filogix, or Finmo. `GET /deals` takes `external_reference`, `source`, and `lender_file_ref` to find a deal by one of them. Matches are exact.
- `/deals/{id}/commission` reads, records, changes, and removes what the brokerage earns on a deal, with its line items and its splits. The same compensation access rule as the dashboard applies. The endpoint supports `Idempotency-Key` on create.
- `/deals/{id}/consents` lists and records the consents on a deal. `POST /deals/{id}/consents/{consentId}/revoke` takes one back and keeps the row. Both POST endpoints support `Idempotency-Key`.
- `/contacts/{id}/communication-preferences` reads and changes a contact's email and SMS consent, by category. `opt_in` clears a withdrawal and never records express consent.
- `/contacts/{id}/addresses` lists, adds, updates, and removes a contact's addresses. Exactly one address is primary. Provinces use two-letter codes, and countries use `CA`, `US`, or `other`. The endpoint supports `Idempotency-Key` on create.
- `GET /lenders` and `GET /submissions` now include the lenders and submissions of the brokerage's connected Finmo account, marked `source: finmo`. The brokerage's own records are marked `source: brokerplus`. Both endpoints accept a `source` filter. A Finmo submission carries `lender_name`, `lender_status`, `funded_at`, `loan_amount`, `interest_rate`, and `term_months`. The `slug`, `attempt`, and `status` fields are now nullable.
- `GET /deals/{id}/stage-history` lists every stage move on a deal, newest first, with the stage it left, the stage it entered, who moved it, and when. The same record feeds the `deal.stage_changed` webhook. Imported deals carry history only from their import date onward.
- `POST /deals` opens a deal for the key's broker. It lands on the broker's default pipeline for its type, or on the pipeline you name, and takes that pipeline's team. Attach borrowers with `contacts`. The endpoint supports `Idempotency-Key`.
- `POST /tasks` files a manual task. The task takes the team of its deal or contact, or the broker's own team. Send `assignee_user_id` to assign it, or leave it blank to file it unassigned. The endpoint supports `Idempotency-Key`.
- `PATCH /tasks/{id}` accepts `assignee_user_id`. Reassignment applies the dashboard's eligibility rule. An ineligible assignee returns `400 INVALID_ASSIGNEE` and changes nothing. Send `null` to unassign.
## 2026-09-08
**Corrected: access checks and existing writes.** These corrections add no new endpoints.
- Deal and contact requests enforce the broker's team access, including related notes, documents, conditions, and submissions.
- Deal updates use the dashboard's stage checks, compliance rules, and owner handoff. Status writes accept `active`, `dead`, `closed`, or `funded`.
- A combined deal update commits its fields and events together. A later notification or closing action can still fail after that commit.
- Contact edits preserve calculated client types and enforce identity locks. Condition edits enforce the parent deal's lock.
- Deal closing and maturity dates accept `YYYY-MM-DD` or an ISO timestamp. Responses remain date-only strings.
- New notes inherit their parent's team, sanitize HTML, and record eligible mentions.
- A saved response requires the original API key, broker, and current access to its result. Access loss returns `404` without repeating the mutation.
Read [Idempotency](/idempotency) before retrying a write with an uncertain result. No new endpoints are included in these corrections.
## 2026-08-15
**Added: the surface grew from 40 endpoints to 60.** Read the sections below for what is new.
**Deals now expose the mortgage itself.**
- `GET /deals/{id}/application` returns the loan: amount, term, amortization, rate, and payment.
- `GET /deals/{id}/property` returns the property that secures the loan.
- `GET /deals/{id}/applicants` returns everyone on the application, with their employment history
and their income.
- `GET /deals/{id}/financials` returns the assets, the liabilities, and the down payment sources.
Every lender-coded field on these endpoints arrives as a `code` and a readable `label`. A code that
we cannot name gives you a `label` of `Unknown`, so that "we hold a value that we cannot name" stays
different from "there is no value".
**Added: the opportunity book.** `GET /opportunities` and `GET /opportunities/{id}` return the
mortgages that your brokerage tracks, with the balance, the penalty to break, and the savings from a
move.
**Added: appointments.** `GET`, `POST`, `PATCH`, and `DELETE` on `/appointments`. An appointment
that BrokerPlus synced from Google Calendar or from Outlook returns `409` on a write. Change that
appointment in the calendar that owns it. A write here would look like a success, and the next sync
would silently undo it.
**Added: submissions.** `GET /submissions`, `GET /submissions/{id}`, and
`GET /submissions/{id}/decisions` return the deals that a broker sent to a lender, and what each
lender answered.
**Added: communications.** `GET /communications` and `GET /communications/{id}` return the email,
the calls, and the messages on a deal or a contact.
**Added: your brokerage and your teams.** `GET /organization`, `GET /teams`, and `GET /teams/{id}`.
**Added: `PATCH /conditions/{id}`.** You can now mark a condition satisfied, waived, or outstanding.
You cannot write `lender_status`. The lender owns that value, BrokerPlus refreshes it on each
decision poll, and a value that you wrote here would tell a broker that the lender accepted
something that the lender never saw.
**Breaking: a calendar date is now a `YYYY-MM-DD` string.** The affected fields are `closing_at`,
`maturity_date`, `first_payment_date`, `funding_date`, and `rate_hold_expires_at`.
```diff
- "maturity_date": "2029-03-15T00:00:00.000Z"
+ "maturity_date": "2029-03-15"
```
These columns hold a date. They hold no time and no time zone. As a timestamp at midnight UTC, every
client west of UTC rendered the day before. On a maturity date, that is a wrong answer, not a
formatting preference. A write still accepts either form.
**Changed: the documentation is now written in Simplified Technical English.** No behaviour changed.
The pages follow ASD-STE100, constrained by the Google developer documentation style guide, so that
they read the same way for a non-native reader and for a translation tool.
**Still out, on purpose.** You cannot create a deal or a task, reassign a task, or upload a file.
The API does not return an applicant's social insurance number, a liability's account number, a
credit report, a lender rate sheet, or compensation. Each page explains the reason where the subject
comes up.
## 2026-08-08
**Added: the surface grew from 16 endpoints to 40.** Nothing that was already published changed
shape, so no existing integration needed a change.
- **Users** (`GET /users`, `GET /users/{id}`) and **Pipelines** (`GET /pipelines`,
`GET /pipelines/{id}`). These close a gap. The fields `owner_id`, `assigned_broker_id`,
`assignee_user_id`, `board_id`, and `stage_id` were published as ids with nothing to resolve them
against. They now resolve.
- **Documents** (`GET /deals/{id}/documents`, `GET /documents/{id}`) and **Conditions**
(`GET /deals/{id}/conditions`, `GET /conditions/{id}`). A download URL expires after five minutes.
See [Files](/files).
- **Notes** on a contact and on a deal, both read and write.
- **Tags**: the two vocabularies (`GET /contact-tags`, `GET /deal-tags`), and assign and unassign on
both parents.
- **Lenders** (`GET /lenders`).
- **`PATCH /deals/{id}`** for status, stage, assigned broker, and the closing and maturity dates.
The API rejects a stage from a different pipeline, and a broker from a different brokerage.
- **`PATCH /tasks/{id}`** for the title, the body, the due date, and the pinned flag.
- **`PATCH /webhooks/{id}`** for the URL, the events, and the enabled flag. When you enable an
endpoint again, the API clears its failure count.
**Added: [How the objects fit together](/model)**, a map of which id resolves through which
endpoint.
## 2026-08-03
**Added: `Idempotency-Key` on every `POST`.** Send the header to make a create safe to retry. The
API replays the first response instead of creating a second record. The header is optional, and a
request without it behaves as it did before. See [Idempotency](/idempotency).
**Breaking: the pagination envelope is now `snake_case`.** `pageSize` became `page_size`, and
`totalPages` became `total_pages`. They now match the `page_size` query parameter, and every other
field on the surface.
```diff
- "pagination": { "total": 412, "page": 1, "pageSize": 50, "totalPages": 9 }
+ "pagination": { "total": 412, "page": 1, "page_size": 50, "total_pages": 9 }
```
The old wrapper came from an internal helper that serves our own dashboard, and it would have kept
moving with that dashboard. We changed it on the day that we published the API, before anybody read
it.
**Changed: the reference now documents every field.** Behaviour did not change. The OpenAPI document
described about a quarter of its fields before. It now describes all of them.
**Changed: `success` is published as a plain boolean.** Behaviour did not change. The value is still
`true` on a success and `false` on an error, and the field says so. OpenAPI 3.0 has no `const`, so
the fixed value was a single-value `enum`, and the reference rendered that as an empty "Enum values"
chip on every response.
## 2026-08-03: beta
The first public release. It had 16 endpoints, across identity, contacts, leads, deals, tasks, and
webhooks. A key from **Settings > Developer** authenticated all of them.
---
## Document: Webhooks
React to events in your book - register a URL, verify the signature, and handle each delivery.
URL: /webhooks
# Webhooks
Register an HTTPS URL, and BrokerPlus sends it a signed `POST` whenever something happens in your
book: a lead arrives, a deal funds, a client sends back their intake, a document lands.
This API is in beta. Any endpoint can change in a breaking way without notice, so watch the
[changelog](/changelog).
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/webhooks \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/brokerplus",
"events": ["lead.created", "deal.funded"]
}'
```
The response includes a `secret`. **You only see the secret once**, and you need it to verify every
delivery. If you lose it, delete the endpoint and register it again.
`GET /v1/webhooks` lists your endpoints, `PATCH /v1/webhooks/{id}` changes the URL, the events, or
`enabled`, and `DELETE /v1/webhooks/{id}` removes one.
## Events
| Event | When it fires | `data` carries |
| -------------------------- | ------------------------------------------------------------------------------- | -------------------------------------- |
| `lead.created` | A lead is captured: a website form, a call, an import, or by hand. | `lead_id` |
| `contact.created` | A person enters the book: by hand, from a lead, a call, an intake, or a booking. | `contact_id` |
| `deal.created` | A deal is opened. | `deal_id` |
| `deal.updated` | A broker changes a tracked field on a deal (see below). | `deal_id`, `changed_fields` |
| `deal.stage_changed` | A deal moves to another stage. | `deal_id` |
| `deal.status_changed` | A deal's status changes. | `deal_id` |
| `deal.funded` | A deal funds. | `deal_id` |
| `deal.archived` | A deal is archived. | `deal_id` |
| `deal.restored` | An archived deal is restored. | `deal_id` |
| `condition.created` | A broker adds a condition to a deal. | `deal_id`, `condition_id` |
| `condition.satisfied` | A broker marks a condition satisfied, in the app or through the API. | `deal_id`, `condition_id` |
| `task.created` | BrokerPlus or a broker files a task. | `task_id`, and `deal_id` if it has one |
| `task.updated` | A broker edits a task. | `task_id`, and `deal_id` if it has one |
| `task.completed` | A task is completed. | `task_id`, and `deal_id` if it has one |
| `email.received` | Email arrives in a connected mailbox, on a deal or not. | `communication_id` |
| `sms.received` | A text arrives on a BrokerPlus phone number. | `communication_id` |
| `call.logged` | A call lands in the call log: on a BrokerPlus line, or from `POST /v1/calls`. | `communication_id`, and `deal_id` |
| `intake.started` | A client opens an intake for the first time. | `intake_id` |
| `intake.submitted` | A client sends an intake back. | `intake_id` |
| `intake.document_uploaded` | A client uploads a file through a deal's intake. | `deal_id`, `intake_id` |
| `document.uploaded` | A file is stored on a deal by a broker, the API, or an email. | `deal_id`, `document_id` |
These 23 names are the whole list. Each one is a public name we keep stable, separate from the
event names BrokerPlus uses internally.
A few details worth knowing:
- **`deal.updated`** fires when a broker changes `type`, `status`, `board_id`, `stage_id`,
`assigned_broker_id`, `closing_at`, `funded_at`, `maturity_date`, `tags`, or `lender_id`.
`changed_fields` names the fields, not their values. Bulk imports and syncs from connected systems
don't fire it. A stage or status change also fires `deal.stage_changed` or `deal.status_changed`.
- **`email.received`** fires for new mail only, not for junk or for the history imported when a
mailbox is first connected. See [Mail](/mail).
- **`call.logged`** fires when a call on a BrokerPlus line starts, before there is a transcript. The
`communication_id` is also the call's id, so read `GET /v1/calls/{id}` later for the transcript and
recap. `deal_id` is there only when the call is on a deal.
- **`condition.satisfied`** doesn't fire when BrokerPlus satisfies a condition on its own from a
document. Read `GET /v1/deals/{id}/conditions` for those.
- **`intake.document_uploaded`** is the client answering a document request, so it has no
`document_id`. **`document.uploaded`** fires once a file is stored on the deal, before BrokerPlus
classifies it; read it with `GET /v1/documents/{id}`.
There's no event yet for a contact update or delete, a lead update, an appointment, an opportunity,
or a campaign send. For contacts, leads, and appointments, read the [change feed](/sync); for
opportunities and campaigns, use `updated_since` on the list.
## Webhooks are hints
A webhook tells you that something changed. It isn't the record of what changed. A delivery can
arrive late, arrive twice, or never arrive (say your receiver was down longer than the retries
last), and only the events above send one at all.
`GET /v1/changes`, the change feed, is the source of truth: BrokerPlus writes an entry in the same
transaction as every change to a contact, lead, deal, task, intake, document, appointment, or
communication, whoever made it. Treat each delivery as a nudge to read the feed now instead of at
your next poll, and you lose nothing when one goes missing. [Sync](/sync) explains the feed and the
loop.
## The delivery
```http
POST /hooks/brokerplus HTTP/1.1
Content-Type: application/json
User-Agent: BrokerPlus-Webhooks/1.0
bp-event: deal.funded
bp-delivery-id: cmsctw7kb0001p3x3zpyhkjv3
bp-signature: t=1786052400,v1=9f8c...
```
```json
{
"id": "cmsctw7kb0001p3x3zpyhkjv3",
"type": "deal.funded",
"created_at": "2026-08-03T14:20:00.000Z",
"data": { "deal_id": "cmsclloss01wap3ium49cw7rq" }
}
```
**The body carries ids, not objects.** When a delivery arrives, fetch the record, for example
`GET /v1/deals/{id}`. An embedded copy would freeze whatever shape the record had at that moment; an
id always gets you the current one.
## Verify the signature
`bp-signature` looks like `t=,v1=`, where the hex is
`HMAC-SHA256(secret, ".")`.
Verify against the **raw request body**, before you parse it. Re-serializing parsed JSON produces
different bytes, and the signature won't match.
```ts
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verify(secret: string, header: string, rawBody: string): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!Number.isFinite(t)) return false;
// the timestamp is inside the signed string, so a captured delivery cannot be replayed later
// against a receiver that enforces this window.
if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
```
```python
import hashlib, hmac, time
TOLERANCE_SECONDS = 300
def verify(secret: str, header: str, raw_body: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(int(time.time()) - t) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
```
Reject anything that fails this check. A `POST` you can't verify is a stranger claiming to be us.
## Retries, duplicates, and disabled endpoints
- **Any `2xx` is a success.** Everything else is a failure, redirects included.
- **Acknowledge first, then do the work.** BrokerPlus waits 10 seconds for your response. A handler
that does the work before responding times out, and the retry arrives while it is still running.
- **Six attempts per event**: the first try and 5 retries with exponential backoff, the first retry
after 10 seconds. After the sixth failure the delivery is `failed`.
- **Expect the occasional duplicate.** `bp-delivery-id` is stable per event, so store it and skip ids
you've already handled.
- **20 failed events in a row disable the endpoint.** The count is per event, not per attempt, and
any success resets it. Watch `failure_count` in `GET /v1/webhooks` to catch an endpoint going bad.
- **Re-enable with `PATCH /v1/webhooks/{id}` and `{"enabled": true}`** once your receiver is fixed.
That also resets the count; you don't need to register again. Events from while it was disabled
aren't sent, so catch up from the [change feed](/sync).
- **After an outage on our side**, BrokerPlus sends the events it missed, in order, for up to 72
hours.
## The delivery log
`GET /v1/webhooks/{id}/deliveries` lists what BrokerPlus sent an endpoint in the last 30 days, newest
first, with each delivery's `status`, `attempts`, last HTTP status, and last error.
```bash
curl "https://api.mos.getbrokerplus.ca/v1/webhooks/cmsd2r8wq0003p3x1hbq7n2kd/deliveries?status=failed" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```json
{
"success": true,
"data": [
{
"object": "webhook_delivery",
"id": "cmsd4k1za0007p3x1q9w2e8rt",
"event_id": "cmsctw7kb0001p3x3zpyhkjv3",
"event_type": "deal.funded",
"status": "failed",
"attempts": 6,
"last_status_code": 500,
"last_error": "status 500",
"last_attempt_at": "2026-10-07T14:25:10.000Z",
"delivered_at": null,
"created_at": "2026-10-07T14:20:00.000Z",
"updated_at": "2026-10-07T14:25:10.000Z"
}
],
"pagination": { "page": 1, "page_size": 25, "total": 1, "total_pages": 1 }
}
```
Once your receiver works again, resend a `failed` delivery:
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/webhooks/cmsd2r8wq0003p3x1hbq7n2kd/deliveries/cmsd4k1za0007p3x1q9w2e8rt/retry \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
The response is the delivery with `status: "pending"` and 6 fresh attempts. `bp-delivery-id` stays
the same, so a receiver that already handled it can skip it. You get `409` if the delivery isn't
`failed` or the endpoint is disabled.
## Who receives an event
An endpoint gets an event only when the key that registered it can read that record right now. The
endpoint keeps that key's broker and team, and BrokerPlus applies the same rules as `GET /v1/changes`
and each record's list. So an event about another team's record isn't sent, and neither is
`email.received` from a mailbox the broker can't see.
BrokerPlus checks again before every attempt, so the endpoint stops getting events when the broker
is deactivated, leaves the key's team, or the record moves to a team the broker can't read. Events
an endpoint can't receive don't appear in its delivery log.
[Key limits](/authentication) apply too. A `no_documents` key can't subscribe to
`document.uploaded` or `intake.document_uploaded`, and a `leads_only` key can't use webhooks at all.
Both get `403 KEY_LIMITED` with `details.limit` and `details.event`. The endpoint keeps the limits of
the key that registered it, so another key of the same broker can't add a refused event to it.
An endpoint belongs to the broker whose key registered it. Another broker's key doesn't see it in
`GET /v1/webhooks` and gets `404` if it tries to change, delete, or retry on it. To receive another
team's events, register an endpoint with a key from that team.
## URL requirements
The URL must be `https`, and its hostname must resolve to a public address. BrokerPlus checks the
address again at send time, not just at registration: a name that pointed somewhere public can later
point inside a private network, and we won't let our sender become a way in.
## Test your receiver
A [sandbox](/sandbox) delivers webhooks exactly like a live brokerage, with sample data and nothing
sent to real clients. It's the easiest place to try your receiver end to end.
---
## Document: Staying in sync
The three ways to learn what changed in BrokerPlus - the change feed, webhooks, and updated_since - and which one to use.
URL: /sync
# Staying in sync
There are three ways to find out what changed in a brokerage. They work best together: webhooks
tell you **when** to look, the change feed tells you **what** changed, and `updated_since` covers the
lists the feed does not.
| Tool | What it gives you | Can it miss a change? |
| ----------------------- | --------------------------------------------------- | -------------------------------------- |
| `GET /v1/changes` | Every change, in order, with deletes and merges | No, within 30 days |
| Webhooks | A signed POST to your server, seconds after a change | Yes. Treat it as a hint |
| `updated_since` | The rows of one list that changed since a time | No, but it cannot see deletes |
For a working sync loop with code, follow the [Sync your book](/sync-your-book) guide.
## The change feed
`GET /v1/changes` is the source of truth for a sync. It returns every change to the contacts,
leads, deals, tasks, intakes, documents, appointments, and communications your key can read, in
the order the changes took effect. BrokerPlus writes each entry in the same database transaction as
the change itself, whether the change came from the app, an import, an automation, or the API. A
checkpoint never skips an entry.
```bash
curl "https://api.mos.getbrokerplus.ca/v1/changes?after=MS4xMjAwLjE3OTE...&types=contact,deal" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```json
{
"success": true,
"data": [
{
"object": "change",
"id": "8812",
"record_type": "deal",
"record_id": "cmsdeal4k...",
"action": "updated",
"changed_fields": ["stage_id", "stage_entered_at"],
"merged_into_id": null,
"occurred_at": "2026-10-07T14:20:00.000Z"
}
],
"pagination": { "next_checkpoint": "MS4xMjE0LjE3OTE...", "has_more": false }
}
```
What each `action` asks you to do:
| `action` | Do this |
| --------------------- | ---------------------------------------------------------------------- |
| `created`, `updated` | Read the record again by `record_id`, and upsert it. |
| `archived` | Read it again. It left the active book, but you can still read it. |
| `deleted` | Remove it from your copy. |
| `merged` | Point your references at `merged_into_id`, then remove it. |
The rules that matter:
- **Start with `after=now`.** It returns a checkpoint and no entries. Take it before you copy the
lists, so a change made during your copy is still in the feed.
- **Save `next_checkpoint` after you apply the page**, not before.
- **Follow `has_more`, not the page size.** A page can be short, or even empty, while `has_more` is
`true`. Call again at once. When it is `false`, wait and poll again.
- **A checkpoint belongs to its `types`.** Keep one checkpoint per `types` value you use. You can
read it with the same `types` or fewer. Reading more types from it would skip their entries, so
that returns `400 INVALID_CHECKPOINT`. A checkpoint from `after=now` belongs to the `types` you
sent with it, or to every type when you sent none.
- **Entries are kept for 30 days.** An older checkpoint returns `410 CHECKPOINT_EXPIRED`. Take a new
one with `after=now`, copy the lists again, and carry on from there.
- The feed covers the fields of the record itself. Rows that hang off a record, such as addresses,
notes, or the mortgage application, are not in it. Read them when their parent changes.
## Webhooks
A webhook is a signed `POST` that BrokerPlus sends to your server when something happens, such as
`lead.created` or `deal.stage_changed`. It arrives in seconds, so it is the fastest signal you get.
A webhook is a **hint**, not a record of truth. A delivery can arrive late, arrive twice, or, after
an outage longer than its retry window, not arrive at all. So on each delivery, read the change feed
from your checkpoint, or read the record by its id. Never build your copy from the webhook bodies
alone.
How to register an endpoint, verify a signature, and read the delivery log is on the
[Webhooks](/webhooks) page.
## `updated_since` on a list
The main record lists accept `updated_since`: contacts, leads, deals, tasks, intakes, appointments,
submissions, communications, opportunities, and campaigns. The [API reference](/reference) shows it
on each list that has it.
With `updated_since`, a list returns the rows that changed at or after that time, oldest change
first, with ties broken by `id`. To read on from the last row you saved, send its `updated_at` as
`updated_since` and its `id` as `after_id`, and keep `page` at `1`:
```bash
curl "https://api.mos.getbrokerplus.ca/v1/contacts?updated_since=2026-10-01T14:03:22.118Z&after_id=cmcontact8f2...&page_size=100" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
Don't sync with page numbers. A row that changes while you read moves to the end of the order, every
later row shifts up one place, and the next page skips a row. The `updated_at` and `id` pair does
not move, so nothing is skipped.
A value of `updated_since` that is not a date returns `400 VALIDATION_ERROR`, and so does `after_id`
without `updated_since`.
### Records that leave a list
A list cannot tell you that a record is gone. It just stops returning it.
| Record | Leaves the list when |
| ------- | -------------------------------------------------------------------- |
| Contact | It is deleted, archived, or merged into another contact. |
| Deal | It is deleted, or BrokerPlus marks it as a test. |
| Lead | It is deleted. An archived lead stays, with `archived_at` set. |
The change feed reports all of these. For a list the feed does not cover, run a reconcile now and
then: read the full list, and remove from your copy each id that is no longer in it.
## Small lists
Users, teams, pipelines, tags, lenders, and the other setup lists are small and change rarely. Read
them in full on each run.
---
## Document: Sync your book
Keep a copy of your contacts, leads, and deals current in your own database, without losing a row.
URL: /sync-your-book
# Sync your book
Keep a copy of your BrokerPlus records in your own database or warehouse. This guide walks through
the code: the first full copy, the incremental sync after it, and the records that drop out of a
list. For when to use the change feed, `updated_since`, or webhooks, read [Sync](/sync) first.
Give the sync its own key with the `read_only` limit. A sync never needs to write. See
[Authentication](/authentication).
## Sync with the change feed
The change feed is the better path. It never skips a change, and it tells you when a record is
deleted, merged, or archived. `GET /v1/changes` covers contacts, leads, deals, tasks, intakes,
documents, appointments, and communications.
1. Call `GET /v1/changes?after=now` and save the `next_checkpoint`.
2. Copy every record from the lists, for example `GET /v1/contacts`, page by page.
3. Call `GET /v1/changes?after=`, apply each entry, and save the new
`next_checkpoint`.
4. While `has_more` is `true`, repeat step 3 right away. When it is `false`, wait a while, then
repeat.
Take the checkpoint in step 1 **before** the copy in step 2. Anything that changes during the copy
is then waiting for you in the feed.
To apply an entry, read the record again by `record_id` and upsert it. Three actions need a little
more:
- `deleted`: mark the record as removed in your copy.
- `archived`: read the record again. It left the active book but you can still read it, and an
archived lead stays in the lead list.
- `merged`: point your references at `merged_into_id`, then remove the record.
```bash title="curl"
curl "https://api.mos.getbrokerplus.ca/v1/changes?after=MS4xMjAwLjE3OTE...&types=contact,deal" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```ts title="TypeScript"
type Change = {
id: string;
record_type: string;
record_id: string;
action: "created" | "updated" | "deleted" | "merged" | "archived";
changed_fields: string[];
merged_into_id: string | null;
occurred_at: string;
};
const API = "https://api.mos.getbrokerplus.ca/v1";
const headers = { Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}` };
// Reads the feed from a saved checkpoint until it is empty. Returns the checkpoint to save.
export async function readChanges(checkpoint: string, apply: (change: Change) => Promise) {
for (;;) {
const url = new URL(`${API}/changes`);
url.search = new URLSearchParams({ after: checkpoint, types: "contact,deal", limit: "100" }).toString();
const res = await fetch(url, { headers });
if (res.status === 410) throw new Error("Checkpoint expired. Copy the lists again from a new checkpoint.");
if (!res.ok) throw new Error(`changes: ${res.status} ${await res.text()}`);
const { data, pagination } = await res.json();
for (const change of data as Change[]) await apply(change);
checkpoint = pagination.next_checkpoint;
if (!pagination.has_more) return checkpoint;
}
}
```
```python title="Python"
import os
import requests
API = "https://api.mos.getbrokerplus.ca/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['BROKERPLUS_API_KEY']}"}
def read_changes(checkpoint, apply):
"""Reads the feed from a saved checkpoint until it is empty. Returns the checkpoint to save."""
while True:
params = {"after": checkpoint, "types": "contact,deal", "limit": 100}
res = requests.get(f"{API}/changes", params=params, headers=HEADERS, timeout=60)
if res.status_code == 410:
raise RuntimeError("Checkpoint expired. Copy the lists again from a new checkpoint.")
res.raise_for_status()
body = res.json()
for change in body["data"]:
apply(change)
checkpoint = body["pagination"]["next_checkpoint"]
if not body["pagination"]["has_more"]:
return checkpoint
```
```json
{
"success": true,
"data": [
{
"id": "1201",
"record_type": "contact",
"record_id": "cmsctz1fa...",
"action": "updated",
"changed_fields": ["email", "updated_at"],
"merged_into_id": null,
"occurred_at": "2026-10-07T14:20:00.000Z"
}
],
"pagination": { "next_checkpoint": "MS4xMjAyLjE3OTE...", "has_more": false }
}
```
Things that trip people up:
- A checkpoint belongs to the `types` you sent with it. Keep one checkpoint per `types` value.
Sending it with more types returns `400 INVALID_CHECKPOINT`.
- A checkpoint older than 30 days returns `410 CHECKPOINT_EXPIRED`. Start again from step 1.
- A page can hold fewer entries than `limit` and still have `has_more: true`. Decide on
`has_more`, never on the count.
- Entries arrive in the order the changes became visible, which can differ from `occurred_at`.
- The feed covers the record's own fields. Addresses, notes, the mortgage application, and tags
aren't in it yet, so read those from their own endpoints on a schedule.
## Sync with `updated_since`
Use this loop for a list the change feed doesn't cover, or for a one-off copy. Most record lists
take `updated_since` and `after_id`; the [API reference](/reference) shows which. Small lists such
as users, teams, pipelines, and lenders don't need it: read them in full each run.
With `updated_since`, rows come back oldest `updated_at` first, with `id` breaking ties. So the
last row you saved is your position in the list. Send it back to get what comes next:
| Parameter | Value |
| --------------- | ------------------------------------------- |
| `updated_since` | The `updated_at` of the last row you saved. |
| `after_id` | The `id` of the last row you saved. |
Keep `page` at `1` and stop when `data` is empty. For each list:
1. Load your saved cursor (an `updated_at` and an `id`). On the first run there is none: send
`updated_since=1970-01-01T00:00:00Z` and no `after_id`.
2. Request the list with `page_size=100`.
3. Upsert each row into your database by `id`.
4. Save the last row's `updated_at` and `id` as the new cursor.
5. If `data` wasn't empty, go back to step 2.
```bash title="curl"
curl "https://api.mos.getbrokerplus.ca/v1/contacts?updated_since=2026-10-01T14:03:22.118Z&after_id=cmcontact8f2...&page_size=100" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```ts title="TypeScript"
type Cursor = { updatedAt: string; id: string | null };
type Row = { id: string; updated_at: string };
const API = "https://api.mos.getbrokerplus.ca/v1";
export async function syncList(
list: string,
cursor: Cursor,
upsert: (rows: Row[]) => Promise,
saveCursor: (cursor: Cursor) => Promise,
) {
for (;;) {
const url = new URL(`${API}/${list}`);
url.searchParams.set("updated_since", cursor.updatedAt);
if (cursor.id) url.searchParams.set("after_id", cursor.id);
url.searchParams.set("page_size", "100");
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}` },
});
if (res.status === 429) {
await new Promise((r) => setTimeout(r, Number(res.headers.get("Retry-After") ?? 60) * 1000));
continue;
}
if (!res.ok) throw new Error(`${list}: ${res.status} ${await res.text()}`);
const { data } = (await res.json()) as { data: Row[] };
if (data.length === 0) return;
await upsert(data);
const last = data[data.length - 1];
cursor = { updatedAt: last.updated_at, id: last.id };
await saveCursor(cursor);
}
}
// First run: await syncList("contacts", { updatedAt: "1970-01-01T00:00:00Z", id: null }, upsert, save);
```
```python title="Python"
import os
import time
import requests
API = "https://api.mos.getbrokerplus.ca/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['BROKERPLUS_API_KEY']}"}
def sync_list(name, cursor, upsert, save_cursor):
"""cursor is {"updated_at": str, "id": str | None}."""
while True:
params = {"updated_since": cursor["updated_at"], "page_size": 100}
if cursor["id"]:
params["after_id"] = cursor["id"]
res = requests.get(f"{API}/{name}", params=params, headers=HEADERS, timeout=60)
if res.status_code == 429:
time.sleep(int(res.headers.get("Retry-After", "60")))
continue
res.raise_for_status()
data = res.json()["data"]
if not data:
return
upsert(data)
last = data[-1]
cursor = {"updated_at": last["updated_at"], "id": last["id"]}
save_cursor(cursor)
# First run: sync_list("contacts", {"updated_at": "1970-01-01T00:00:00Z", "id": None}, upsert, save)
```
Save the cursor **after** you write the rows. If the job dies in between, the next run reads a few
rows twice, which an upsert makes harmless. A cursor saved too early skips rows.
Don't sync with `page=2`, `page=3`, and so on. A row that changes mid-sync jumps to the end of the
list, every row behind it shifts up one place, and you miss one. The cursor doesn't move when other
rows change. [Sync](/sync) has the details.
## Catch records that leave a list
A list quietly drops a record that was deleted, archived, or merged, and the `updated_since` loop
never sees it again. Use the change feed where it covers the record. Otherwise, run a reconcile job
once a day or once a week:
1. Read the full list, with no `updated_since`, and collect every `id`.
2. Compare that set with the ids in your copy.
3. For each id only you have, call `GET /v1/{list}/{id}`. A `404` means the record is gone from
your key's reach, so mark it removed.
A `404` doesn't always mean deleted. A record that moved to a team outside your key's reach also
returns `404`. And a merged or archived contact still answers `GET /v1/contacts/{id}`, so trust the
list, not the single read, to decide whether a contact is still in the book. [Sync](/sync) lists
which records leave which list.
## Stay inside the limits
- Send `updated_since` exactly as the API returned `updated_at`, for example
`2026-10-01T14:03:22.118Z`. Anything that isn't an ISO 8601 date-time, or `after_id` without
`updated_since`, gets `400 VALIDATION_ERROR`.
- At 100 rows a request, the 600-requests-a-minute limit reads 60,000 rows a minute. See
[Authentication](/authentication#rate-limit).
- Run one sync at a time per key. Two jobs on one key share one rate limit.
## Get told instead of polling
A webhook tells you something changed, so you don't have to poll. Treat it as a nudge to read the
change feed from your checkpoint, not as the copy itself: a webhook can arrive late, twice, or not
at all. See [Webhooks](/webhooks).
---
## Document: Equity reports
Enroll past clients in recurring equity reports, pause or stop them, send one now, and read what each client was sent.
URL: /equity-reports
# Equity reports
An equity report tells a past client what their home is worth, what they owe, and how much equity
they have, on a schedule. It keeps the broker in front of the client between deals. Over the API you
can enroll clients, send a report now, pause or stop reports, and read what each client was sent.
## How enrollment works
A report is about one deal: the client is the deal's primary applicant, and the property is its
subject property. Every request on this page takes `deal_ids`, 1 to 100 of them. Find a client's
deals with `GET /v1/deals?contact_id=`.
A report's settings live on the deal's opportunity: `monthly_reports_enabled`,
`monthly_reports_paused_at`, and `report_frequency`. Read them with `GET /v1/opportunities?deal_id=`,
or list everyone enrolled with `GET /v1/opportunities?monthly_reports_enabled=true`. A deal with no
opportunity can still be enrolled; enrolling creates one.
| Request | What it does |
| ------------------------------------ | ------------------------------------------------------ |
| `POST /v1/equity-reports/enroll` | Starts recurring reports at a frequency. |
| `POST /v1/equity-reports/resume` | Starts paused reports again. |
| `POST /v1/equity-reports/send` | Sends one report now, by email or SMS. |
| `POST /v1/equity-reports/pause` | Pauses reports. The client stays enrolled. |
| `POST /v1/equity-reports/unenroll` | Stops reports. |
| `POST /v1/equity-reports/frequency` | Changes how often reports go out. |
Each request happens now and returns `200`, with one line in `results` for each deal you sent.
Starting reports or sending one reaches a client, so a key with the `no_sending` limit can't do it
(`403 KEY_LIMITED`), and if the broker your key acts as may not contact clients, you get
`403 FORBIDDEN`. Pausing, stopping, or changing the frequency can't send anything, so any key can do
it.
## Consent
The API follows the same rules as the reports page in BrokerPlus:
- A client who opted out of equity reports is refused with `unsubscribed`. Only the broker can enroll
them again, in BrokerPlus.
- A client under a sanctions freeze is refused with `frozen`.
- A deal the broker dismissed from the book is refused with `dismissed`.
- Every time a report goes out, BrokerPlus checks the client's email or SMS consent again. A client
who withdrew consent, or whose address is suppressed, doesn't get it.
These requests have no consent field, so you can't record consent or skip the check through them.
Record or withdraw a client's consent with `PATCH /v1/contacts/{id}/communication-preferences`.
## Enroll clients
Send the deals and a `frequency`: `monthly` (the default), `quarterly`, `semi_annually`, or
`annually`.
```bash title="curl"
curl -X POST "https://api.mos.getbrokerplus.ca/v1/equity-reports/enroll" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: enroll-2026-10-07-1" \
-d '{"deal_ids":["cmsdl1a2b","cmsdl3c4d","cmsdl5e6f"],"frequency":"quarterly"}'
```
```ts title="TypeScript"
const res = await fetch("https://api.mos.getbrokerplus.ca/v1/equity-reports/enroll", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "enroll-2026-10-07-1",
},
body: JSON.stringify({ deal_ids: ["cmsdl1a2b", "cmsdl3c4d", "cmsdl5e6f"], frequency: "quarterly" }),
});
const { data: enrollment } = await res.json();
```
```python title="Python"
import os
import requests
res = requests.post(
"https://api.mos.getbrokerplus.ca/v1/equity-reports/enroll",
json={"deal_ids": ["cmsdl1a2b", "cmsdl3c4d", "cmsdl5e6f"], "frequency": "quarterly"},
headers={
"Authorization": f"Bearer {os.environ['BROKERPLUS_API_KEY']}",
"Idempotency-Key": "enroll-2026-10-07-1",
},
)
enrollment = res.json()["data"]
```
The response is `200`, with one line in `results` for each deal you sent.
```json
{
"success": true,
"data": {
"results": [
{
"deal_id": "cmsdl1a2b",
"opportunity_id": "cmsop7g8h",
"contact_name": "Sam Client",
"result": "changed",
"reason": null
},
{
"deal_id": "cmsdl3c4d",
"opportunity_id": "cmsop9i0j",
"contact_name": "Jordan Client",
"result": "unchanged",
"reason": "already_enrolled"
},
{
"deal_id": "cmsdl5e6f",
"opportunity_id": "cmsop1k2l",
"contact_name": "Riley Client",
"result": "refused",
"reason": "unsubscribed"
}
]
}
}
```
The deals marked `changed` are enrolled now, and the first report goes out when it falls due.
Enrolling doesn't send a report on the spot; call `POST /v1/equity-reports/send` too if you want one
now. The other deals are not changed.
A `changed` deal can carry the reason `no_email`: the client is enrolled, but gets nothing until the
broker adds an email address. If no deal can take the action, nothing changes: the response is
`409 NOTHING_TO_REQUEST`, with each deal's reason in `error.details.results`.
## Send a report now
`POST /v1/equity-reports/send` sends one report to each client without enrolling anyone. Set
`channel` to `email` (sent from the broker's connected mailbox) or `sms` (a text with a link to the
report, with your own words in `sms_body` if you like).
```bash
curl -X POST "https://api.mos.getbrokerplus.ca/v1/equity-reports/send" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"deal_ids":["cmsdl1a2b"],"channel":"email"}'
```
The response has the same shape as an enrollment. A deal marked `changed` got its report now. A
client with no email address is refused with `no_email`, and for `sms`, one with no phone number with
`no_phone`.
## Pause, stop, or change the frequency
Each deal comes back `changed`, `unchanged`, or `refused`, and repeating a call is safe: the second
one returns `unchanged`.
```bash
curl -X POST "https://api.mos.getbrokerplus.ca/v1/equity-reports/pause" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"deal_ids":["cmsdl1a2b","cmsdl7m8n"]}'
```
```json
{
"success": true,
"data": {
"results": [
{
"deal_id": "cmsdl1a2b",
"opportunity_id": "cmsop7g8h",
"contact_name": "Sam Client",
"result": "changed",
"reason": null
},
{
"deal_id": "cmsdl7m8n",
"opportunity_id": null,
"contact_name": null,
"result": "refused",
"reason": "not_found"
}
]
}
}
```
- `POST /v1/equity-reports/pause` keeps the client enrolled. Restart with
`POST /v1/equity-reports/resume`.
- `POST /v1/equity-reports/unenroll` stops reports. Reports already sent stay readable.
- `POST /v1/equity-reports/frequency` takes `frequency` with `deal_ids`. It doesn't enroll anyone.
## Change what the report shows
The report uses the figures on the deal's opportunity and property:
- Set the home's value with `PATCH /v1/opportunities/{id}` and `property_value`. It replaces the
BrokerPlus estimate; send `null` to go back to the estimate.
- Correct the balance or rate with `PATCH /v1/opportunities/{id}` and `balance` or `interest_rate`
(a percent, see [Units](/units)).
- Split the mortgage with `PATCH /v1/opportunities/{id}` and `segments`, for example a fixed portion
beside a line of credit. The list replaces every segment, so send each one you want to keep, or
`[]` to make it one loan again. The first segment that is not a `heloc` sets the opportunity's
product, rate and term. A deal in a locked stage refuses the change with `403 DEAL_LOCKED`.
- Change the address with `PATCH /v1/deals/{id}/property`.
## Read the reports that went out
`GET /v1/equity-reports` lists the reports clients received, newest first, with the figures exactly
as the client saw them. `ltv` is a percent, like every ratio in the API. `channel` says whether it
went by `email` or `sms`, `viewed_at` is when the client opened it, and `cta_clicked_at` is when they
asked the broker to call.
## Read the send and open history
A report is made once a month for each deal, and it can go out more than once. Two lists tell you
what happened after that:
- `GET /v1/equity-reports/sends` lists each time a report went out: to which client, on which
`channel`, at what `frequency` (null for a one-off send), and whether it left BrokerPlus (`status`
is `sent`, `failed`, or `suppressed`). It is the same history as Outreach History in BrokerPlus.
- `GET /v1/equity-reports/interactions` lists what clients did with a report. `field` is
`email_open`, `page_view`, or `cta_click`, or the figure the client corrected (`propertyAddress`,
`propertyValue`, or `mortgageBalance`) with `old_value`, `new_value`, and the equity and
loan-to-value the report showed after the correction.
Both are logs: a row never changes after it is written. To sync, send `updated_since`, and with it
`after_id` from the last row you read. The rows come oldest first.
```bash
curl "https://api.mos.getbrokerplus.ca/v1/equity-reports/interactions?field=cta_click&updated_since=2026-10-01T00:00:00Z" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
## Answer a client's correction
A client can correct their report: the property's address, its value, or the mortgage balance. When
the brokerage reviews corrections, or the deal's client data is locked, the correction waits for the
broker, as on the report changes queue in BrokerPlus.
- `GET /v1/equity-report-changes?status=pending` lists the corrections that wait. `old_value` is
what the report said and `new_value` is what the client says. `held_reason` says why it waited.
- `POST /v1/equity-report-changes/{id}/accept` writes the client's figure onto the deal.
- `POST /v1/equity-report-changes/{id}/decline` keeps the deal's figure. A declined property value
is taken back off the client's report.
Neither sends anything to the client. A correction can be answered once: a second answer gets `404`.
Accepting on a deal whose client data is locked to another broker gets `403`.
```bash
curl -X POST "https://api.mos.getbrokerplus.ca/v1/equity-report-changes/cmt5e2r8k0003p3iu7h1c9d2a/accept" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
## Retention signals
A retention signal is a reason BrokerPlus raised to reach a past client now: an `equity` signal when
their loan-to-value fell below the brokerage's line, or a date of theirs (`mortgage_anniversary`,
`birthday`, or `new_year`). `GET /v1/pulse-signals` lists them, newest first. Filter by `type`,
`status`, `contact_id`, or `deal_id`. A `drafted` signal has a draft waiting for the broker: read it
with `GET /v1/drafts/{id}` using `draft_id`, and approve it as any other draft.
`GET /v1/pulse-triggers` lists the broker's date triggers: the message for each date, how many days
before or after it they go out, and whether they send on their own (`auto_send`). The broker has at
most one trigger for each date.
`POST /v1/pulse-triggers` sets one up, switched off, with the `type` and an optional `days_offset`
from -90 to 90. `PATCH /v1/pulse-triggers/{id}` moves `days_offset`.
`POST /v1/pulse-triggers/{id}/turn-on` turns it on, and from then on BrokerPlus writes to each of the
broker's clients on that date, so a key with the `no_sending` limit is refused it.
`POST /v1/pulse-triggers/{id}/turn-off` turns it off. A message it already scheduled, up to a week
ahead, still goes out. The message and `auto_send` are set in BrokerPlus, and there is no delete:
turn the trigger off instead.
## Errors
| Status | Code | What to do |
| ------ | -------------------- | --------------------------------------------------------------------- |
| `400` | `VALIDATION_ERROR` | Send `deal_ids` as an array of 1 to 100 ids. `details` names the field. |
| `403` | `KEY_LIMITED` | The key has the `no_sending` limit. Use a key without it. |
| `403` | `POLICY_BLOCKED` | The brokerage's automation policy doesn't allow the request. |
| `409` | `NOTHING_TO_REQUEST` | No deal can take the action. Read `error.details.results`. |
---
## Document: Idempotency and batch
Retry a request safely after a timeout, and write up to 100 records in one call without making duplicates.
URL: /idempotency
# Idempotency and batch
A timeout doesn't tell you whether BrokerPlus made the record. Send the same request again without
protection and you can end up with two contacts, two leads, or two tasks. This page covers the two
tools that prevent that: the `Idempotency-Key` header for single requests, and `external_id` for
batch writes.
## The `Idempotency-Key` header
Make up a unique key for each new operation, such as a UUID, and send it on every `POST`. If you
retry with the same key and the same request, the API returns the response it stored the first
time, with an `Idempotent-Replay: true` header, and does not do the work again.
```bash title="curl"
curl -X POST https://api.mos.getbrokerplus.ca/v1/contacts \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Idempotency-Key: 8f14e45f-ea0a-4c1b-9a1e-2b7c9d3e5f60" \
-H "Content-Type: application/json" \
-d '{"first_name":"Sam","last_name":"Client","email":"sam@example.com"}'
```
```ts title="TypeScript"
// Make the key once, and reuse it for every retry of this operation.
const idempotencyKey = crypto.randomUUID();
const body = JSON.stringify({ first_name: "Sam", last_name: "Client", email: "sam@example.com" });
const res = await fetch("https://api.mos.getbrokerplus.ca/v1/contacts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}`,
"Idempotency-Key": idempotencyKey,
"Content-Type": "application/json",
},
body,
});
```
```python title="Python"
import json
import os
import uuid
import requests
# Make the key once, and reuse it for every retry of this operation.
idempotency_key = str(uuid.uuid4())
body = json.dumps({"first_name": "Sam", "last_name": "Client", "email": "sam@example.com"})
res = requests.post(
"https://api.mos.getbrokerplus.ca/v1/contacts",
data=body,
headers={
"Authorization": f"Bearer {os.environ['BROKERPLUS_API_KEY']}",
"Idempotency-Key": idempotency_key,
"Content-Type": "application/json",
},
)
```
**A retry must send exactly the same bytes.** Serialize the body once and send that string on every
attempt. Different whitespace or a different key order counts as a different request.
A good key comes from your own data, so a crashed job finds the same key when it restarts. For
example, `webform-` or `zoho-contact-`.
### What the header covers
| Rule | Detail |
| ----------- | ------------------------------------------------------------------------------------------------------- |
| Methods | `POST` only. `PATCH` and `DELETE` are not protected. |
| Lifetime | 24 hours. After that the key is forgotten, which does not mean the first request failed. |
| Ownership | A key belongs to the API key and broker that first used it. Key strings are unique across the brokerage. |
| Match | The method, path, and body bytes must all match. |
| Access | If you can no longer see the record the first request made, the replay returns `404` and changes nothing. |
| Optional | Without the header, nothing is stored and a retry is a new request. |
The header makes a retry safe, but it is not an exactly-once guarantee. The record write and the
stored response are separate steps, so a server error can land between them. After a `5xx` or a
timeout, check the record before you send a request with a **new** key.
### Conflicts
| Response | What happened, and what to do |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `409 IDEMPOTENCY_KEY_IN_FLIGHT` | The first request is still running. Wait, then retry with the same key and body. If it persists, check whether the record exists. |
| `409 IDEMPOTENCY_KEY_CLAIMED` | Another API key or broker owns this key. Nothing ran. Don't switch to a new key until you know whether the first request worked, or you can make a duplicate. |
| `422 IDEMPOTENCY_KEY_REUSED` | The key is yours, but the request differs from the first one. Send the original method, path, and body, or use a new key for a genuinely new operation. |
A request that fails validation before it changes anything releases its key. Fix the body and try
again with the same key.
## Batch writes
Four endpoints create or update up to **100 records** in one request:
- `POST /v1/contacts/batch`
- `POST /v1/leads/batch`
- `POST /v1/deals/batch`
- `POST /v1/tasks/batch`
Each item is the body you would send to the single create (`POST /v1/contacts`) or, for a record
that exists, the single update (`PATCH /v1/contacts/{id}`). The single endpoint checks each item, so
the rules and errors are the same.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/contacts/batch \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"namespace": "zoho",
"items": [
{ "external_id": "4410001", "data": { "first_name": "Sam", "last_name": "Client", "email": "sam@example.com" } },
{ "external_id": "4410002", "data": { "first_name": "Jordan", "email": "not-an-email" } }
]
}'
```
```json
{
"success": true,
"data": {
"created": 1,
"updated": 0,
"failed": 1,
"results": [
{ "object": "batch_item_result", "index": 0, "status": "created", "id": "cmsclk2a9...", "external_id": "4410001", "error": null },
{
"object": "batch_item_result",
"index": 1,
"status": "failed",
"id": null,
"external_id": "4410002",
"error": { "status": 400, "code": "VALIDATION_ERROR", "message": "Invalid email" }
}
]
}
}
```
How a batch behaves:
- **Send `external_id` with a `namespace`**, and BrokerPlus remembers the pair. The next batch with
the same `external_id` updates that record instead of making a new one. The `namespace` is the
name of the system your ids come from, such as `zoho`, so two systems can use the same id.
- To update a record you already know, send its BrokerPlus `id`. Send `id` and `external_id`
together to link your id to a record that is already in BrokerPlus, so later batches find it.
- If the record an `external_id` names was deleted or merged in BrokerPlus, that item fails with
`404 NOT_FOUND` and nothing is written. BrokerPlus does not quietly re-create what the broker
removed.
- **Items are not one transaction.** A failed item doesn't stop the others. Read `results`, one per
item in the order you sent them, and retry only the items that failed.
- An `external_id` that already names a different record fails that item with `409 EXTERNAL_ID_TAKEN`.
Two requests racing on the same new `external_id` fail one item with `409 EXTERNAL_ID_IN_FLIGHT`;
send it again shortly.
- A batch that is invalid as a whole (no items, more than 100, the same `external_id` twice, or an
`external_id` without `namespace`) returns `400` and writes nothing.
- **Each item counts as one request** against the [rate limit](/authentication#rate-limit).
- A batch is a `POST`, so the `Idempotency-Key` header works on it too. A retry with the same key
replays the whole batch response.
The [Migrate from another CRM](/migrate-from-a-crm) guide uses batch writes for a full import.
To complete, cancel, or reopen many tasks at once, use `POST /v1/tasks/bulk` (up to 200 tasks). It
applies one action and lists the tasks it changed and skipped.
---
## Document: Reaching clients
Which requests email, text, book, or otherwise reach a client, and how to keep a key from making them.
URL: /reaching-clients
# Reaching clients
Some requests put something in front of a client: an email, a text, a booking, a form to sign. The
API does these at once, as the broker your key acts as, through the same steps BrokerPlus uses when
the broker clicks the button. The response is the record that the request made or changed, with
`200` or `201`.
A key acts as its broker, so a request from your key is the broker's own request. Make sure your
integration, or the AI assistant that uses it, sends only what the broker wants sent.
## Keep a key from reaching clients
Give the key the [`no_sending` limit](/authentication#limits-on-a-key) when you create it. A key
with that limit gets `403` on every request in the table below, and it can still read and write
everything else. Use it for an integration that has no reason to contact clients, such as a
reporting tool or a sync.
An MCP client sees each of these tools marked as one that reaches people outside BrokerPlus and
cannot be undone, so the client asks its user before it calls one. A key with the `no_sending` limit
doesn't see these tools at all. See [MCP](/mcp).
## What reaches a client
| Request | What happens |
| ----------------------------------------------------------- | ----------------------------------------------------------------- |
| `POST /v1/contacts/{id}/messages` | One email or text goes to one contact. |
| `POST /v1/drafts/{id}/send` | A draft goes out, exactly as it is written now. |
| `POST /v1/campaigns/{id}/schedule` | A campaign is scheduled for the time you give. |
| `POST /v1/automation-runs/{id}/start` | A waiting automation run starts now instead of at its time. |
| `POST /v1/automations/{id}/turn-on` | An automation turns on and messages everybody it matches. |
| `POST /v1/automations/canvases/{id}/turn-on` | Each automation in the group turns on. |
| `POST /v1/lead-forms/{id}/turn-on-auto-reply` | The form replies to each person who fills it in. |
| `POST /v1/automations/stops/{id}/lift` | Automations can message the client again. |
| `POST /v1/appointments/book` | A meeting is booked, and the client gets the confirmation email. |
| `POST /v1/appointments/{id}/reschedule` | A meeting moves to a new time, and the client gets an email. |
| `POST /v1/appointments/{id}/cancel` | A meeting is cancelled, and the client gets an email. |
| `POST /v1/intakes` with `delivery: "email"` | A new intake is emailed to each applicant. |
| `POST /v1/intakes/{id}/request-changes` | Intake answers are reopened, and the client gets an email. |
| `POST /v1/deals/{id}/document-requests` | Documents are added to the client's portal, with an email. |
| `POST /v1/deals/{id}/condition-requests` | A plain-language letter about conditions goes to one person. |
| `POST /v1/deals/{id}/generated-documents/{documentId}/send` | A generated letter is emailed to one person on the deal. |
| `POST /v1/deals/{id}/signing-documents/send` | Each signer gets one link to the documents to sign. |
| `POST /v1/deals/{id}/signing-documents/{documentId}/resend` | One signer gets a new signing link. |
| `POST /v1/deals/{id}/funding-package/send` | The funding package goes out in one envelope. |
| `POST /v1/equity-reports/enroll` | Recurring equity reports start for each client. |
| `POST /v1/equity-reports/resume` | Paused equity reports start again. |
| `POST /v1/equity-reports/send` | One equity report goes to each client now. |
| `POST /v1/nps-surveys` | One NPS survey is emailed to one client. |
| `POST /v1/pep-forms` | The PEP declaration form is emailed to one client. |
| `POST /v1/deals/{id}/consent-forms` | A consent form is emailed to one borrower on the deal. |
| `POST /v1/mail-merges` | A separate copy of one email goes to each address on a list. |
| `POST /v1/deals/{id}/tracker/{stageId}/reach` | The client sees the stage as reached, and its automations start. |
| `POST /v1/tracker-stages` | Every client of the team sees the new stage. |
| `PATCH /v1/tracker-stages/{id}` | Every client of the team sees the changed stage. |
| `POST /v1/tracker-stages/reorder` | Every client of the team sees the stages in the new order. |
| `POST /v1/tracker-stages/seed` | Every client of the team sees the standard stages. |
| `PATCH /v1/tracker-stages/note` | Clients see the new tracker note. |
| `POST /v1/hub-resources` | One client, or every client, sees the PDF in their hub. |
Stopping something reaches nobody, so a `no_sending` key can do it: cancelling an automation run,
pausing a campaign, turning an automation off, pausing or stopping equity reports, and stopping
automations for a client.
## Send safely
- **Send an `Idempotency-Key`** with every one of these requests. A retry with the same key returns
the first result and sends nothing a second time. See [Idempotency](/idempotency).
- **Read the error.** A request that BrokerPlus refuses sends nothing. `412 EMAIL_NOT_CONNECTED` and
`412 SMS_NOT_CONNECTED` mean the broker has no connected mailbox or phone number. `409 NOT_SENT`
means the request could not go out as it stands, for example because the time is no longer open.
`403 FORBIDDEN` means the person your key acts as cannot send this in BrokerPlus either.
`403 POLICY_BLOCKED` means your brokerage's automation policy does not allow messages to clients
to be asked for this way.
- **Test in a sandbox first.** A sandbox key sends nothing to anybody, and you can read what it
would have sent. See [Sandbox](/sandbox).
---
## Document: Rates
Read and set the rates a broker and a brokerage keep on the Rates page, which drive the refinance, renewal, and penalty figures in BrokerPlus.
URL: /rates
# Rates
The **Rates** page in BrokerPlus holds the rates a broker quotes. BrokerPlus pulls live rates for the
lenders it knows, and the broker can replace any of them, add an offset to a lender, or add a lender
that BrokerPlus does not pull. The refinance, renewal, and penalty figures use those rates. The API
reads and sets the same rates.
This API is in beta. Any endpoint can change in a breaking way without notice, so watch the
[changelog](/changelog).
## Units
Every rate is a **percent**: `4.29` means 4.29%. A difference from prime is a percent too: `-0.85`
means prime minus 0.85%. An offset is in **basis points**: `-10` means 0.10% below the live rate. See
[Units](/units).
A lender BrokerPlus pulls rates for has a slug, for example `rbc`. A lender the broker added has a
slug that starts with `custom:`.
## The broker's own rates
These rates belong to the broker who owns the key. Another broker in the brokerage never sees them.
`POST /v1/me/rate-overrides` sets the rate for one lender, term, and product, the same as a cell on
the Rates page. Send `lender_slug` to replace a live rate, or `custom_lender_name` for a custom
lender. If a rate already exists for that lender, term, and product, it changes.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/me/rate-overrides \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"lender_slug":"rbc","term_months":60,"product_type":"fixed","rate":4.29}'
```
```json
{
"success": true,
"data": {
"object": "rate_override",
"id": "cm9r4t1a0000",
"lender_slug": "rbc",
"lender_name": null,
"is_custom": false,
"lender_category": null,
"term_months": 60,
"product_type": "fixed",
"rate": 4.29,
"prime_offset": null,
"original_rate": 4.29,
"note": null,
"expires_at": null,
"locked_by_brokerage": false,
"created_at": "2026-10-09T15:00:00.000Z",
"updated_at": "2026-10-09T15:00:00.000Z"
}
}
```
For a variable rate, send `prime_offset` instead of `rate`. BrokerPlus then calculates the rate from
prime, and moves it when prime moves. The term is `0` for a variable rate and `-1` for a rental rate.
When `locked_by_brokerage` is `true`, the brokerage sets that lender, term, and product for every
broker, so BrokerPlus uses the brokerage rate and not this one.
| Call | What it does |
| ----------------------------------------- | ------------------------------------------------------------------- |
| `GET /v1/me/rate-overrides` | Lists the broker's rates. |
| `DELETE /v1/me/rate-overrides/{id}` | Removes one rate. A pulled lender goes back to its live rate. |
| `POST /v1/me/rate-overrides/reset` | Removes every rate for a pulled lender and every offset. Custom lenders stay. |
| `POST /v1/me/custom-lenders` | Adds up to 25 custom lenders, each with its rates, in one call. |
| `PATCH /v1/me/custom-lenders/{lenderSlug}` | Renames a custom lender. |
| `GET /v1/me/lender-offsets` | Lists the broker's lender offsets. |
| `POST /v1/me/lender-offsets` | Sets the offset for one lender, in basis points. |
| `DELETE /v1/me/lender-offsets/{lenderSlug}` | Removes the offset for one lender. |
A custom lender is matched by name, in any case. If you add `Beacon Trust` twice, the broker has one
lender with the rates from both calls.
## The brokerage's rates
A brokerage can set rates and lender offsets for every broker at once, on the brokerage **Rates**
page. Only a key that belongs to a brokerage admin can read or change them. Any other key gets
`403 FORBIDDEN`.
`POST /v1/brokerage/rate-overrides` takes the same body as `POST /v1/me/rate-overrides`, without
`prime_offset`. The answer has `"object": "brokerage_rate_override"`. While the brokerage sets a
lender, term, and product, every broker's own rate for it shows `locked_by_brokerage: true`, and
BrokerPlus uses the brokerage rate.
A brokerage offset wins over a broker's own offset for the same lender.
| Call | What it does |
| -------------------------------------------------- | -------------------------------------------------------------------- |
| `GET /v1/brokerage/rate-overrides` | Lists the brokerage's rates. |
| `POST /v1/brokerage/rate-overrides` | Sets the brokerage's rate for one lender, term, and product. |
| `DELETE /v1/brokerage/rate-overrides/{id}` | Removes one brokerage rate. |
| `POST /v1/brokerage/rate-overrides/reset` | Removes every brokerage rate for a pulled lender and every brokerage offset. Custom lenders stay. |
| `GET /v1/brokerage/lender-offsets` | Lists the brokerage's lender offsets. |
| `POST /v1/brokerage/lender-offsets` | Sets the brokerage's offset for one lender, in basis points. |
| `DELETE /v1/brokerage/lender-offsets/{lenderSlug}` | Removes the brokerage's offset for one lender. |
## Lender cashback
Some lenders pay the client cashback at closing. BrokerPlus keeps a cashback for each lender it
knows, and a brokerage can set its own in place of it. The opportunity figures use the result for
every client whose best option is that lender.
`GET /v1/lender-cashback` lists one rule per lender, sorted by slug. Its `id` is the lender's slug.
`source` is `override` for the brokerage's own rule and `default` for the one BrokerPlus keeps.
| Field | Unit |
| ------------------ | --------------------------------------------------------------------- |
| `cashback_percent` | A **percent** of the balance: `1` means 1%. |
| `flat_amount` | Dollars. When it is set, it wins over `cashback_percent`. |
| `min_balance`, `max_balance` | Dollars. The cashback applies only to a balance in that range. |
| `rate_premium_bps` | **Basis points** the cashback product usually costs over the best rate. |
Any key can read the list. Only a key that belongs to a brokerage admin can change it:
`POST /v1/lender-cashback` sets the brokerage's own rule for one lender, and
`DELETE /v1/lender-cashback/{lenderSlug}` goes back to the one BrokerPlus keeps.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/lender-cashback \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"lender_slug":"td","cashback_percent":1.5,"max_balance":900000}'
```
## Rate sheets
A broker can upload a lender's rate sheet: a PDF, an image, or a Word file. BrokerPlus reads the
lender, the date, and every rate on it. Nothing changes on the broker's rates until the sheet is
applied, the same as the review step on the Rates page.
1. `POST /v1/rate-sheets` uploads the file, as `multipart/form-data` in a `file` field or as JSON
with `file_name`, `content_type`, and `content_base64`. The answer has the rates BrokerPlus read,
and `mapping`: for each cell of the Rates page, the rates on the sheet that fit it, with the one
BrokerPlus suggests at `suggested_index`.
2. `POST /v1/rate-sheets/{id}/apply` puts the rows you choose on the broker's own rates. Leave out
a cell to keep what is there.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/rate-sheets/cm9s2a7b0000/apply \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"rows":[{"term_months":60,"product_type":"fixed","rate":4.19},{"term_months":0,"product_type":"variable","rate":4.95,"prime_offset":-0.75}]}'
```
Every rate is a **percent**, on the sheet and in an apply. A term is `0` for a variable rate, or
12, 24, 36, 48, or 60 months for a fixed rate.
BrokerPlus files a sheet under the lender it read. To file it under another one, send `lender` in the
apply: `{"slug":"rbc"}` for a lender BrokerPlus pulls, or `{"slug":null,"name":"Beacon Trust"}` for a
custom lender.
`GET /v1/rate-sheets` lists the brokerage's sheets, `GET /v1/rate-sheets/{id}` returns one with its
`mapping`, and `DELETE /v1/rate-sheets/{id}` deletes one. Deleting a sheet leaves the rates it set.
---
## Document: Migrate from another CRM
Move contacts, deals, notes, and custom fields from another CRM into BrokerPlus, with no duplicates and inside the rate limit.
URL: /migrate-from-a-crm
# 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
```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 {
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")
```
## 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.
```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"]
```
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.
---
## Document: Test in a sandbox
Build against a private brokerage full of sample data that never emails, texts, pulls credit, or submits to a lender.
URL: /sandbox
# Test in a sandbox
A live key acts as a real broker in a real brokerage, so a bug in your integration can change a real
book or reach a real client. A **sandbox** is a private brokerage with sample data that records
everything it would have sent, and sends none of it. Build there, then swap the key.
## Create a sandbox
An admin of your brokerage can make one.
1. Go to **Settings > Developer**.
2. Under **Sandboxes**, select **New sandbox**, give it a name, and select **Create sandbox**.
3. Copy the test key, the sign-in email, and the password. You will only see them once.
You can have three sandboxes at a time.
BrokerPlus fills a new sandbox with sample contacts, deals, and tasks within a few moments. Nothing
from your real brokerage is copied in. Sign in to the web app with the sandbox email and password to
watch what your integration does, as a broker would see it. An admin of your real brokerage can
reset that password in **Settings > Developer**.
## Sandbox keys
A sandbox key starts with `bp_test_`, and a live key with `bp_live_`. Otherwise they work the same,
in the same `Authorization` header.
The two never cross. A sandbox key only reaches its own sandbox, a live key cannot reach a sandbox,
and a key whose prefix does not match its brokerage gets `401`. Ids from your real brokerage do not
exist in a sandbox, so they return `404`.
## What a sandbox does instead of sending
Every flow runs as it would in production, up to the point where something would leave BrokerPlus.
| Action | In a sandbox |
| ---------------------------------- | ---------------------------------------------------------------------------- |
| Email and text messages | Recorded, and reported as sent. |
| Credit reports | Recorded. Returns the Equifax test subject, not a real credit file. |
| Lender submissions | Recorded. No lender gets the deal, and no decision comes back. |
| Billing, phone numbers, and calls | Refused with `403 SANDBOX_REFUSED`. |
Every request that [reaches a client](/reaching-clients) runs in a sandbox too, up to the send,
which is recorded instead. Webhooks from a sandbox are delivered to your endpoint like live ones, which makes a
sandbox the right place to test your [webhook](/webhooks) receiver.
## See what it would have sent
`GET /v1/sandbox/outbox` lists every message and request the sandbox recorded, newest first. Only a
sandbox key can call it; a live key gets `403 SANDBOX_ONLY`.
```bash title="curl"
curl "https://api.mos.getbrokerplus.ca/v1/sandbox/outbox?channel=email" \
-H "Authorization: Bearer $BROKERPLUS_TEST_KEY"
```
```json
{
"success": true,
"data": [
{
"object": "sandbox_outbox_entry",
"id": "cmuy7q2k0001",
"channel": "email",
"adapter": "broker_email",
"recipients": ["sam@example.com"],
"subject": "Your mortgage documents",
"body": "Hello Sam,
",
"deal_id": "cmuy7p9x0004",
"created_at": "2026-10-07T14:02:11.000Z"
}
],
"pagination": { "page": 1, "page_size": 50, "total": 1, "total_pages": 1 }
}
```
Filter with `channel` (`email`, `sms`, `credit`, or `lender`). With `updated_since`, you get only
what was recorded at or after that time, oldest first.
## Expiry
A sandbox lasts 30 days from the last time its key was used, and each request pushes that date
out. After that, the key returns `401` and BrokerPlus deletes the sandbox and its data. To start
over with fresh sample data, delete the sandbox in **Settings > Developer** and make a new one.
## Before you go live
A sandbox is close to production, not identical. A lender package can need the full credit report
file, which the sample credit report does not have, so a sandbox can show a deal as not ready to
submit when a real one would be.
When everything works, create a live key and change only the key.
---
## Document: Configure your workspace
Create and change the custom fields, tags, pipelines, stages, meeting types, and other settings a brokerage keeps in BrokerPlus.
URL: /workspace
# Configure your workspace
Everything a broker sets up under **Settings** in BrokerPlus (custom fields, tags, lead stages,
pipelines, task categories, meeting types, hours, and the email signature) can also be set up
through the API. That makes it practical to copy a setup between brokerages, or to keep one in code.
This API is in beta. Any endpoint can change in a breaking way without notice, so watch the
[changelog](/changelog).
## Who can change a setting
A key can change a setting only when its broker can change the same setting in BrokerPlus.
| Key | Result |
| ---------------------------------------- | ------------------------------------------------ |
| Not an admin, on an admin-only setting | `403 FORBIDDEN`, and the message names the setting |
| Belongs to a view-only broker | `403 VIEW_ONLY` |
| Has the `read_only` limit | `403 READ_ONLY_KEY` |
| Has the `leads_only` limit | `403 KEY_LIMITED` |
Settings belong to one brokerage, so an id from another brokerage is `404 NOT_FOUND`. See
[Authentication](/authentication) for keys and their limits.
Billing, roles, invitations, team membership, brand settings, and integration credentials are never
in the API. Change those in BrokerPlus.
## Custom fields
A custom field is a question the brokerage adds to every contact or every deal (a lead uses the
contact fields). Only an admin's key can create, change, order, or archive one.
`POST /v1/custom-fields` creates a field, and BrokerPlus makes its `key` from the `label`. You use
that `key` to read and write answers on records.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/custom-fields \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"entity":"deal","label":"Lead Source","type":"select","options":["Google","Referral"],"default_value":"Referral"}'
```
```json
{
"success": true,
"data": {
"object": "custom_field_definition",
"id": "cm9f2k1x0000",
"entity": "deal",
"key": "lead_source",
"label": "Lead Source",
"type": "select",
"options": ["Google", "Referral"],
"section": "Custom Fields",
"help_text": null,
"default_value": "Referral",
"position": 4,
"created_at": "2026-10-07T14:02:11.000Z",
"updated_at": "2026-10-07T14:02:11.000Z"
}
}
```
`PATCH /v1/custom-fields/{id}` changes the `label`, `options`, `section`, or `default_value`. The
`key` and `type` are fixed, because every record's answers depend on them.
- `options` is the full list. An option you leave out is removed, and an option keeps its answers
only if you send its label unchanged.
- Removing the default option sets the default to null.
- A new default applies only to records created after the change.
`POST /v1/custom-fields/reorder` takes one entity's field ids, first to last; fields you leave out
keep their place. `DELETE /v1/custom-fields/{id}` archives a field: it leaves every form, but
BrokerPlus keeps the answers. Creating a field with the same label and type brings it back, answers
and all.
## Tags
Contact tags and deal tags are two separate lists. Any key that can write can create, change, and
archive tags, because every broker can in BrokerPlus.
Create one with `POST /v1/contact-tags` or `POST /v1/deal-tags`. On a contact tag, `is_type: true`
shows it in the contact's Type row; deal tags don't take `is_type`.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/contact-tags \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Realtor","color":"amber","is_type":true}'
```
```json
{
"success": true,
"data": {
"object": "tag",
"id": "cm9f2m7q0000",
"name": "Realtor",
"color": "amber",
"position": 3,
"is_type": true,
"created_at": "2026-10-07T14:05:40.000Z",
"updated_at": "2026-10-07T14:05:40.000Z"
}
}
```
`PATCH` changes the name or colour (`navy`, `amber`, `slate`, `red`, or a `#rrggbb` hex). `DELETE`
archives the tag and takes it off every record. Creating a tag with an archived tag's name restores
the tag, but not its place on those records.
To put a tag on a record or take it off, use `POST /v1/contacts/{id}/tags` and
`DELETE /v1/contacts/{id}/tags/{tagId}`, or the same paths under `/v1/deals`.
## Lead stages
Lead stages are the columns of the Leads board. A `leads_only` key can read them but not change
them.
- `POST /v1/lead-stages` adds a stage at the end of the board.
- `PATCH /v1/lead-stages/{id}` renames it; its leads stay put.
- `POST /v1/lead-stages/reorder` sets the order. Every id must be a live stage, or nothing changes.
- `DELETE /v1/lead-stages/{id}` deletes it and moves its leads to the stage in `move_to`, or to the
first remaining stage. The board keeps at least one stage, so deleting the last one returns
`409 LAST_STAGE`.
```bash
curl -X DELETE "https://api.mos.getbrokerplus.ca/v1/lead-stages/cm9f2n1a0000?move_to=cm9f2n1b0000" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```json
{
"success": true,
"data": { "id": "cm9f2n1a0000", "deleted": true }
}
```
## Lead channels
A lead channel says how a lead found the brokerage, such as Google or a referral.
- `POST /v1/lead-channels` creates one.
- `PATCH /v1/lead-channels/{id}` renames or reorders it. Channels BrokerPlus made for every brokerage
can move but can't be renamed (`400 SYSTEM_CHANNEL`).
- `DELETE /v1/lead-channels/{id}` archives it, and the leads and deals that had it are left with no
channel.
## Referral partners
A referral partner is someone who sends the broker business, such as a realtor. In the API it is a
referral source with a contact behind it. The partner's figures are on
[`GET /v1/referral-partners`](/model#referral-partners).
`POST /v1/referral-sources` creates one. Send `contact_id` to use a contact you already have;
otherwise BrokerPlus makes a partner contact from `name`, `first_name`, `last_name`, `email`, and
`phone`.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/referral-sources \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Sam Realtor","first_name":"Sam","last_name":"Realtor","email":"sam@example.com","type":"realtor","company":"Example Realty"}'
```
```json
{
"success": true,
"data": {
"object": "referral_source",
"id": "cm9f2p4c0000",
"name": "Sam Realtor",
"position": 7,
"type": "realtor",
"company": "Example Realty",
"contact_id": "cm9f2p4d0000",
"contact_details": null,
"notes": null,
"last_contact_at": null,
"next_follow_up_at": null
}
}
```
`PATCH /v1/referral-sources/{id}` changes a partner, and a new `name`, `company`, email, or phone also
goes onto the partner's contact. If that contact's broker locked its details, you get
`403 CLIENT_DATA_LOCKED` and nothing changes. `DELETE /v1/referral-sources/{id}` archives the partner;
leads that named them lose their referral source, and the contact stays in the book.
### Who referred whom
A referral records the contact who sent a person to the broker, as Referred By on the contact page.
A person has one referrer. `POST /v1/contact-referrals` records one from `referrer_contact_id` and
`referred_contact_id`, with an optional `source` (for example `realtor` or `past_client`), `status`,
`note`, `deal_id`, and `occurred_at`. If the person already has a referrer, it is replaced and the
response is `200`.
`GET /v1/contact-referrals?referrer_contact_id=...` lists everyone one partner sent, with both
names. `PATCH /v1/contact-referrals/{id}` changes the status, kind, note, deal, or date; BrokerPlus
also sets `funded` itself when the linked deal funds. `DELETE /v1/contact-referrals/{id}` removes the
referral and keeps both contacts. A partner's totals are on `GET /v1/referral-partners`.
### Introductions to a partner
An introduction records a client or lead the broker sent to a partner: the other half of who
referred whom, and the `sent` count on the Partners page. `POST /v1/partner-introductions` records
one from `partner_contact_id` and `introduced_contact_id`, with an optional `deal_id`, `note`, and
`occurred_at`. A person is introduced to a partner once, so recording the same pair again changes the
one introduction. BrokerPlus sends nothing to the partner or the client.
`GET /v1/partner-introductions?partner_contact_id=...` lists everyone the broker sent to one partner,
with both names. `DELETE /v1/partner-introductions/{id}` removes the record and keeps both contacts.
### Partner firm notes
The brokerage can keep one note on each partner firm, usually to say which office its partners work
from. Every broker reads and writes the same notes, as on the Partners page.
`POST /v1/partner-company-notes` writes the note on a firm from `company` and `note`. Case is
ignored, so `RE/MAX` and `Re/Max` are one firm, and a firm that already has a note gets its text
replaced. `GET /v1/partner-company-notes?company=...` finds the note on a firm.
`PATCH /v1/partner-company-notes/{id}` changes the text, and `DELETE` removes the note.
### Partner roles
A partner role says what a contact does for the broker's clients: `realtor`, `lawyer`, `appraiser`,
`lender_contact`, `financial_planner`, `builder`, and so on, or a word the brokerage made up. A
contact with any role is a partner, and a contact can hold several.
`POST /v1/contacts/{id}/partner-roles` gives a contact a role, with an optional `title`. Adding a
role the contact already holds keeps the one role. `GET /v1/contacts/{id}/partner-roles` lists the
roles with their `title`, `website`, `calendar_url`, and `photo_url`.
`PATCH /v1/contacts/{id}/partner-roles/{roleId}` changes the title, website, or booking link; the
photo is uploaded in BrokerPlus. `DELETE /v1/contacts/{id}/partner-roles/{roleId}` takes a role off.
When the last role goes, the contact is no longer a partner, and it stays in the book.
### Partner hub links
A partner hub is one page where a referral partner sees every file they are on with the broker: the
client's first name, the city, and where the file is. The partner opens it from a link, with no
sign-in. Issue a link from the API and give it to the partner yourself, in a text or your own email.
BrokerPlus sends nothing.
`POST /v1/partner-hubs` opens the hub for a partner contact and issues a link in `link.url`. If the
partner already has a hub, you get that hub and a new link.
Open files are always on the hub. `show_funded` and `show_not_proceeding` choose which finished files
are on it: the files that funded, and the files that ended without funding. Both are `true` until
the broker turns one off. Send either one with `POST /v1/partner-hubs` to change it.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/partner-hubs \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"contact_id":"cm9f2p4d0000"}'
```
```json
{
"success": true,
"data": {
"object": "partner_hub",
"id": "cm9h1k2a0000",
"contact_id": "cm9f2p4d0000",
"partner_name": "Sam Realtor",
"notify_stage_changes": false,
"notify_funded": false,
"show_funded": true,
"show_not_proceeding": true,
"revoked_at": null,
"last_sent_at": null,
"links": [{ "object": "partner_hub_link", "id": "cm9h1k2b0000", "hub_id": "cm9h1k2a0000", "channel": "copy", "recipient_email": null, "view_count": 0, "last_viewed_at": null, "created_at": "2026-10-09T15:00:00.000Z" }],
"created_at": "2026-10-09T15:00:00.000Z",
"updated_at": "2026-10-09T15:00:00.000Z",
"link": {
"object": "partner_hub_link",
"id": "cm9h1k2b0000",
"hub_id": "cm9h1k2a0000",
"channel": "copy",
"recipient_email": null,
"view_count": 0,
"last_viewed_at": null,
"created_at": "2026-10-09T15:00:00.000Z",
"url": "https://portal.mos.getbrokerplus.ca/r/Q2F0IGluIHRoZSBoYXQ"
}
}
}
```
BrokerPlus keeps only a fingerprint of each link, so you can read `url` only in the response that
issued it. For another link to a hub you already have, use `POST /v1/partner-hubs/{id}/link`. Every
link keeps working until the broker turns the hub off in BrokerPlus. Issuing a link turns a hub
that was off back on, the same as in BrokerPlus.
`GET /v1/partner-hubs` lists the hubs; `q` finds a partner by name. `GET /v1/partner-hubs/{id}` shows
one hub with the links that still work and how often the partner opened each one. A key with the
`leads_only` limit can't reach these routes.
## Client tracker
The client tracker is the list of stages a client sees on their hub, in the broker's own words, with
the day the file reached each one. The stages belong to a team, and every deal of the team shows the
same stages. A key works on the tracker of the team it acts in.
`GET /v1/tracker-stages` lists the stages in order. `POST /v1/tracker-stages` adds one at the end,
`PATCH /v1/tracker-stages/{id}` changes its words, and `POST /v1/tracker-stages/reorder` sets the
order. `DELETE /v1/tracker-stages/{id}` removes a stage from every deal, but BrokerPlus keeps the
dates: add a stage with the same name to bring it back with them. A stage's `key` never changes,
because automations name a stage by its key. Every client of the team sees a new, changed, or
reordered stage at once, so a key with the `no_sending` limit can't add, change, reorder, or seed
stages, or change the tracker note (`403 KEY_LIMITED`). It can still read and remove them.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/tracker-stages \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"label":"Appraisal Booked","description":"The appraiser visits the home.","board_stage_keys":["appraisal"]}'
```
```json
{
"success": true,
"data": {
"object": "tracker_stage",
"id": "cm9h3t1a0000",
"team_id": "cm9a1b2c0000",
"key": "appraisal_booked",
"label": "Appraisal Booked",
"description": "The appraiser visits the home.",
"client_note": null,
"position": 5,
"board_stage_keys": ["appraisal"],
"created_at": "2026-10-09T15:00:00.000Z",
"updated_at": "2026-10-09T15:00:00.000Z"
}
}
```
`GET /v1/deals/{id}/tracker` shows one deal's tracker as the client sees it: each stage, the day the
deal reached it, and the note from the broker. When the tracker and the deal's pipeline stage
disagree, `drift` says which way. BrokerPlus changes neither, because the broker decides.
`POST /v1/deals/{id}/tracker/{stageId}/reach` marks a stage reached now, and returns `200` with the
deal's tracker. The client sees a reached stage, and reaching one can start automations that message
the client. So a key with the `no_sending` limit can't reach a stage (`403 KEY_LIMITED`), and if the
broker your key acts as may not contact clients, you get `403 FORBIDDEN`. `POST /v1/deals/{id}/tracker/{stageId}/retract` takes a stage
and every later stage back down at once, and sends nothing.
### Tracker links
Before partner hubs, a broker could give a realtor or a lawyer a link that showed one deal's
tracker. BrokerPlus no longer makes these links: give the partner a
[partner hub](#partner-hub-links) instead. The links already handed out keep working until the
broker takes them back.
`GET /v1/deals/{id}/tracker/links` lists the links on a deal that still work, newest first, with who
each one is for and how often it was opened. A link does not include its address, because BrokerPlus
never kept it. `POST /v1/deals/{id}/tracker/links/{linkId}/revoke` takes one back: it stops working
at once, nobody is told, and the link stays on record with `revoked_at` set.
```sh
curl -X POST https://api.mos.getbrokerplus.ca/v1/deals/cmsd8f2k10001p3x3abcd1234/tracker/links/cmsl4n7q20001p3x3wxyz5678/revoke \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
## Pipelines and stages
A pipeline is a board of deals, and each stage is a column on it. A key reaches the pipelines of its
broker's team, and any broker on the team who can change deals can change them. A stage that stops
automated messages also needs permission to change automations.
`POST /v1/pipelines` creates a pipeline. Without `stages`, it starts with the standard ones. A stage
can have `requirements`: what a deal must have before it can move in.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/pipelines \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Commercial","allowed_deal_types":["purchase"],"stages":[{"key":"new","label":"New"},{"key":"submitted","label":"Submitted","requirements":[{"kind":"field","key":"ownership.brokerOfRecord"}]}]}'
```
```json
{
"success": true,
"data": {
"object": "pipeline",
"id": "cm9p1a2b0000",
"name": "Commercial",
"description": null,
"allowed_deal_types": ["purchase"],
"position": 3,
"key": null,
"broker_user_id": "cm9u7x0b0000",
"stages": [
{
"object": "pipeline_stage",
"id": "cm9s1c3d0000",
"key": "new",
"label": "New",
"position": 0,
"requirements": []
},
{
"object": "pipeline_stage",
"id": "cm9s1c3d0001",
"key": "submitted",
"label": "Submitted",
"position": 1,
"requirements": [
{
"kind": "field",
"key": "ownership.brokerOfRecord",
"label": null,
"required": null,
"mode": "cumulative",
"deal_types": []
}
]
}
],
"created_at": "2026-10-07T14:02:11.000Z",
"updated_at": "2026-10-07T14:02:11.000Z"
}
}
```
The stages are shortened here. `PATCH /v1/pipelines/{id}` changes a pipeline, and
`POST /v1/pipelines/reorder` sets the team's order. A new deal lands on the first pipeline that
accepts its type, so the order also decides where new deals go.
Each stage endpoint returns the whole pipeline:
- `POST /v1/pipelines/{id}/stages` adds a stage. Sending the `key` of an archived stage restores it.
- `PATCH /v1/pipelines/{id}/stages/{stage_id}` changes a stage; `requirements` replaces the full list.
- `POST /v1/pipelines/{id}/stages/reorder` sets the order, with every stage included.
- `DELETE /v1/pipelines/{id}/stages/{stage_id}` archives a stage.
A requirement applies to its stage and every later stage. Set `mode` to `this_stage` to apply it to
its stage only, or to `optional` to ask for it without stopping the move. This works the same for a
`field`, a `checkpoint`, and a `custom_field`, which names one of your deal custom fields by its key.
BrokerPlus checks every requirement first: a field must be one it knows, a participant one of your
seats, a checkpoint a step on the team's checklist, and a custom field one of your live deal custom
fields. Otherwise you get `400 VALIDATION_ERROR` and nothing changes.
Deals protect the boards they sit on:
- A stage with active deals can't be deleted: `409 STAGE_IN_USE`, with the count. Move them first.
- A pipeline with deals can't be deleted: `409 PIPELINE_IN_USE`. To archive the deals along with it,
as a broker does when they confirm the delete in BrokerPlus, send
`DELETE /v1/pipelines/{id}?archive_deals=true`.
- A broker's last pipeline can't be deleted: `409 LAST_BOARD`.
## Participant seats
A seat is a participant that a team's deals keep a place for before anybody fills it: a listing
realtor, a lawyer, an underwriter. A stage requirement and an automation recipient name a seat by
its `kind`. A curated role is the role itself, for example `lawyer`. The brokerage's own word is
`label:` and the word, for example `label:Listing Realtor`.
- `GET /v1/participant-seats` lists the seats of the key's active team, in deal card order.
- `POST /v1/participant-seats` adds a seat. Send `role` or `role_label`, not both. A word that the
brokerage already uses in a different case becomes the same seat. A second seat of the same kind
gets `409 DUPLICATE`.
- `PATCH /v1/participant-seats/{id}` pins or unpins a seat, or changes `default_contact_id`.
- `DELETE /v1/participant-seats/{id}` removes a seat. The people already in it stay on their deals.
A stage that requires the seat blocks this: `409 SEAT_REQUIRED`, with each stage in
`details.stages`.
A pinned seat shows on each deal that the team opens after the pin. Open deals do not change. To
change one deal only, use `POST /v1/deals/{id}/participant-seats/{seatId}/adopt` or `.../dismiss`.
`GET /v1/deals/{id}/participant-seats` lists the seats of the deal's team, with `on_deal` true for
each seat that the deal shows.
Only a member of a seat's team can change it. Other keys get `404 NOT_FOUND`. The MCP server has a
tool for each of these operations.
## Task categories
Every task has a category. BrokerPlus has built-in ones, and a team can add up to 25 of its own. Only
the team's broker or a brokerage admin can change them; other keys get `403 FORBIDDEN`.
`POST /v1/task-categories` creates one. Its name must be unique on the team, or you get
`409 DUPLICATE`.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/task-categories \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Appraisals"}'
```
```json
{
"success": true,
"data": {
"object": "task_category",
"key": "custom:cm9k4t2a0000",
"id": "cm9k4t2a0000",
"name": "Appraisals",
"built_in": false,
"team_id": "cm9t1b7c0000",
"position": 0,
"hidden": false
}
}
```
- `PATCH /v1/task-categories/{id}` renames it.
- `DELETE /v1/task-categories/{id}` deletes it, and its tasks go back to their built-in category.
- `POST /v1/task-categories/hidden` hides built-in categories. Send the full list, such as
`{"hidden":["compliance"]}`, or an empty list to show them all. `admin` can't be hidden. The
response is every category, with `hidden` set on each.
## Note types
Every deal note and contact note has a kind, such as `underwriting` or `call`. Deals and contacts
have separate lists. BrokerPlus has built-in kinds, and the broker's team can add, rename, recolour,
or remove kinds. A change applies to the team of the key's broker, and a broker who is not on a team
gets `400 NO_TEAM`.
`GET /v1/note-types?scope=deal` lists the kinds that a deal note can have. Send a `slug` as `type`
when you add a note.
`POST /v1/note-types` adds a kind. BrokerPlus makes the `slug` from the `name`, and the `slug` never
changes. A `template` is the text that a new note of the kind starts with in BrokerPlus.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/note-types \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"scope":"deal","name":"Appraisal","color":"amber"}'
```
```json
{
"success": true,
"data": {
"object": "note_type",
"scope": "deal",
"slug": "appraisal",
"name": "Appraisal",
"color": "amber",
"template": null,
"built_in": false
}
}
```
- `PATCH /v1/note-types/{scope}/{slug}` changes the `name`, `color`, or `template`.
- `DELETE /v1/note-types/{scope}/{slug}` removes a kind. Its notes keep their `type`. You cannot
remove `general`.
## NPS alert rules
When a client answers a Net Promoter Score (NPS) survey, BrokerPlus checks the score against the
brokerage's alert rules. Every active rule whose range holds the score runs: it alerts brokers in
the app or by email, and it can add a task on the client's contact. Ranges can overlap, so one score
can run two rules. The rules belong to the brokerage, and any broker can change them.
`GET /v1/nps-alert-rules` lists the rules. A brokerage with no rules gets three starter rules on the
first read: 0 to 6, 7 to 8, and 9 to 10. `POST /v1/nps-alert-rules` adds one. Only `name`,
`min_score`, and `max_score` are required.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/nps-alert-rules \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Call within the hour","min_score":0,"max_score":4,"channels":["in_app","email"],"task_title":"Call this client today"}'
```
```json
{
"success": true,
"data": {
"object": "nps_alert_rule",
"id": "cm9n2p8r0000",
"name": "Call within the hour",
"min_score": 0,
"max_score": 4,
"notify_owner": true,
"broker_ids": [],
"channels": ["in_app", "email"],
"task_title": "Call this client today",
"task_body": null,
"task_assignee_user_id": null,
"task_due_in_days": 2,
"is_active": true,
"position": 3,
"created_at": "2026-10-09T14:02:11.000Z",
"updated_at": "2026-10-09T14:02:11.000Z"
}
}
```
- `PATCH /v1/nps-alert-rules/{id}` changes the fields you send. `min_score` must stay at or below
`max_score`, or you get `400 VALIDATION_ERROR`.
- `DELETE /v1/nps-alert-rules/{id}` deletes the rule.
- A broker in `broker_ids` or `task_assignee_user_id` must be in the brokerage, or you get
`404 NOT_FOUND`.
## Meeting types
A meeting type is a booking link clients use to book time with a broker. A key can manage its own
broker's meeting types and the shared ones of any team its broker can write for, but never another
broker's.
`POST /v1/meeting-types` creates one. Without `team_id`, the key's broker hosts it.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/meeting-types \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Discovery call","duration_minutes":30,"buffer_after_minutes":10,"min_notice_minutes":240,"questions":[{"label":"Purchase or refinance?","type":"choice","options":["Purchase","Refinance"],"required":true}]}'
```
```json
{
"success": true,
"data": {
"object": "meeting_type",
"id": "cm9f3a7k0000",
"name": "Discovery call",
"slug": "discovery-call",
"duration_minutes": 30,
"owner_user_id": "cm9f0b2c0000",
"owner_name": "Alex Broker",
"team_id": null,
"booking_url": "https://example.com/alex-broker/discovery-call",
"min_notice_minutes": 240,
"buffer_before_minutes": 0,
"buffer_after_minutes": 10,
"availability_hours": null,
"questions": [
{ "id": "4f1c2e0a", "label": "Purchase or refinance?", "type": "choice", "options": ["Purchase", "Refinance"], "required": true }
],
"position": 3
}
}
```
The response is shortened; `GET /v1/meeting-types` describes every field.
`PATCH /v1/meeting-types/{id}` changes one, and the change applies to bookings made after it:
- `availability_hours` sets hours for this meeting type only (send all seven days), or null to use
the broker's booking hours.
- `questions` is the full list; send a question's `id` to keep it.
- `form_fields` sets each field to `required`, `optional`, or `hidden`. Fields you leave out keep
their mode.
- `reminder_minutes` and `reminder_channels` change client reminders. Changing them sends nothing.
`POST /v1/meeting-types/reorder` sets the order on the booking page. `DELETE /v1/meeting-types/{id}`
removes the meeting type and turns off its link; appointments already booked stay on the calendar,
and clients aren't told. To stop new bookings but keep the link, send `enabled: false` instead.
Booking a client into a meeting type emails them now. A key with the `no_sending` limit cannot book.
See [Scheduling](/model#scheduling).
## Working hours
`GET /v1/me/hours` and `PATCH /v1/me/hours` read and change the key's own broker's time zone and
hours. A key can't change anyone else's.
- `business_hours` are when the broker's BrokerPlus phone line rings through.
- `booking_hours` are when clients can book them. Null means bookings use `business_hours`.
Each schedule has one entry per day, `0` (Sunday) to `6` (Saturday), and each day up to six windows
in minutes after midnight, in the broker's time zone. A day with no windows is closed.
```bash
curl -X PATCH https://api.mos.getbrokerplus.ca/v1/me/hours \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"timezone":"America/Toronto","booking_hours":[{"day_of_week":0,"windows":[]},{"day_of_week":1,"windows":[{"start_minute":540,"end_minute":720},{"start_minute":780,"end_minute":1020}]},{"day_of_week":2,"windows":[{"start_minute":540,"end_minute":1020}]},{"day_of_week":3,"windows":[{"start_minute":540,"end_minute":1020}]},{"day_of_week":4,"windows":[{"start_minute":540,"end_minute":1020}]},{"day_of_week":5,"windows":[{"start_minute":540,"end_minute":900}]},{"day_of_week":6,"windows":[]}]}'
```
```json
{
"success": true,
"data": {
"timezone": "America/Toronto",
"business_hours": null,
"booking_hours": [
{ "day_of_week": 0, "windows": [] },
{ "day_of_week": 1, "windows": [{ "start_minute": 540, "end_minute": 720 }, { "start_minute": 780, "end_minute": 1020 }] },
{ "day_of_week": 2, "windows": [{ "start_minute": 540, "end_minute": 1020 }] },
{ "day_of_week": 3, "windows": [{ "start_minute": 540, "end_minute": 1020 }] },
{ "day_of_week": 4, "windows": [{ "start_minute": 540, "end_minute": 1020 }] },
{ "day_of_week": 5, "windows": [{ "start_minute": 540, "end_minute": 900 }] },
{ "day_of_week": 6, "windows": [] }
]
}
}
```
Blocked days are days clients can't book the broker, such as a vacation. `POST /v1/me/blocked-days`
blocks one day or a range of up to 90, `GET /v1/me/blocked-days` lists them, and
`DELETE /v1/me/blocked-days/{date}` unblocks one. Appointments already booked on a blocked day stay.
## Email signature
`GET /v1/me/email-signature` returns the key's broker's signature as HTML, and
`PATCH /v1/me/email-signature` replaces it. A key changes only its own broker's signature.
BrokerPlus strips scripts, styles, and other markup email clients don't show, and returns the HTML
it kept. Send an empty string to remove the signature.
```bash
curl -X PATCH https://api.mos.getbrokerplus.ca/v1/me/email-signature \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"html":"Alex Broker
Example Mortgage Group
"}'
```
```json
{
"success": true,
"data": { "html": "Alex Broker
Example Mortgage Group
" }
}
```
Changing it sends nothing. The new signature goes on the broker's next emails, from every inbox that
doesn't have its own signature.
## Team note
Each team has one shared note. Every member sees it on their overview and can change it.
`GET /v1/teams/{id}/note` reads it, and `PATCH /v1/teams/{id}/note` replaces the whole note. Only a
member of the team can do either; for any other team the answer is `404 NOT_FOUND`. Get the team ids
from `GET /v1/teams`.
```bash
curl -X PATCH https://api.mos.getbrokerplus.ca/v1/teams/cm9a1t0e0000/note \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"body":"Lender rate sheets moved to Tuesdays.
"}'
```
```json
{
"success": true,
"data": {
"object": "team_note",
"team_id": "cm9a1t0e0000",
"team_name": "Alex Broker's Team",
"body": "Lender rate sheets moved to Tuesdays.
",
"updated_at": "2026-10-09T15:00:00.000Z"
}
}
```
The body is HTML. BrokerPlus removes unsafe markup and returns what it kept. Send an empty string to
clear the note. Changing the note notifies nobody, and a mention in it does not notify the teammate.
## Assistant instructions
A broker can give their assistant standing instructions, such as "Sign off with my first name" or
"Never move a deal to Approved before the appraisal is in". Each instruction has a `topic` that
says when the assistant applies it. These are the same instructions as Assistant settings in
BrokerPlus, and the assistant follows a change from its next run.
An instruction belongs to the broker's team, or to the whole brokerage. Only an admin can add,
change, or delete a brokerage instruction, and `editable` tells you whether the key's broker can.
A topic holds at most 8 instructions for a team, and 8 for the brokerage.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/agent-instructions \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"topic":"client_writing","body":"Sign off with my first name only."}'
```
```json
{
"success": true,
"data": {
"object": "agent_instruction",
"id": "cm9a1i0n0000",
"scope": "team",
"team_id": "cm9a1t0e0000",
"topic": "client_writing",
"title": null,
"body": "Sign off with my first name only.",
"enabled": true,
"editable": true,
"created_by_user_id": "cm9a1u0s0000",
"created_at": "2026-10-10T15:00:00.000Z",
"updated_at": "2026-10-10T15:00:00.000Z"
}
}
```
- `GET /v1/agent-instructions` lists them, with an optional `topic` filter.
- `PATCH /v1/agent-instructions/{id}` changes the fields you send. `enabled: false` switches one
off and keeps it.
- `DELETE /v1/agent-instructions/{id}` deletes one.
## Team chat
Team chat is where brokers on the same team talk to each other: a conversation between two people,
or a group. A key sees only the conversations that its broker is in, and a team chat message never
goes to a client.
1. Find who the broker can message with `GET /v1/direct-threads/roster`. It lists the active members
of each team the broker is on.
2. Start a conversation with `POST /v1/direct-threads`. One teammate in `user_ids` opens the
conversation with them, or returns the one the broker already has. Two or more, or a `title`,
start a new group.
3. Post with `POST /v1/direct-threads/{id}/messages`. The other members see the message at once.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/direct-threads/cmv2z0q.../messages \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"body":"The appraisal is in. Can you call the lender?"}'
```
```json
{
"success": true,
"data": {
"object": "direct_message",
"id": "Vq3h0e...",
"thread_id": "cmv2z0q...",
"sender_user_id": "cm9a1u0e...",
"body": "The appraisal is in. Can you call the lender?
",
"body_text": "The appraisal is in. Can you call the lender?",
"created_at": "2026-10-10T15:00:00.000Z"
}
}
```
`GET /v1/direct-threads` lists the conversations, the newest message first, and `unread` is `true`
when a teammate wrote after the broker last read. `GET /v1/direct-threads/{id}/messages` lists the
messages, newest first. Reading messages does not mark them read: `POST /v1/direct-threads/{id}/read`
does that, and the other members then see how far the broker has read. A conversation the broker is
not in answers `404 NOT_FOUND`.
---
## Document: How the objects fit together
What each object is, how the objects relate, and which id resolves through which endpoint.
URL: /model
# How the objects fit together
The [endpoint reference](/reference) lists every field. This page is the map: what each object is,
how the objects hang together, and which id you follow to get from one to the next.
This API is in beta. Any endpoint can change in a breaking way without notice, so watch the
[changelog](/changelog).
## Your brokerage
Everything a key can reach belongs to one brokerage, and the key acts as one broker in it.
[`GET /me`](/reference#tag/identity) tells you which. A key never reaches another brokerage, and it
never does more than the broker who made it. See [Authentication](/authentication).
**Users** are the brokers and staff. [`GET /users`](/reference#tag/users) lists them with their
licences. Any field that names a person (`owner_id` on a contact, `assigned_broker_id` on a deal,
`assignee_user_id` on a task, `author_id` on a note) is a user id that `GET /users/{id}` resolves.
A user carries identity only: name, email, role, and whether the account is active. Phone numbers
and booking handles stay out, because several of them work like credentials.
Most ids come with a readable name beside them, such as `assigned_broker_name` on a deal or
`assignee_name` on a task. Store the id and show the name; the name can change. Every record also
has an `object` field (`"deal"`, `"contact"`, `"task"`) so you can tell types apart in one list.
**Teams** group brokers. [`GET /teams`](/reference#tag/teams) lists them, and each has exactly one
owner. What a key sees depends on the teams its broker belongs to.
## Contacts and leads
A **contact** is a person or a company in the book; `kind` tells you which. `types` says what they
are to you (borrower, referral partner, realtor), and one contact can be several at once.
Contacts carry more than the main `email` and `phone`:
- **Addresses** through [`/contacts/{id}/addresses`](/reference#tag/contacts): the broker's own
addresses for the person (home, work, mailing, cottage). Exactly one is primary. The first one you
add becomes primary, and `is_primary: true` on another moves it. The residence history on a
mortgage application is a separate, read-only record.
- **Methods** through [`/contacts/{id}/methods`](/reference#tag/contacts): extra emails and phones,
fax numbers, and social profiles. A social profile returns a `url` built from what the broker
typed.
- **Relationships** through [`GET /contacts/{id}/relationships`](/reference#tag/contacts), worded
from that contact's side.
To find a person before you create one, send `email` or `phone` to `GET /contacts` or `GET /leads`.
Email ignores case, phone compares the last 10 digits, and both check every email and phone on the
contact. No match is an empty list. [Capture leads](/capture-leads) walks through this.
### Duplicates, merge, and archive
[`GET /contacts/duplicates`](/reference#tag/contacts) lists sets of contacts that share an email, a
phone, or a name. To merge one, pick the contact to keep and send the others:
```sh
curl -X POST https://api.mos.getbrokerplus.ca/v1/contacts/cmsctz1fa0003p3x3k2l7q9wd/merge \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Idempotency-Key: merge-cmsctz1fa" \
-H "Content-Type: application/json" \
-d '{"duplicate_ids": ["cmsd0b2kq0007p3x3a9f1c2re"]}'
```
Deals, notes, documents, and history move to the contact you keep. A merge can't be undone. After
it, `GET /contacts/{id}` on a merged id returns `410 CONTACT_MERGED` with `details.merged_into_id`,
so you can replace the id you stored.
`POST /contacts/{id}/archive` takes a contact out of the lists and sets `archived_at`. The contact
keeps its history, `GET /contacts/{id}` still returns it, and a later import or lead with the same
email finds it. `POST /contacts/{id}/unarchive` puts it back.
### Leads
A **lead** is an inbound enquiry that is not a deal yet. Every lead has a contact, and the name,
email, and phone live on that contact. `status` is `open`, `archived`, or `converted`.
[`POST /leads/{id}/convert`](/reference#tag/leads) starts a deal, and the lead then carries it as
`converted_deal_id`.
## Custom fields
A brokerage keeps its own **custom fields** on contacts and deals.
[`GET /custom-fields`](/reference#tag/custom-fields) lists them with their `key`, type, and options.
A field with a `default_value` starts with that answer on each new record. A lead has no fields of
its own: it uses its contact's, and you can set them with `custom_fields` on `POST /leads`.
Filter a list by a field with `custom_field[]=` on `GET /contacts`, `GET /leads`, or
`GET /deals`, for example `GET /v1/leads?custom_field[lead_source]=Google`. To create or change the
fields themselves, see [Configure your workspace](/workspace#custom-fields).
## Contact statistics
[`GET /contacts/{id}/statistics`](/reference#tag/contacts) says how a contact stands in the book. The
figures are computed on each read from the deals your key can see, and a deal counts once for each
borrower on it.
Funded volume uses the commission's funded amount when the broker recorded one, and the
application's loan amount otherwise. A funded deal with neither counts in
`funded_deals_without_amount` and adds nothing, so while that count is above zero the volume is a
floor, not a total. Year-to-date uses the brokerage's calendar year in `timezone`. A contact is a
repeat client once two of their deals have funded, and `last_touch` is the latest note, message, or
deal change.
## Pipelines, stages, and deals
A **deal** is a mortgage file. It sits in one place on one pipeline:
- `board_id` is the **pipeline**, resolved by [`GET /pipelines/{id}`](/reference#tag/pipelines).
- `stage_id` is a **stage** on that pipeline. Stages come inside the pipeline, in board order.
- `stage_name` is a copy of the stage label, so a list view needs no second call.
Every brokerage sets up its own pipelines, so read stages from `GET /pipelines` rather than
hardcoding a name or an id. A pipeline BrokerPlus created has a `key` (for example `historical`,
where funded and past deals go); look it up by `key`, not by name. A stage with `exclude_from_book`
holds paid-out mortgages, and a deal on a stage with `stops_automated_outreach` gets no automated
messages. `allowed_deal_types` says which deals land on a pipeline by default; it doesn't stop a
deal from moving there.
`POST /deals` opens a deal for the key's broker. Without `board_id`, it lands on the broker's default
pipeline for its `type`, on the first stage. If you name a pipeline, it must belong to the broker's
team and `stage_id` must be on it. Attach borrowers with `contacts`, each with a role.
`PATCH /deals/{id}` can move a deal only to a stage on **its own pipeline**. The API refuses a stage
from another pipeline, because the deal would drop out of every board view.
`GET /deals/{id}/stage-requirements` tells you what a stage needs before you move a deal into it.
### Copy, archive, and restore a deal
`POST /deals/{id}/duplicate` makes a new deal with the same people and primary application, on the
first stage of the same pipeline. `carry_participants: false` copies only the primary applicant,
`type` gives the copy another deal type, and `archive_original: true` archives the original once the
copy exists. Submissions, funding, compliance, and documents are not copied.
`DELETE /deals/{id}` archives a deal, and `POST /deals/{id}/restore` brings it back. Tasks the
archive cancelled stay cancelled.
### Flags
A flag paints a deal's card red on the board. `GET /deals/{id}/flags` lists the flags that are up,
each with a plain-words `reason` and a stable `reason_code`. `raised_by` is `broker` for the one flag
a broker raises by hand, `automation` for a Pulse automation, or `brokerplus` for BrokerPlus itself.
`POST /deals/{id}/flags` raises the broker's flag (send it again to change the words), and
`POST /deals/{id}/flags/{flagId}/clear` takes it down. Other flags are read-only: clearing one
returns `409 SYSTEM_FLAG`. Flags are internal; no client sees them.
### The deal's checklist
Each team keeps a checklist of steps for its files, such as "appraisal ordered" or "commitment
signed". `GET /deals/{id}/checkpoints` lists the team's steps with their value on the deal: `true`
for done, `false` for not done, and null when nobody has set it.
`PATCH /deals/{id}/checkpoints/{checkpointId}` sets one step, and `value: null` clears it.
### Find a deal by an id from another system
A deal imported from Velocity, Filogix, or Finmo keeps its old id in `external_references`, and the
lender's file number sits in `lender_file_ref`. Look one up with
`GET /deals?external_reference=` (add `source` to name the system) or
`GET /deals?lender_file_ref=`. Both match the whole value exactly, and an empty list means no
deal has that id.
## What hangs off a deal
| Sub-resource | Endpoint | What it is |
| -------------------- | ------------------------------------------- | ---------------------------------------------------------------- |
| Mortgage application | `GET /deals/{id}/application` | The loan: amount, term, amortization, rate, payment, and ratios. |
| Subject property | `GET /deals/{id}/property` | The property that secures the loan. |
| Applicants | `GET /deals/{id}/applicants` | Everyone on the application, with jobs and income. |
| Financials | `GET /deals/{id}/financials` | What the applicants own and owe, and the down payment. |
| Other properties | `GET /deals/{id}/other-properties` | Real estate the applicants already own. |
| Terms history | `GET /deals/{id}/application/terms-history` | Each change to the loan terms, with who and when. |
| Documents | `GET /deals/{id}/documents` | The files on the deal. See [Documents](/files). |
| Conditions | `GET /deals/{id}/conditions` | What a lender needs before the deal funds. |
| Participants | `GET /deals/{id}/participants` | The borrowers and the partners on the deal. |
| Deal team | `GET /deals/{id}/team` | The users who work the deal, by role. |
| Commission | `GET /deals/{id}/commission` | What the brokerage earns, and how it is split. |
| Consents | `GET /deals/{id}/consents` | What each party authorized. |
| Notes and tags | `GET /deals/{id}/notes`, `/deals/{id}/tags` | Commentary and your own labels. |
| Stage history | `GET /deals/{id}/stage-history` | Every stage move, newest first. |
| Activity | `GET /deals/{id}/activity` | The trail of messages, calls, notes, and changes. |
## Participants and partners
A **participant** is a contact on a deal with a role, and `is_partner` splits the roles in two:
- **Borrower** roles are on the application: `primary_applicant`, `co_applicant`, `guarantor`, and
`power_of_attorney`.
- **Partner** roles are professionals on the file: `realtor`, `lawyer`, `appraiser`,
`lender_contact`, `underwriter_contact`, `credit_assistant`, `insurance`, `financial_planner`,
`accountant`, `builder`, or `other` (with your own word in `role_label`).
A contact holds one role per deal. Add and remove participants, and change a partner's role, through
[`/deals/{id}/participants`](/reference#tag/deals). A borrower's role belongs to the application and
doesn't change here.
A partner on a deal is not the same as a partner in the book: `types` on a contact says the person
refers business to you, while the role on a deal says what they do on one file.
### Referral partners
A deal's `referral_partner` is who sent you the deal. Set it with
`POST /deals/{id}/referral-partner`, using `contact_id` for a contact or `name` for someone who isn't
one. A contact becomes a partner the first time you name them.
[`GET /referral-partners`](/reference#tag/referral-partners) reads the partner book with the same
figures as the Partners page. A partner's `id` is their contact id, or `source:` plus an id for a
name-only partner; pass it as it is to `GET /referral-partners/{id}`. People count on the day they
were referred, money on the day the deal funded. The book covers every team, but a key limited to
some teams sees only the people and deals those teams can read. To create or change partners, see
[Configure your workspace](/workspace#referral-partners).
## Teams and members
A **team** is one broker's crew. The broker owns it, and everyone else is a **member**, with the
brokerage's own `title` for the seat (underwriter, assistant, submitting broker).
[`GET /teams/{id}/members`](/reference#tag/teams) reads the roster. Membership is set up in
BrokerPlus, not through the API.
## Commission
A **commission** is what the brokerage earns on a funded deal, one per deal, read and changed through
[`/deals/{id}/commission`](/reference#tag/deals). Every figure is what the broker typed; the API
doesn't derive a fee from a rate sheet.
- The top level holds the lender, funded amount, finder's fee, bonus, buydown, and gross.
- A **line item** says where part of the money comes from. When line items exist, `gross_amount` is
their sum less the buydown, and the API keeps it that way.
- A **split** allocates the gross to a payee: a broker, the house, a franchise, or a referral
partner. It says who is owed a share, not that it was paid.
Commission has its own access rule. The key's broker can read it as the deal's broker, as a brokerage
admin, or as a team member with the `view_commission` grant. Anyone else gets
`403 COMMISSION_ACCESS`, even on a deal they can change. Changes post to the brokerage's connected
accounting system, and `revision` goes up on each one.
## Consents and communication preferences
A **consent** records that one party on one deal authorized one thing: a credit pull, a lender
submission, privacy processing, electronic delivery, or a mortgage protection plan transfer. Read and
add them through [`/deals/{id}/consents`](/reference#tag/deals), and take one back with
`POST /deals/{id}/consents/{consentId}/revoke`. Consents are never deleted, so the file always shows
what the brokerage held at the time. When the client agreed to written text, `text` and
`text_version` keep it as it read then. A `credit_pull` consent lapses 30 days after it is given.
A consent is a record only; it doesn't start a pull or a submission.
A **communication preference** is the client's standing on commercial email and SMS. It belongs to
the person, not a deal: [`GET /contacts/{id}/communication-preferences`](/reference#tag/contacts).
Email has three categories (`all_commercial`, `marketing`, `equity_reports`), and withdrawing from
`all_commercial` stops every commercial message. SMS has one switch. No record means `implied`, the
default for a client in the book.
`PATCH` on the same path takes one action per category: `opt_out` records a withdrawal, `opt_in`
clears one without recording express consent, and `express` records express consent with your
`source`. A **suppression** (a hard bounce, a complaint, a STOP reply) shows as `suppressed`, and the
API doesn't change it.
## Applicants and financials
Every rate and ratio here is a percent; see [Units and formats](/units).
An **applicant** is one person on the application, with `role` `primary`, `co_applicant`, or
`guarantor`. Each carries their jobs and any other income. Their name and contact details live on
`contact_id`. The API never returns a social insurance number.
**Financials** are the money on the deal: assets, liabilities, and down payment sources. The API
never returns an account number. A liability with `from_credit_bureau: true` came from a credit
report, not from the applicant. Consumer report data has its own redistribution rules, so check
your obligations before you store or forward it.
### Calculated figures on the application
`GET /deals/{id}/application` returns the figures BrokerPlus calculates from the rows, the same ones
the broker sees:
- `gds`, `tds`, and `ltv` (null when there isn't enough data), and `ratio_qualifying_rate`, the rate
those ratios use.
- `total_loan_amount`: the loan plus the insurance premium, when the premium is added to the loan.
- `insurance_premium`, `insurance_premium_rate`, and `insurance_premium_added_to_loan` for mortgage
default insurance.
- `stated_fees`: the fees the lender stated.
The property adds `appraised_value` and `appraisal_date`. These figures are read-only; change the
rows they come from.
### The rest of the application
The application, the property, and their rows carry the same details the broker fills in on the
deal. The application has the loan, charge, and repayment types, the buyer type, the legal and
closing costs, and the purchase stage. The property has heating, water, parking, zoning, size,
purchase history, taxes, and rental costs. An applicant has an honorific, a suffix, sex, and a
language. A job has the employer's address and the business type. A liability has its debt, charge,
and payout types, plus `property_id` when it is the mortgage on another property.
`PATCH /deals/{id}/application` and `PATCH /deals/{id}/property` change these, as do the row
endpoints below. The first application update creates the application on a deal that has none. A
coded field takes the numeric `code` it returns on read. `product_code` and `tenure` are read-only,
because the origination system sets them.
### Edit the application one row at a time
Each applicant, job, income source, address, phone number, asset, liability, and other property
has its own `POST`,
`PATCH`, and `DELETE`. A request changes one row and leaves the rest alone, and BrokerPlus
recalculates the totals and ratios straight away.
| Row | Endpoint |
| --------------- | ---------------------------------------------------------------------- |
| Applicant | `/deals/{id}/applicants/{applicantId}` |
| Job and its pay | `/deals/{id}/applicants/{applicantId}/employments/{employmentId}` |
| Other income | `/deals/{id}/applicants/{applicantId}/income-sources/{incomeSourceId}` |
| Address | `/deals/{id}/applicants/{applicantId}/addresses/{addressId}` |
| Phone number | `/deals/{id}/applicants/{applicantId}/phones/{phoneId}` |
| Asset | `/deals/{id}/assets/{assetId}` |
| Liability | `/deals/{id}/liabilities/{liabilityId}` |
| Other property | `/deals/{id}/other-properties/{propertyId}` |
To create a row, `POST` to the path without the last id. To add an applicant, first put the contact
on the deal as a borrower with `POST /deals/{id}/participants`, then send their `contact_id`.
This adds a job that pays $4,250 twice a month:
```sh
curl -X POST https://api.mos.getbrokerplus.ca/v1/deals/cm7x2q1k80001/applicants/cm7x2q1k80007/employments \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"employer_name": "Lakeshore Freight",
"job_title": "Dispatcher",
"started_on": "2021-03-15",
"incomes": [{ "income_type": 1, "payment_frequency": 24, "amount": 4250 }]
}'
```
An amount is per period, not per year. In a `PATCH` to a job, `incomes` is the full list: include
the `id` of each line to keep, and leave out each line to remove. A few rows push back:
- A liability from a credit report is read-only: `409 FROM_CREDIT_BUREAU`.
- You can't remove the primary applicant: `400 PRIMARY_APPLICANT`.
- An applicant must be on the deal as a borrower: `400 NOT_A_BORROWER`.
An applicant's addresses are their address history: one `current` address, each earlier home as a
`previous` address with its `started_on` and `ended_on`, and a `correspondence` address when their
mail goes somewhere else. Lenders ask for about three years of history. An applicant's phone numbers
are the ones the lender sees, and they are separate from the contact's own phone numbers.
### Credit notes
`GET /deals/{id}/credit-notes` lists the broker's own notes on the credit of the people on the deal,
one per person. `PATCH /deals/{id}/credit-notes/{contactId}` writes one, as the credit panel on the
deal does. The first write creates it. These are the broker's words only: no credit score and no
credit report goes out over the API, and a score the broker entered in BrokerPlus is kept as it is.
### Lender commitments
When the lender sends its commitment, BrokerPlus reads the terms from the PDF: the amount, the
rate, the term, the payment, the fees, and the prepayment and portability terms. The broker checks
them against the PDF and corrects what was read wrong. Only reviewed terms go on the cost of
borrowing disclosure that the client signs.
- `GET /deals/{id}/commitments` lists what was read, newest first. Each commitment has its `terms`,
with the broker's corrections applied, and the `cost_of_borrowing` worked out from them. A value
the PDF does not state is `null`. `terms.sources` says which page each value came from.
- `POST /deals/{id}/commitments` reads a PDF. With no body, it reads the PDF the lender sent with its
latest decision. For a lender that sends none, upload the commitment as a document and send its id
in `document_id`. Reading the same PDF again replaces its terms and clears the review.
- `POST /deals/{id}/commitments/{commitmentId}/review` marks the terms as checked. Send corrections
in `edits`, for example `{ "edits": { "interest_rate": 4.79 } }`. They replace the corrections of
an earlier review.
Rates are percents (see [Units](/units)). Nothing here sends anything to the client.
### Value history of the deal's property
`GET /deals/{id}/property-valuations` lists each valuation of the property the deal's mortgage is
on, newest first, with its estimate, its assessment, and its source. It is the home value trend on
the client's equity report, and BrokerPlus adds to it each month while the client gets reports. A
broker's own value, set with `property_value` on the opportunity, is not in this list.
### Creditor life insurance
`GET /deals/{id}/life-insurance` returns the deal's creditor life insurance: whether the broker
offered it, what the client said in `outcome`, and where the application is with the insurer in
`status`. `POST /deals/{id}/life-insurance/outcome` records the client's answer, as the insurance
panel on the deal does. Sending the application or asking for a quote shares the client's file with
the insurer under their consent, so the broker does that in BrokerPlus.
### Valuation history of an other property
`GET /deals/{id}/other-properties/{propertyId}/valuations` lists each valuation of the property,
newest first, with its estimate, its assessment, and its source. `change_12mo` shows how the estimate
changed over the last year, as a percent. It is `null` until there are two estimates to compare.
`POST /deals/{id}/other-properties/{propertyId}/refresh-valuation` asks the valuation service for a
new estimate, the same as the refresh button in BrokerPlus. The request returns `202` and the
valuation runs in the background. The property must have a street address, or the API returns
`400 NO_ADDRESS`. Send an `Idempotency-Key`: a retry with the same key does not ask again.
### Debt consolidation analysis
A debt consolidation analysis compares three ways to pay out the client's other debts: a refinance,
a home equity line of credit, and a reverse mortgage. It is the same analysis as the Debts tab in
BrokerPlus. It reads:
- Each debt on the deal that has a `debt_type`. Set `debt_type` when you add a debt with
`POST /deals/{id}/liabilities`. A debt with no `debt_type` is not in the analysis.
- The other properties on the deal.
- The balance and the property value on the deal's opportunity. Set the value with
`PATCH /opportunities/{id}` if BrokerPlus has no estimate.
`POST /deals/{id}/consolidation-analyses` runs the analysis and saves it. To replace the default rate
for an option, send `new_mortgage_rate_pct`, `heloc_rate_pct`, `reverse_mortgage_rate_pct`, or
`rental_heloc_rate_pct`, as a percent. BrokerPlus sends nothing to the client.
An analysis never changes. `GET /deals/{id}/consolidation-analyses` lists them newest first, so the
first one is the deal's current analysis. In each analysis, `status_quo` is the client's position if
nothing changes, `options` lists each option that BrokerPlus compared, and
`recommended_option_index` points to the recommended option. Every rate and the loan-to-value ratio
are percents.
### Terms history
`GET /deals/{id}/application/terms-history` lists each change to the rate, term, amortization, and
other loan terms, oldest first, with the old value, the new value, who changed it, and when. Use
`updated_since` to read only the newer changes.
## Conditions
A **condition** has two statuses. `status` is your team's answer; `lender_status` is the lender's,
and a condition your team considers done can still be outstanding with the lender. `source` says who
asked for it.
You change `status` with `POST /conditions/{id}/satisfy`, `POST /conditions/{id}/waive`, or
`PATCH /conditions/{id}`. `lender_status` belongs to the lender, and BrokerPlus refreshes it on each
decision poll.
`POST /deals/{id}/conditions` adds a condition the way the deal's Conditions tab does, and files a
task to decide it that completes when the condition is satisfied or waived. `assignee_role` says who
owes it (`broker`, `borrower`, `lawyer`, `credit_assistant`, `realtor`, `insurer`, or your own value),
and `assignee_contact_id` names the person when they are in your book. To explain open conditions to
someone on the deal in plain language, use `POST /deals/{id}/condition-requests`; it sends the letter
at once. See [Ask a client for something](/client-asks).
`documents` lists the documents attached to a condition as what answers it. They go to the lender
with the condition. `POST /conditions/{id}/documents` attaches more, as the attach on the Conditions
tab does. Each document has to be on the condition's deal. Attaching changes nothing else: satisfy
the condition when it is done.
## Scheduling
A **meeting type** is a booking link a broker shares with clients. `GET /meeting-types` lists the
key's broker's meeting types and their teams' shared ones. `GET /availability` returns the open start
times of one meeting type, calculated the way the booking page does: each host's hours in their own
time zone, with split shifts, blocked days, buffers, minimum notice, and calendar busy times.
`POST /appointments/book` books a contact into a time that `GET /availability` returned.
`POST /appointments/{id}/reschedule` moves a booking to another open time with the same host, and
`POST /appointments/{id}/cancel` cancels it. All three act now, email the client, and return the
booking. A key with the `no_sending` limit cannot use them. If the time is taken first, the call
returns `409` and nothing is sent. `GET /appointments/{id}/booking` reads the booking, including
after a move or a cancellation.
`/appointments` itself is the broker's calendar. Creating, changing, or deleting an appointment there
sends nothing to a client. To change meeting types, hours, and blocked days, see
[Configure your workspace](/workspace#meeting-types).
## Submissions
A **submission** is a deal a broker sent to a lender, with the lender and status. `source` says what
sent it:
- `brokerplus`: BrokerPlus sent it. Read the lender's answers through
`GET /submissions/{id}/decisions`.
- `finmo`: the brokerage's connected Finmo account sent it, and BrokerPlus mirrors it. The lender's
answer is in `lender_status`, and the requested terms are in `loan_amount`, `interest_rate`, and
`term_months`. A Finmo submission has no decisions endpoint, and `attempt` is null.
A submission with `simulated: true` was a test that never reached a lender, so leave it out of
reports. The API reads submissions; it never sends one.
## Opportunities
An **opportunity** is a mortgage your brokerage tracks for a renewal or a refinance, with the
balance, the penalty to break it, and the savings from a move. `POST /opportunities` adds one by hand,
the same as Add Client on the book: BrokerPlus links the client by `email` or creates the contact,
and makes a deal for the mortgage. Each call creates one, so send an `Idempotency-Key`.
`effective_penalty` is the penalty that applies: the greater of `ird_penalty` and
`three_month_penalty`, unless the broker entered one from a lender payout statement. A timestamp in
`outreach_unsubscribed_at` means the client opted out of equity reports, and nothing else.
Where the broker typed over a figure BrokerPlus had, such as `comparison_rate` or
`property_bedrooms`, the opportunity carries the broker's figure. `best_option_pin` is the option the
broker chose to show the client, or null.
### Find opportunities
`GET /opportunities` takes the same filters as the book in BrokerPlus, and you can combine them.
This request finds the variable-rate mortgages that mature in the first quarter of 2027, soonest
first, and leaves out the ones the broker dismissed:
```sh
curl -G https://api.mos.getbrokerplus.ca/v1/opportunities \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-d maturity_from=2027-01-01 \
-d maturity_to=2027-03-31 \
-d product_type=variable,arm \
-d dismissed=false \
-d sort=maturity_asc
```
| Filter | Returns only |
| --------------------------------- | ------------------------------------------------------------------------- |
| `maturity_from`, `maturity_to` | The mortgages that mature in this window. Both days are included. |
| `dismissed` | `true`: the dismissed ones. `false`: the ones still on the book. |
| `archived` | `true`: the mortgages whose term ended. `false`: the live ones. |
| `opportunity_type` | `renewal`, `pre_renewal`, `ird`, or `both`. |
| `product_type` | `fixed`, `variable`, `arm`, or `heloc`. |
| `start_year` | The mortgages that started in these years, for example `2021,2022`. |
| `assigned_broker_id` | The mortgages on deals assigned to this broker. |
| `monthly_reports_enabled` | `true`: the clients enrolled in equity reports. `false`: the rest. |
Separate several values with commas. `sort` takes `maturity_asc`, `maturity_desc`,
`last_client_contact_desc`, or `last_client_contact_asc`; without it, the most recently changed
opportunity is first. A sync with `updated_since` always reads in `updated_at` order, so it can't
take `sort`. The filters work with a sync.
### Correct what the mortgage carries
BrokerPlus didn't arrange these mortgages, so the balance, rate, and payment are only as current as
the last statement. `PATCH /opportunities/{id}` corrects them. A deal doesn't carry its
opportunity's id; find it with `GET /opportunities?deal_id=cm7x2q1k80001` (there is at most one).
```sh
curl -X PATCH https://api.mos.getbrokerplus.ca/v1/opportunities/cmsctw7kb0001p3x3zpyhkjv3 \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "balance": 383250, "interest_rate": 5.25 }'
```
```json
{
"success": true,
"data": {
"object": "opportunity",
"id": "cmsctw7kb0001p3x3zpyhkjv3",
"balance": 383250,
"balance_as_of": "2026-10-05T18:20:00.000Z",
"balance_is_manual": true,
"interest_rate": 5.25,
"prime_adjustment": null,
"effective_penalty": 4180,
"renewal_savings": 9240
}
}
```
Use `interest_rate` for a fixed rate and `prime_adjustment` for a variable one, where `-0.5` means
prime minus 0.50%. A balance you send becomes the new anchor: BrokerPlus pays it down from that day,
neither the nightly recalculation nor an import overwrites it, and `balance_is_manual` reads `true`.
Send null to hand it back. Penalties, savings, and the target rate follow from these figures, so one
request can change several fields. The original principal, maturity date, and amortization belong to
the deal, not here.
### Act on an opportunity
These do the same as the menu on an opportunity in BrokerPlus:
| Request | What it does |
| ----------------------------------- | ------------------------------------------------------------------------- |
| `POST /opportunities/{id}/convert` | Starts a new working deal with the borrowers and mortgage facts. |
| `POST /opportunities/{id}/dismiss` | Takes the mortgage off the book and stops its equity reports and signals. |
| `POST /opportunities/{id}/restore` | Puts a dismissed mortgage back. |
| `POST /opportunities/{id}/snooze` | Hides it until `until`, and reminds the broker that morning. |
| `POST /opportunities/{id}/unsnooze` | Ends the snooze. |
Dismiss, restore, and unsnooze are safe to repeat. Convert creates a deal every time (a client can
refinance twice), so send an `Idempotency-Key`; the response is the new deal. A deal on a stage that
locks it refuses everything except convert with `403 DEAL_LOCKED`.
The equity reports about a client are at `GET /equity-reports?contact_id=...`. See
[Equity reports](/equity-reports).
## Notes
A note has the same shape on a contact and on a deal. Notes thread one level deep: a note can carry a
`parent_id`, and the API refuses a reply to a reply rather than flattening it. `pinned` is what the
app shows at the top.
`PATCH /contacts/{id}/notes/{noteId}` and `PATCH /deals/{id}/notes/{noteId}` change the words, the
pin, or the kind. A pin alone doesn't change `updated_at`. Anyone who can edit the contact or deal
can edit its notes, as in BrokerPlus. `DELETE` removes the note and its replies for good.
Task notes work the same at `PATCH /tasks/{id}/notes/{noteId}`, without a kind. Only the author can
delete a task note; anyone else gets `403 NOT_AUTHOR`.
A new note has the kind `general` unless you send `type`. The kinds are at
[Note types](/workspace#note-types).
### Mentions, reactions, and files
These work the same on a contact note and a deal note. The paths below use deals.
- **Mentions.** To mention a teammate, put `@Name` in the body.
`GET /deals/{id}/notes/mentionable` lists the teammates that a note can mention. Each one gets a
notification, as in BrokerPlus. `mentions` on the note lists who was named.
- **Reactions.** `POST /deals/{id}/notes/{noteId}/reactions` with `{"emoji":"👍","active":true}` adds
the reaction of the key's broker, and `"active":false` takes it off. Sending it again changes
nothing. `reactions` on the note sums them per emoji.
- **Files.** `POST /deals/{id}/notes/{noteId}/attachments` attaches a PDF of at most 25 MB, as
`multipart/form-data` in a `file` field or as JSON with `file_name` and `content_base64`.
`attachments` on the note lists the files, and
`GET /deals/{id}/notes/{noteId}/attachments/{attachmentId}` gives a download link that works for
five minutes. A key with the `no_documents` limit gets `403` on both.
## Drafts
A draft is a message written for the broker to review. `POST /contacts/{id}/drafts` files an `sms`
or `email` draft into the review queue of the key's broker with status `pending_review`, exactly like
a draft BrokerPlus writes. The broker reads it in BrokerPlus and sends, edits, or rejects it.
Use a draft when your system writes the words and a person should read every one before a client
does. To send a draft as it is written, use `POST /drafts/{id}/send`. To send a message without a
review, use `POST /contacts/{id}/messages`. Both send now, from the broker's connected mailbox or
phone number, and a key with the `no_sending` limit gets `403`. If you only want the broker to do
something, create a task instead.
```bash
curl https://api.mos.getbrokerplus.ca/v1/contacts/cm7x2q1k80001/drafts \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "channel": "sms", "body": "Hi Sam, your approval came through today. Call me when you have a minute." }'
```
```json
{
"success": true,
"data": {
"object": "draft",
"id": "cm7y8r4mf0009",
"contact_id": "cm7x2q1k80001",
"deal_id": null,
"channel": "sms",
"status": "pending_review",
"subject": null,
"body": "Hi Sam, your approval came through today. Call me when you have a minute.",
"recipients": [{ "contact_id": "cm7x2q1k80001", "name": "Sam Client" }],
"sender_user_id": "cm7usr9f30002",
"scheduled_at": null,
"communication_id": null,
"created_at": "2026-09-22T14:05:11.000Z"
}
}
```
The response is shortened here. An `sms` draft has no `subject` and at most 1600 characters. An
`email` draft needs a `subject`.
### Follow a draft after you file it
`GET /drafts` lists the drafts your key filed, newest first, and `GET /drafts/{id}` reads one. Filter
with `status`, `channel`, `contact_id`, `deal_id`, or `updated_since`. Drafts the broker wrote in
BrokerPlus aren't listed.
| `status` | What it means |
| ---------------- | ------------------------------------------------------- |
| `pending_review` | Waiting in the broker's review queue. |
| `scheduled` | Set to go out at `scheduled_at`. |
| `sent` | Sent. `communication_id` is the message the client got. |
| `discarded` | The broker rejected it. |
| `failed` | A scheduled send didn't go out. |
The broker can edit a draft before it goes, so `subject` and `body` show what the client got.
### Change, schedule, or discard a draft
`PATCH /drafts/{id}` changes a draft that waits for review, and `POST /drafts/{id}/discard` throws it
away. `POST /drafts/{id}/schedule` sets it to go out at `send_at`, and
`POST /drafts/{id}/unschedule` stops it. To draft a reply to a message instead of a new message, see
[Reply to a client](/mail#reply-to-a-client).
## Tags
Contacts and deals have **separate tag vocabularies**: `GET /contact-tags` and `GET /deal-tags`. They
share a shape, not a namespace, so a deal tag means nothing on a contact.
Putting a tag on a record and taking it off are both idempotent. Adding the same tag twice leaves one,
and removing a tag that isn't there still succeeds. To create or change tags, see
[Configure your workspace](/workspace#tags).
## Lenders
[`GET /lenders`](/reference#tag/lenders) lists the lenders your brokerage can use. `source` says
where each comes from:
- `brokerplus`: the brokerage's own catalogue, with `is_prime`, `is_alternative`, and `is_private`.
- `finmo`: mirrored from the brokerage's connected Finmo account. Finmo folds the product line into
the name, so "First National - Prime" and "First National - Excalibur" are two lenders.
`lender_type` carries Finmo's classification.
A lender with `under_maintenance: true` can't take a submission right now. A submission's
`lender_id` resolves against the lender with the same `source`; send `source=brokerplus` or
`source=finmo` to list one side. Rate sheets and compensation aren't published.
## Tasks
Tasks are an **action queue**, not a general to-do list. Each has a `type` for the kind of work and a
`priority` that orders the list, and it can link to a deal, a contact, or both. BrokerPlus files many
tasks on its own.
You can create, change, assign, complete, cancel, and reopen tasks, and read and write their notes
(which a client never sees). A new task takes the team of its deal or contact, or the broker's own
team. An assignee must be eligible for that team and deal, by the same rule as BrokerPlus, or the API
returns `INVALID_ASSIGNEE`. A task with no assignee waits until somebody takes it.
`POST /tasks/bulk` completes, cancels, or reopens up to 200 tasks at once and returns `updated_ids`
and `skipped_ids`. A task already in that state is skipped, and so is an approval task, which only
the broker decides (cancel or reopen one directly and you get `409 APPROVAL_TASK`).
`POST /tasks/batch` creates or updates up to 100 tasks; see [Idempotency and batch](/idempotency).
---
## Document: Capture leads from your website or ads
Send every form submission or ad lead to BrokerPlus exactly once, with no duplicate people.
URL: /capture-leads
# 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).
```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"]
```
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:
```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
```
```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.
```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"]
```
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).
---
## Document: Suggestions, notifications, and deal checks
Read what the broker sees in BrokerPlus - suggestions, notifications, a deal's compliance and credit, the activity trail, and goals.
URL: /broker-view
# Suggestions, notifications, and deal checks
These endpoints show you what the broker sees on their own screens: the suggestions waiting for
them, the notifications in their bell, a deal's compliance checklist and credit reports, and the
activity trail of a deal or a contact.
This API is in beta. Any endpoint can change in a breaking way without notice, so watch the
[changelog](/changelog).
## Suggestions
BrokerPlus suggests changes to the broker: a new contact from an email, a follow-up task, a document
to file, a stage move. `GET /v1/suggestions` lists them; add `status=pending` for the ones still
waiting on a decision.
```bash title="curl"
curl "https://api.mos.getbrokerplus.ca/v1/suggestions?status=pending" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```ts title="TypeScript"
const res = await fetch("https://api.mos.getbrokerplus.ca/v1/suggestions?status=pending", {
headers: { Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}` },
});
const { data } = await res.json();
```
```json title="Response"
{
"success": true,
"data": [
{
"object": "suggestion",
"id": "cmsg8f2k1",
"kind": "task_create",
"title": "Call Sam about the appraisal",
"status": "pending",
"values": { "title": "Call Sam about the appraisal", "due_date_iso": "2026-10-09" },
"approvable": true,
"deal_id": "cmsd71x0q",
"deal_name": "Sam Lee - 12 Elm Street",
"subject_type": "communication",
"subject_id": "cmsc0p4n7",
"bundle_key": null,
"bundle_label": null,
"task_id": null,
"created_by_agent": true,
"expires_at": null,
"dismissed_at": null,
"dismiss_reason": null,
"executed_at": null,
"created_at": "2026-10-07T14:02:11.000Z",
"updated_at": "2026-10-07T14:02:11.000Z"
}
],
"pagination": { "total": 1, "page": 1, "page_size": 25, "total_pages": 1 }
}
```
`POST /v1/suggestions/{id}/approve` carries out a suggestion as the key's broker.
`POST /v1/suggestions/{id}/dismiss` turns it down; send a `reason` and the broker sees it.
A key can approve a suggestion only when `approvable` is `true`. Anything that reaches a client, acts
outside BrokerPlus, or opens a draft stays with the broker: approving it returns
`409 APPROVE_IN_BROKERPLUS`, and the suggestion stays in their queue. When a task asks the broker to
approve it, `task_id` names that task. It is the same decision, not a second one.
A suggestion is BrokerPlus asking the broker. A request from your key is the broker acting, so it
never becomes a suggestion. See [Reaching clients](/reaching-clients).
## Notifications
`GET /v1/notifications` lists the notifications in the bell of the key's broker, never a teammate's.
Add `unread_only=true` for the unread ones. `POST /v1/notifications/{id}/read` marks one read, and
`POST /v1/notifications/read-all` marks them all and says how many:
```json title="Response to POST /v1/notifications/read-all"
{ "success": true, "data": { "updated": 3 } }
```
## Compliance checklist
`GET /v1/deals/{id}/compliance` returns the deal's compliance checklist as the broker sees it: the
checkpoints and open items, and for each party the identity verification, know-your-client record,
anti-money-laundering screening, risk rating, and politically exposed person question. It also has
the consents, the suitability answers, and the required documents.
```json title="Response (shortened)"
{
"success": true,
"data": {
"object": "deal_compliance",
"deal_id": "cmsd71x0q",
"checkpoints": [
{ "key": "intake_credit", "label": "Intake + credit", "status": "ready", "blocker_count": 0, "pending_reason": null }
],
"parties": [
{
"contact_id": "cmsc8a1v2",
"name": "Sam Lee",
"role": "primary_applicant",
"is_entity": false,
"identity": { "status": "verified", "completed_at": "2026-10-02T16:20:00.000Z", "expires_at": null, "method": "government_photo_id" },
"kyc": { "status": "cleared", "completed_at": "2026-10-02T16:25:00.000Z", "expires_at": null, "has_record": true }
}
],
"consents": [
{ "contact_id": "cmsc8a1v2", "scope": "credit_pull", "status": "active", "consent_id": "cmsn2k7q0", "granted_at": "2026-10-01T15:00:00.000Z", "expires_at": "2026-10-31T15:00:00.000Z" }
],
"legal_hold": false
}
}
```
The checklist is read-only. Overriding a check or marking an item done happens in BrokerPlus. A
`consent_id` points at a consent you can read with `GET /v1/deals/{id}/consents`.
The response leaves out the ID document, its images and number, the date of birth, the screening
results, and the risk score. Each status tells you whether the broker has work to do; the details
stay in BrokerPlus.
### Evidence for a custom requirement
A requirement that the brokerage added to the deal has a `custom_obligation_id` in
`document_requirements`, and `evidence` lists the documents that answered it.
`POST /v1/deals/{id}/compliance/custom-obligations/{obligationId}/documents` uploads a file and
attaches it to the requirement, as the requirement's upload button does. Send the file as
`multipart/form-data` in a `file` field, or as JSON with `file_name`, `content_type`, and
`content_base64`. The file is also a document on the deal. `DELETE` on
`.../documents/{documentId}` takes the document off the requirement and leaves it on the deal.
## Know-your-client records
A know-your-client record is the FINTRAC information record of one party on a deal, as the KYC
editor on the deal's compliance tab shows it. Read the records of a deal with
`GET /v1/deals/{id}/kyc-records`, and one party's record with
`GET /v1/deals/{id}/kyc-records/{contactId}`.
`PATCH /v1/deals/{id}/kyc-records/{contactId}` saves a party's record. It makes the record if the
party has none. Only the fields that you send change. `third_parties` replaces the whole list.
```json title="Request"
{
"relationship_purpose": "purchase_financing",
"occupation": "Nurse",
"third_party_determination": "no_third_party",
"third_party_measures": "Asked the client at the meeting."
}
```
A deal imported from another system can carry the identity check and the politically exposed person
answer from that system, in the fields that start with `id_` and `pep_`. These fields are read only.
`id_document_number` shows only the last four characters, and the credit file number is not
published.
For a company on the deal, `GET` and `PATCH /v1/deals/{id}/entity-verifications/{contactId}` read
and save the entity verification: whether the broker confirmed that the entity exists, who owns and
controls it in `controllers`, and who can sign for it. A person gets `422 NOT_ENTITY`. The
documents that the broker attached in BrokerPlus are not published.
These routes reach a deal that the broker can change. A deal on another team, or a contact who is not
on the deal, gets `404 NOT_FOUND`.
## Identity verifications
`GET /v1/deals/{id}/identity-verifications` lists the identity verifications of the people on the
deal, newest first: the method, the status, and the evidence that each one examined. A verification
belongs to the person, so a verification started from another deal is in the list too.
`document_number` shows only the last four characters. The images of the document, the reports, and
the credit file reference are not published. The list is read-only: the broker reviews a
verification in BrokerPlus.
## Deal risk assessment
The risk assessment rates the transaction, not the client. `GET /v1/deals/{id}/risk-assessment`
reads it, as the broker fills it in on the deal's compliance tab: the reportable transaction types,
the fraud and money-laundering questions, the FINTRAC report, and the broker's risk level.
`PATCH /v1/deals/{id}/risk-assessment` saves it, and makes it if the deal has none. Each answer is
`yes` or `no`. The answers you send are added to the answers on file, so you can save one section at
a time. `"completed": true` marks the assessment complete.
```json title="Request"
{
"transaction_flags": { "large_cash_transaction": "no" },
"fraud_answers": { "criminal_history": "no", "unjustified_cash": "no" },
"risk_level": "low",
"method_notes": "Long-time client, salaried income, no cash in the down payment."
}
```
An unknown question key gets `400 VALIDATION_ERROR`. The politically exposed person answer is read
only. `identity_verified` is the broker's statement for the deal and does not clear a party's
identity check.
## Credit reports
`GET /v1/deals/{id}/credit-reports` lists the deal's credit reports: the score, the bureau, when
BrokerPlus got it, and the account totals the deal shows.
```json title="Response (shortened)"
{
"success": true,
"data": [
{
"object": "credit_report",
"id": "cmscr0d81",
"contact_id": "cmsc8a1v2",
"contact_name": "Sam Lee",
"bureau": "Equifax",
"status": "complete",
"score": 742,
"report_date": "2026-09-30",
"pulled_at": "2026-09-30T18:11:04.000Z",
"consent_id": "cmsn2k7q0",
"detail_pending": false,
"tradeline_summary": { "total_trades": 3, "open_trades": 2, "total_balance": 18250, "utilization": 42.5 }
}
]
}
```
The report itself, the accounts, and the account numbers stay out. The API can't pull credit: a pull
costs money and needs the client's consent, so the broker does it in BrokerPlus.
## Activity trail
`GET /v1/deals/{id}/activity` returns a deal's trail, newest first: messages and calls, broker notes,
and changes to the deal. `GET /v1/contacts/{id}/activity` returns the messages and calls with one
person, plus changes to the deals they are on.
Each entry has a `summary`, and a message or note also has a `preview`. Follow `record_type` and
`record_id` to the full record; a `communication` entry, for example, reads with
`GET /v1/communications/{id}`.
```json title="Response (shortened)"
{
"success": true,
"data": [
{
"object": "activity",
"id": "cmsev41p2",
"kind": "event",
"occurred_at": "2026-10-07T13:40:00.000Z",
"summary": "Stage changed",
"preview": null,
"type": "stage.transitioned",
"actor_name": "Alex Broker",
"deal_id": "cmsd71x0q",
"record_type": "deal",
"record_id": "cmsd71x0q"
}
]
}
```
The trail holds the newest 200 entries of each kind. Mail from a mailbox the broker can't read isn't
on it.
## Who a deal is waiting on
A deal card shows who owes the next move: the client with documents, or a lender with an answer.
BrokerPlus keeps each of these as a watch. It starts one when the deal begins to wait, raises its
`escalation_level` as the wait gets longer, and ends it when the answer arrives.
`GET /v1/work-watches` lists them, and `GET /v1/work-watches/{id}` reads one.
```bash
curl "https://api.mos.getbrokerplus.ca/v1/work-watches?deal_id=cmsd71x0q&status=active" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```json title="Response (shortened)"
{
"success": true,
"data": [
{
"object": "work_watch",
"id": "cmsw81k2a",
"deal_id": "cmsd71x0q",
"kind": "waiting_on_borrower",
"status": "active",
"waiting_on": "borrower",
"waiting_on_contact_id": "cmsc11b7e",
"waiting_on_user_id": null,
"since_at": "2026-10-02T14:00:00.000Z",
"due_at": null,
"escalation_level": 1,
"task_id": "cmst90q4d"
}
]
}
```
Filter by `deal_id`, `status`, and `waiting_on`. Use `updated_since` to sync the changes. Watches
are read-only: to change what a deal waits for, change the deal, its documents, or its conditions.
## Goals
The goals card on the Business pages holds the broker's targets: funded volume, funded deals, or
commission over a period, for the whole book or for one lender. `GET /v1/goals` lists them with
`pace`, which says where the broker stands today. BrokerPlus works `pace` out from the funded deals
each time you read a goal, so it moves without the goal changing.
```bash
curl https://api.mos.getbrokerplus.ca/v1/goals \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```json title="Response (shortened)"
{
"success": true,
"data": [
{
"object": "goal",
"id": "cmsg71q0a",
"metric": "funded_volume",
"broker_id": "cmsu12k9d",
"lender_id": null,
"period_start": "2026-01-01",
"period_end": "2026-12-31",
"target": 5000000,
"curve": "seasonal",
"pace": { "actual": 3100000, "expected": 3400000, "gap": -300000, "status": "on_track", "progress": 0.62 },
"gap_in_deals": 0.6,
"within_reach": false
}
]
}
```
`POST /v1/goals` sets a goal for the key's broker. A goal is named by its `metric`, `period_start`
and `lender_id`, so setting the same three again replaces the target. `PATCH /v1/goals/{id}`
changes the target, the end of the period, the curve, or the months the broker set by hand.
`DELETE /v1/goals/{id}` deletes one. A key reads the goals of the broker whose reports it can read,
and it changes only its own broker's goals.
## In an AI assistant
The [MCP server](/mcp) has a tool for every operation on this page: suggestions, notifications, the
activity trail, who a deal is waiting on, and goals. It also has tools for the compliance checklist,
know-your-client records, identity verifications, the risk assessment, and credit reports. An
assistant can repeat what it reads to anyone it talks to, so treat a key you give an assistant the
way you treat the client's file.
---
## Document: Plans and comparisons
Build mortgage plans, lender offers, deal scenarios, and strategy analyses for a client through the API.
URL: /planning
# Plans and comparisons
A broker advises a client with a few tools in BrokerPlus: the mortgage plan builder, the offers on a
deal and their side-by-side comparison, deal scenarios, and strategy analyses. This page shows how
to read and build each one through the API. BrokerPlus sends nothing to the client from these
endpoints, so a key with the `no_sending` limit can use all of them.
This API is in beta. Any endpoint can change in a breaking way without notice, so watch the
[changelog](/changelog).
Every rate on this page is a percent: `4.79` means 4.79%. See [Units](/units).
## Who can see a plan
A key reaches the same plans, offers, and analyses its broker reaches in BrokerPlus. A plan on a
deal that belongs to another team is `404 NOT_FOUND`, the same as the deal itself. A key with the
`leads_only` limit reaches none of them. See [Authentication](/authentication).
## Mortgage plans
A mortgage plan shows a client up to 3 options side by side. Each option has its rate, term, and
amortization, the monthly housing costs, and the figures for a purchase, a sale, or a refinance.
The broker opens a plan you save in the plan builder, on the deal or the contact you attach it to.
`POST /v1/mortgage-plans` saves a plan. Attach it to a deal with `deal_id`, to a person with
`contact_id`, or to both when they are on the same team.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/mortgage-plans \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Jordan Lee purchase",
"deal_id": "cm9d1a2b3000",
"plan_type": "purchase_conventional",
"province": "ON",
"dwelling": "condo",
"columns": [
{"id": "a", "label": "5-year fixed", "rate": 4.79, "rate_type": "fixed", "term_months": 60,
"amortization_years": 25, "purchase_price": 650000, "down_payment": 130000,
"budget": {"condo_fees": 420}}
]
}'
```
```json
{
"success": true,
"data": {
"object": "mortgage_plan",
"id": "cm9p7q8r9000",
"title": "Jordan Lee purchase",
"client_name": null,
"deal_id": "cm9d1a2b3000",
"contact_id": null,
"plan_type": "purchase_conventional",
"province": "ON",
"dwelling": "condo",
"first_time_buyer": false,
"municipality": null,
"notes": null,
"columns": [
{
"id": "a",
"label": "5-year fixed",
"rate": 4.79,
"rate_type": "fixed",
"term_months": 60,
"amortization_years": 25,
"budget": { "condo_fees": 420 },
"purchase_price": 650000,
"down_payment": 130000
}
],
"refinance": null,
"user_id": "cm8u1v2w3000",
"created_at": "2026-10-09T14:02:11.000Z",
"updated_at": "2026-10-09T14:02:11.000Z"
}
}
```
`PATCH /v1/mortgage-plans/{id}` changes the fields you send. `columns` replaces every option, so
send each option you want to keep. `GET /v1/mortgage-plans` lists plans, and takes `deal_id`,
`contact_id`, and `updated_since`. See [Sync](/sync).
## Offers and the comparison
The offers tab on a deal holds the lender offers the broker is weighing, each with its fees. The
broker sees an offer you add on the deal straight away.
`POST /v1/deals/{id}/offers` adds an offer. Send `rate` as a percent and `payment_frequency` as
payments a year (`12` is monthly, `26` is bi-weekly).
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/deals/cm9d1a2b3000/offers \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "First National 5-year fixed",
"lender_slug": "first-national",
"term_months": 60,
"amortization_months": 300,
"payment_frequency": 12,
"mortgage_amount": 500000,
"rate": 4.79,
"fees": [{"label": "Appraisal", "amount": 450, "required_by_institution": true, "s48_included": true}]
}'
```
`GET /v1/deals/{id}/offers/compare` is the tab's side-by-side comparison, worked out when you ask
from the offers, the scenarios, and today's rates. For each offer it has the payment, mortgage
default insurance, the penalty to break the term, the fees, and the annual percentage rate (APR).
Nothing in it is stored, so read it again after a change.
`PATCH /v1/deals/{id}/offers/{offerId}` changes the fields you send. `fees` replaces every fee.
## Deal scenarios
A scenario is a what-if on the deal: a property sold, debts paid out from the proceeds, and the
rates to try. `POST /v1/deals/{id}/scenarios` adds one, and the comparison prices it at each rate in
`rate_options`. A list you send in a `PATCH` replaces the stored list.
## Strategy analyses
A strategy analysis compares up to 8 mortgage options for a client, each measured against the one
at `baseline_index`, for example staying with the current lender against two switches. The broker
opens an analysis you save on the strategy board.
`POST /v1/strategy-analyses` saves one. Send each option's `rate` as a percent. For a readvanceable
mortgage, `readvanceable.limit` and `readvanceable.heloc_rate` are percents too.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/strategy-analyses \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Stay or switch",
"deal_id": "cm9d1a2b3000",
"options": [
{"label": "Stay with TD", "balance": 400000, "rate": 5.24, "amortization_months": 300, "term_months": 60},
{"label": "Switch to MCAP", "balance": 400000, "rate": 4.79, "amortization_months": 300, "term_months": 60}
]
}'
```
`PATCH /v1/strategy-analyses/{id}` changes the fields you send. `options` replaces every option.
`GET /v1/strategy-analyses` lists analyses, and takes `deal_id` and `updated_since`.
BrokerPlus writes the note that explains the recommendation (`deal_defender`) in the app, where the
broker reviews it. You can read and edit the note through the API, but not ask BrokerPlus to write
one.
---
## Document: Reports and data
Save report templates, make a PDF report for many deals at once, and export the broker's data.
URL: /reports-and-data
# Reports and data
This page covers the report builder (the templates a brokerage saves, and the bulk report runs that
make one PDF report for each deal) and the exports of a broker's data.
This API is in beta. Any endpoint can change in a breaking way without notice, so watch the
[changelog](/changelog). A key with the `leads_only` limit can't reach any of this; see
[Authentication](/authentication).
## Report templates
A report template is a layout from the report builder in BrokerPlus: a list of widgets, such as the
property value, the mortgage summary, or a note from the broker. A template holds no client data.
Each report fills the widgets in from its deal when the report is made.
Templates belong to the brokerage. Every broker reads them, and every broker can change one, the
same as in the report builder. `GET /v1/report-templates` lists them, and `POST`, `PATCH` and
`DELETE /v1/report-templates/{id}` write them.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/report-templates \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Annual review","layout":{"version":1,"widgets":[{"id":"summary","type":"deal_summary","order":0,"config":{}}]}}'
```
```json
{
"success": true,
"data": {
"object": "report_template",
"id": "cm9k2r1a0000",
"name": "Annual review",
"description": null,
"layout": {
"version": 1,
"widgets": [{ "id": "summary", "type": "deal_summary", "order": 0, "config": {} }]
},
"created_by_user_id": "cm9a0b1c0000",
"created_at": "2026-10-10T15:00:00.000Z",
"updated_at": "2026-10-10T15:00:00.000Z"
}
}
```
Each widget type takes its own `config` keys. BrokerPlus checks them the same way the report builder
does: a value the type cannot use gets `400 VALIDATION_ERROR`, and a key the type does not know is
dropped. A good way to start is to read a template a broker made in BrokerPlus and copy its layout.
## Bulk report runs
A bulk report run makes one PDF report for each deal you give it, from a template or a layout. It is
the same as **Bulk Reports** in BrokerPlus. BrokerPlus sends nothing to the clients: you get the
files, and you decide what to do with them.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/bulk-report-runs \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"template_id":"cm9k2r1a0000","deal_ids":["cm8d1e2f0000","cm8d1e2f0001"]}'
```
```json
{
"success": true,
"data": {
"object": "bulk_report_run",
"id": "cm9k3s2b0000",
"template_id": "cm9k2r1a0000",
"template_name": "Annual review",
"status": "pending",
"total": 2,
"completed": 0,
"failed": 0,
"error": null,
"has_zip": false,
"created_by_user_id": "cm9a0b1c0000",
"created_at": "2026-10-10T15:05:00.000Z",
"updated_at": "2026-10-10T15:05:00.000Z"
}
}
```
A run takes at most 500 deals. A deal that the key's broker cannot read is left out, and `total`
tells you how many reports the run makes. A run copies the template's layout when it starts, so a
later change to the template does not change the run.
Poll `GET /v1/bulk-report-runs/{id}` until `status` is `done` or `failed`. It lists each report with
its own `status`, and `error` says why a report failed. Then
`GET /v1/bulk-report-runs/{id}/files` gives a link to each PDF and to a ZIP file with all of them.
The links stop working after 1 hour, so get new links when you need them. A key with the
`no_documents` limit can't get the files.
`GET /v1/bulk-report-runs` lists the runs on the broker's teams, newest first.
## Data exports
A data export is a copy of the broker's records in ZIP files, the same as **Export** in BrokerPlus.
`GET /v1/data-exports/packages` lists what a broker can export: a package such as `contacts` or
`deals`, or `takeout` for everything. Each export holds what the key's broker can read, nothing more.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/data-exports \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"package_id":"contacts"}'
```
```json
{
"success": true,
"data": {
"object": "data_export",
"id": "cm9k4t3c0000",
"package_id": "contacts",
"status": "pending",
"error": null,
"total_steps": 0,
"completed_steps": 0,
"row_count": 0,
"volumes": [],
"total_bytes": 0,
"created_at": "2026-10-10T15:10:00.000Z",
"completed_at": null,
"expires_at": "2026-11-09T15:10:00.000Z",
"expired_at": null
}
}
```
An export is built in the background. Poll `GET /v1/data-exports/{id}` until `status` is `ready`;
`completed_steps` and `total_steps` show how far it is. If an export of the same package is already
pending or running, the request gives you that export instead of a second one.
`POST /v1/data-exports/{id}/cancel` stops one.
The API does not give the files. An export can hold data that the API never sends, such as credit
scores, so the broker downloads it in BrokerPlus. The files stay for 30 days. `volumes` gives the
size and the SHA-256 digest of each ZIP file, so you can check a download.
Exports belong to the broker who asked for them. `GET /v1/data-exports` lists only the key's own
broker's exports, newest first.
## Imports
An import brings another system's deals into the book, as the Import page in BrokerPlus does.
`GET /v1/import-runs` lists the imports on the broker's teams, newest first, and
`GET /v1/import-runs/{id}` gives one, with each file or sync in `sources` and what was read and what
failed.
`POST /v1/import-runs/files` adds a deals export (`.csv`, `.xlsx` or `.xls`) to the team's open
import, or starts one. Send the file as `multipart/form-data` in a `file` field:
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/import-runs/files \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-F "file=@deals.xlsx" \
-F "provider=filogix"
```
Or send JSON with the file in `content_base64` and its name in `file_name`. BrokerPlus reads and maps
the file in the background. Nothing is added to the book until the broker reviews the import and
adds it in BrokerPlus.
### Status routes
A status route says where an import puts a deal by the status word the other system wrote, such as
`ready to close`. The routes belong to the brokerage, and every import uses them.
`GET /v1/import-status-routes` lists them. `PATCH /v1/import-status-routes` saves the routes you
send and leaves the others as they are:
```json
{
"entries": [
{ "source_status": "Ready to close", "deal_type": "purchase", "placement": "approved" },
{ "source_status": "Funded", "board_id": "cm9k4b0a0000", "board_stage_id": "cm9k4b0s0003" }
]
}
```
A route names a pipeline and stage, or a `placement`. Send neither to delete the route. A route you
save is the broker's own answer, so BrokerPlus never changes it.
### Saved mappings
When a broker confirms how an import's columns map to BrokerPlus fields, BrokerPlus saves the
mapping. The next file with the same column headers is mapped the same way.
`GET /v1/import-mappings` and `GET /v1/import-mappings/{id}` read them. A key that belongs to a
brokerage admin can rename one or replace its columns with `PATCH /v1/import-mappings/{id}`, and
delete one with `DELETE /v1/import-mappings/{id}`.
---
## Document: Documents
List, upload, file, and download the documents on a deal, and see what a client still owes.
URL: /files
# Documents
A document is a file on a deal: a pay stub, a statement, an appraisal, a signed disclosure. Every
document belongs to a deal, so you find it through the deal. This page covers listing, uploading,
filing, and downloading documents, and the list of what a client still owes.
This API is in beta. Any endpoint can change in a breaking way without notice, so watch the
[changelog](/changelog). A key with the `no_documents` limit can't reach any of this; see
[Authentication](/authentication).
## List a deal's documents
```bash
curl "https://api.mos.getbrokerplus.ca/v1/deals/cmscl1o.../documents?verified=true" \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```json
{
"success": true,
"data": [
{
"object": "document",
"id": "cmsd0f2...",
"deal_id": "cmscl1o...",
"contact_id": "cmsc8k2...",
"contact_name": "Jordan Client",
"file_name": "paystub-march.pdf",
"display_name": "Jordan Client - Paystub",
"file_type": "application/pdf",
"file_size": 184220,
"document_type": "paystub",
"document_type_label": "Paystub",
"classification_status": "classified",
"stage": "verified",
"verified": true,
"folder": "income",
"uploaded_via": "manual",
"uploaded_by_user_id": "cmsb9x1...",
"uploaded_by_name": "Alex Broker",
"version": 1,
"created_at": "2026-10-02T15:04:11.000Z",
"updated_at": "2026-10-02T15:06:40.000Z"
}
],
"pagination": { "total": 1, "page": 1, "page_size": 25, "total_pages": 1 }
}
```
Each row describes the file. It doesn't contain the file itself; see
[Download a file](#download-urls).
| Field | What it means |
| ----------------------------------------- | ------------------------------------------------------------------------------- |
| `file_name`, `file_type`, `file_size` | The file as it was uploaded. |
| `display_name` | The name brokers see in BrokerPlus. |
| `document_type`, `document_type_label` | What BrokerPlus classified it as, such as a pay stub or a T4. |
| `classification_status` | Whether that classification is final or still running. |
| `stage`, `verified` | Whether someone checked the file and it is ready for a lender. |
| `folder` | Where a verified file is filed, such as `income`. Null until verified. |
| `uploaded_via` | Where it came from: a broker, this API, an email, or the client. |
| `uploaded_by_user_id`, `uploaded_by_name` | The broker who added it. Null when a client or an email did. |
| `version` | The revision count. A newer document can supersede an older one. |
| `excluded_from_submission` | True when the broker marked the file to stay out of lender submissions. |
Filter with `verified`, `document_type` (a code from `GET /v1/document-types`), `contact_id` for one
borrower's files, or `updated_since` for an incremental sync (see [Sync](/sync)).
## Upload a document
`POST /v1/deals/{id}/documents` adds a file to a deal. BrokerPlus stores, classifies, and checks it
exactly as it does a file a broker drops in the app, and fires the `document.uploaded`
[webhook](/webhooks).
Send the file as `multipart/form-data` in a field named `file`:
```bash
curl https://api.mos.getbrokerplus.ca/v1/deals/cmscl1o.../documents \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Idempotency-Key: upload-paystub-march" \
-F "file=@paystub-march.pdf;type=application/pdf" \
-F "document_type=paystub" \
-F "contact_id=cmsc8k2..."
```
```json
{
"success": true,
"data": {
"object": "document",
"id": "cmsd0g7...",
"deal_id": "cmscl1o...",
"contact_id": "cmsc8k2...",
"file_name": "paystub-march.pdf",
"display_name": null,
"document_type": "paystub",
"classification_status": "pending",
"stage": "review",
"verified": false,
"folder": null,
"uploaded_via": "api",
"uploaded_by_name": "Alex Broker"
}
}
```
The response is shortened here. Everything besides the file is optional:
| Field | What it does |
| --------------- | ------------------------------------------------------------------------------------ |
| `document_type` | The type you expect, as a code from `GET /v1/document-types`. BrokerPlus checks it. |
| `contact_id` | The borrower the file belongs to. It must be an applicant on the deal, not a partner. |
| `name` | The name brokers see. Without it, BrokerPlus names the file from its type. |
If your client can't send a form, send JSON with the file in base64:
```json
{
"file_name": "paystub-march.pdf",
"content_type": "application/pdf",
"content_base64": "JVBERi0xLjcK...",
"document_type": "paystub"
}
```
The limits match the app:
- A form upload can be up to 50 MB, and a base64 upload up to 10 MB before encoding. Larger files get
`413 FILE_TOO_LARGE`.
- The file must be a PDF, JPEG, PNG, TIFF, WebP, Word (`.docx`), Excel (`.xlsx`), or CSV, and its
content must match its type. Otherwise you get `400 VALIDATION_ERROR`.
- A PDF must open without a password: `422 LOCKED_PDF` for a locked one, `422 INVALID_PDF` for one
that can't be read.
A photo is stored as a one-page PDF, so `file_type` can differ from what you sent.
**Send an `Idempotency-Key` with every upload.** Without one, a retry after a dropped connection adds
a second copy. See [Idempotency and batch](/idempotency).
An upload can't choose a folder or set `verified`. A document becomes verified when a broker checks
it, or when BrokerPlus checks it and the brokerage lets it verify documents. To verify one yourself,
move it after the upload.
## Change a document
`PATCH /v1/documents/{id}` renames a document, files it under another borrower, or keeps it out of
the next lender submission. Send only what changes.
```bash
curl -X PATCH https://api.mos.getbrokerplus.ca/v1/documents/cmsd4f1... \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"display_name": "Jordan T4 2025", "contact_id": "cmsc8k2..."}'
```
```json
{
"success": true,
"data": {
"object": "document",
"id": "cmsd4f1...",
"display_name": "Jordan T4 2025",
"contact_id": "cmsc8k2...",
"contact_name": "Jordan Client",
"stage": "review",
"verified": false,
"folder": null
}
}
```
The `contact_id` must be an applicant on the deal; a partner such as a realtor gets `400`. Send
`"display_name": null` to let BrokerPlus name the file from its type again.
`POST /v1/documents/{id}/stage` moves a document to another tray on the deal's document board, the
same as a drag in BrokerPlus. `{"stage": "verified", "folder": "income"}` marks it checked and files
it; `verified` with another `folder` moves it between folders; `pending` or `review` takes the check
back. Nothing goes to the client.
`DELETE /v1/documents/{id}` deletes the file from the deal and from storage, for good.
There's no reject. In BrokerPlus, a rejection asks the client for a new copy, which emails them. To
ask for a document, use a document request (below).
## Notes on a document
Brokers write notes on a document in its preview in BrokerPlus, for example "Page 2 is missing."
Only the broker's team sees them. A client never does.
`GET /v1/documents/{id}/notes` lists them, newest first. `POST /v1/documents/{id}/notes` adds one
with a `body`. Send `parent_id` to reply to a note. A reply can't have replies of its own. A teammate
mentioned in the body is told, the same as in BrokerPlus.
`PATCH /v1/documents/{id}/notes/{noteId}` changes a note's `body`, and
`DELETE /v1/documents/{id}/notes/{noteId}` deletes it with its replies. Only the broker who wrote a
note can change or delete it: for anybody else the note is `404 NOT_FOUND`.
## Documents a client still owes
`GET /v1/deals/{id}/document-requests` lists each document BrokerPlus asked a client for and hasn't
received yet, one row per applicant who owes it.
```bash
curl https://api.mos.getbrokerplus.ca/v1/deals/cmscl1o.../document-requests \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```json
{
"success": true,
"data": [
{
"id": "cmsd1a0...:t4:cmsc8k2...",
"label": "T4 slips for the last two years",
"document_type": "t4",
"required": true,
"contact_id": "cmsc8k2...",
"contact_name": "Jordan Client",
"is_primary_applicant": true,
"rejected": false,
"waiting_on_intake": false,
"requested_at": "2026-10-01T13:00:00.000Z"
}
],
"pagination": { "total": 1, "page": 1, "page_size": 25, "total_pages": 1 }
}
```
A row leaves the list when the client sends a file for it or a broker dismisses it. `rejected` is
`true` when the client's file was refused and the request is open again.
To ask the client for more documents, `POST /v1/deals/{id}/document-requests` does what the Request
Documents window does. It emails the client now, so a key with the `no_sending` limit can't use it.
See [Ask a client for something](/client-asks).
## The team's own document types
`GET /v1/document-types` lists the documents BrokerPlus knows. A team can add its own for anything
else it asks clients for, such as a gift letter in a particular form. `POST /v1/custom-document-types`
adds one with a `label` and, if you want, a `category` and a `description` that the client sees.
BrokerPlus makes a `code` from the label, for example `custom:gift_letter_from_parents_4d4c4a2e`.
Use that code in a [document package](#document-packages).
Two types in a team can't have the same label: the second gets `409 DUPLICATE_LABEL`.
`DELETE /v1/custom-document-types/{id}` takes a type out of the library. A request that a client
already has keeps asking for it under its label.
## Document packages
A document package is a named set of documents that the broker's team asks for together, for
example `Self-Employed Ontario`: the notice of assessment, two years of T1 generals, and business
bank statements. Packages belong to the broker's team. A key reaches the packages of the team its
broker is on, and no other team's.
`POST /v1/doc-packages` makes one. `document_types` takes codes from `GET /v1/document-types`.
`notes` holds one short line for the client under each document.
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/doc-packages \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Self-Employed Ontario","document_types":["noa","t1_general"],"notes":{"noa":"2024 and 2025, both pages"}}'
```
```json
{
"success": true,
"data": {
"object": "doc_package",
"id": "cmsp0k3...",
"name": "Self-Employed Ontario",
"document_types": ["noa", "t1_general"],
"notes": { "noa": "2024 and 2025, both pages" },
"condition_key": null,
"created_at": "2026-10-09T15:00:00.000Z",
"updated_at": "2026-10-09T15:00:00.000Z"
}
}
```
Two packages in a team can't have the same name: the second gets `409 DUPLICATE_NAME`.
`PATCH /v1/doc-packages/{id}` changes only the fields you send, and `DELETE /v1/doc-packages/{id}`
deletes a package. A request that the client already has keeps its documents, because a request
copies a package and does not link to it.
`condition_key` names a situation that BrokerPlus can recognize on a submitted application. When an
application matches it, BrokerPlus ticks the package on the deal's next request. It never sends the
package by itself. `GET /v1/doc-packages/samples` lists the situations, each with the package it
starts as, and `POST /v1/doc-packages/samples/{key}` adds one to the team.
A package sends nothing. To ask a client for its documents, send its `document_types` to
`POST /v1/deals/{id}/document-requests`, which asks the client now.
## PDFs in the client hub
The Resources tab of a client's hub holds PDFs from the broker, such as a guide for first-time
buyers or a closing checklist. A brokerage PDF goes to every client. A deal's own PDF goes to that
deal's client alone.
`POST /v1/hub-resources` adds one. Send `deal_id` for a deal's own PDF, or leave it out for a
brokerage PDF. Send the file as `multipart/form-data`, or as JSON:
```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/hub-resources \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-F file=@closing-checklist.pdf -F name="Closing Checklist" -F deal_id=cmscl1o...
```
```json
{
"success": true,
"data": {
"object": "hub_resource",
"id": "cmsq2b7...",
"deal_id": "cmscl1o...",
"name": "Closing Checklist",
"file_name": "closing-checklist.pdf",
"file_size": 184320,
"created_at": "2026-10-09T15:00:00.000Z"
}
}
```
Only a PDF of at most 20 MB is accepted, and each list holds at most 12. The client sees the PDF the
next time they open their hub. BrokerPlus does not tell them; to send a note about it, use
BrokerPlus. Because the client sees it, a key with the `no_sending` limit can't add a PDF
(`403 KEY_LIMITED`), but it can remove one. `GET /v1/hub-resources` lists the brokerage PDFs, and `?deal_id=` lists one deal's.
`DELETE /v1/hub-resources/{id}` removes a PDF from the hub and from storage.
## Files that came in by email
When a client emails a file and BrokerPlus matches the email to a deal, the file waits for the
broker before it goes on the deal file. `GET /v1/deals/{id}/email-attachments` lists the files that
wait, newest email first. Send `status=accepted`, `dismissed` or `all` to see the files the broker
already decided.
- `POST /v1/deals/{id}/email-attachments/{attachmentId}/accept` puts the file on the deal file as a
new document. `document_id` in the response is that document, and BrokerPlus classifies it as it
does an upload.
- `POST /v1/deals/{id}/email-attachments/{attachmentId}/dismiss` keeps the file off the deal file.
The email stays in the inbox.
```json
{
"success": true,
"data": {
"object": "email_attachment",
"id": "cmv2yhwjh...",
"deal_id": "cmscl1o...",
"file_name": "paystub.pdf",
"from_email": "client@example.com",
"status": "accepted",
"document_id": "cmv2yhwnm..."
}
}
```
A file is decided once. A second accept returns the same document, and a dismiss after an accept
gets `409 ALREADY_RESOLVED`. Read the file itself through its document with
`GET /v1/documents/{id}`.
## Generated documents and signing
Two more kinds of document live on a deal, and both are covered in
[Ask a client for something](/client-asks):
- **Generated documents**, such as an approval letter, made from a template with
`POST /v1/deals/{id}/generated-documents` and filed as a PDF on the deal.
- **Documents to sign**, added with `POST /v1/deals/{id}/signing-documents` and sent in the
broker's name.
## Funding packages
A funding package holds the lender's forms that the broker gets ready after the commitment: a
request for funds, a PAD form, an acknowledgement. For each document, BrokerPlus reads the form
fields, decides who fills each one, and writes in the values it holds on the deal. You can do every
step through the API, including sending it for signature.
1. Add the forms. Send documents already on the deal to
`POST /v1/deals/{id}/funding-package/items` with `document_ids`, or upload new ones to
`POST /v1/deals/{id}/funding-package/upload`. The first document starts the package.
2. Map each form with `POST /v1/deals/{id}/funding-package/items/{item_id}/map`. Each field gets a
label, a `party`, and a `value` from `GET /v1/deals/{id}/funding-package/context`. Values are
never made up: a field with no match stays empty.
3. Check the item with `PATCH /v1/deals/{id}/funding-package/items/{item_id}`. Set the person who
signs it in `recipient_contact_id`, correct `fields` if you need to, and send
`state: reviewed`. Only a reviewed item can be sent.
```bash
curl https://api.mos.getbrokerplus.ca/v1/deals/cmdeal.../funding-package/items \
-H "Authorization: Bearer $BROKERPLUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"document_ids": ["cmsd0f2..."]}'
```
`GET /v1/deals/{id}/funding-package` returns the open package, or `404` when the deal has none.
Adding a document that is already in the package adds nothing, so you can retry an add.
`DELETE /v1/deals/{id}/funding-package/items/{item_id}` takes a document out of the package and
leaves it on the deal. A key with the `no_documents` limit can't reach a funding package.
### Send it for signature
`POST /v1/deals/{id}/funding-package/send` sends the package now, in the name of the broker your key
acts as. Every document must be reviewed, with a person who signs it who has an email, or you get
`422` with each problem in `details.fields`. A key with the `no_sending` limit can't send a package
(`403 KEY_LIMITED`), and if that broker may not contact clients you get `403 FORBIDDEN`.
BrokerPlus fills each form and puts them all in one envelope. Each person gets one email from the
signing service, however many forms are theirs. The response is `201` with the envelope. If the
signing service refuses the package, you get `409 NOT_SENT`, and nothing is sent.
`GET /v1/deals/{id}/signing-envelopes` lists the envelopes that went out on the deal, with the file
names, the people each went to, and `status`: `sent`, then `signed` once everyone has signed, or
`declined` or `voided`. You can't void an envelope or send a reminder through the API.
## Download URLs
A list never contains a download URL. Ask for the single document to get one:
```bash
curl https://api.mos.getbrokerplus.ca/v1/documents/cmsd0f2... \
-H "Authorization: Bearer $BROKERPLUS_API_KEY"
```
```json
{
"success": true,
"data": {
"object": "document",
"id": "cmsd0f2...",
"file_name": "paystub-march.pdf",
"download_url": "https://...",
"download_url_expires_at": "2026-10-07T19:35:00.000Z"
}
}
```
**A download URL carries its own authorization and expires after five minutes.** Anyone holding an
unexpired URL can fetch the file without a key, so treat it like the file itself:
- **Get the URL, then follow it right away.** Don't store it, cache it, or queue it.
- **Keep it out of browsers you don't control and out of anything that writes logs.**
- **Ask again when you need it.** Each request gives a fresh URL at no cost; an old one can't be
extended.
`download_url_expires_at` is the exact moment the URL stops working. When you process many
documents, fetch each URL just before you download that file. Fetch them all up front and the last
ones expire before you get to them.
---
## Document: Errors
The error shape, the codes you will see, and which failures are safe to retry.
URL: /errors
# Errors
Every failure has the same shape: an HTTP status, and an `error` object with a stable `code` and a
`message` that says how to fix it.
```json
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "One of first_name or company_name is required",
"details": { "fieldErrors": { "email": ["Invalid email"] } }
}
}
```
**Branch on `code`, never on `message`.** We improve the wording of messages all the time. `details`
is there when it helps: the fields that failed validation, the stage requirements a deal does not
meet yet, or the limit that refused a key.
Each endpoint in the [API reference](/reference) lists the codes it can return. The tables below
are the ones you will meet most.
## Codes you will see everywhere
| Status | Code | What happened |
| ------ | --------------------- | ------------------------------------------------------------------------------------------------- |
| `400` | `VALIDATION_ERROR` | The body or a query parameter is not valid. `details.fieldErrors` names each field, and `message` says the fix. |
| `400` | `MALFORMED_JSON` | The body is not valid JSON. |
| `400` | `BAD_REQUEST` | An id in the path is not a valid id. |
| `401` | `UNAUTHORIZED` | The key is missing, malformed, or revoked, or its broker is deactivated. See [Authentication](/authentication). |
| `403` | `READ_ONLY_KEY` | The key is read-only, and this is a write. |
| `403` | `KEY_LIMITED` | A limit on the key refuses this request. `details.limit` names it. See [Key limits](/authentication#limits-on-a-key). |
| `403` | `VIEW_ONLY` | The key's broker has view-only access, and this is a write. |
| `403` | `SANDBOX_REFUSED` | A sandbox cannot do this, such as billing or phone calls. See [Sandbox](/sandbox). |
| `403` | `FORBIDDEN` | The person your key acts as cannot do this in BrokerPlus either, such as send from the connected mailbox. |
| `403` | `POLICY_BLOCKED` | Your brokerage's automation policy does not allow messages to clients to be asked for this way. An admin changes it in BrokerPlus. |
| `404` | `NOT_FOUND` | The record does not exist, or your key cannot see it. See below. |
| `409` | `CONFLICT` | The record changed while we checked it, or another system controls it. Read it again first. |
| `409` | `APPROVAL_TASK` | The task asks the broker to approve a BrokerPlus suggestion. Only the broker decides it, in BrokerPlus. |
| `409` | `NOT_SENT` | A request that [reaches a client](/reaching-clients) could not go out as it stands, and nothing was sent. `message` says why. |
| `410` | `CONTACT_MERGED` | The contact was merged into another one. `details.merged_into_id` is the one it became. |
| `412` | `EMAIL_NOT_CONNECTED` | The broker has no connected mailbox, so nothing can be emailed as them. |
| `412` | `SMS_NOT_CONNECTED` | The broker has no phone number, so nothing can be texted as them. |
| `413` | `FILE_TOO_LARGE` | The upload is over the size limit. |
| `429` | `RATE_LIMITED` | More than 600 requests in a minute. Wait for `Retry-After` seconds. |
| `503` | `SERVICE_UNAVAILABLE`, `TRY_AGAIN` | BrokerPlus is briefly busy and did nothing. Wait a moment and send the same request again. |
| `5xx` | `INTERNAL_ERROR` | Something failed on our side, but a record might already have changed. Check before you retry. |
## Codes for one area
| Area | Codes |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| Idempotency and batch | `IDEMPOTENCY_KEY_IN_FLIGHT`, `IDEMPOTENCY_KEY_CLAIMED`, `IDEMPOTENCY_KEY_REUSED`, `EXTERNAL_ID_TAKEN`, `EXTERNAL_ID_IN_FLIGHT`. See [Idempotency and batch](/idempotency). |
| Change feed | `CHECKPOINT_EXPIRED` (`410`): the checkpoint is older than 30 days. See [Staying in sync](/sync). |
| Contacts | `DUPLICATE_CONTACT` (you sent `check_duplicates: true` and the person is in the book), `ALREADY_EXISTS`, `ALREADY_MERGED`, `MERGE_CONFLICT`, `CLIENT_DATA_LOCKED`, `NO_EMAIL_ON_FILE`, `NO_PHONE_ON_FILE`. |
| Deals | `INVALID_PARTY`, `INVALID_BOARD`, `INVALID_STAGE`, `INVALID_LENDER`, `BROKER_HAS_NO_BOARDS`, `STAGE_REQUIREMENTS_UNMET`, `DEAL_LOCKED`, `PARTY_ALREADY_ON_DEAL`, `HAS_PROFILE`, `AML_GATE_BLOCKED`. |
| Commission | `COMMISSION_ACCESS`, `NO_COMMISSION`, `COMMISSION_EXISTS`, `INVALID_BROKER`. |
| Tasks | `INVALID_DEAL`, `INVALID_CONTACT`, `INVALID_ASSIGNEE`, `NO_TEAM`, `ALREADY_COMPLETED`, `ALREADY_CANCELLED`, `ALREADY_OPEN`. |
| Scheduling | `SLOT_TAKEN`, `HOST_DOUBLE_BOOKED`, `MEETING_TYPE_DISABLED`, `BOOKING_CANCELED`, `NOT_A_BOOKING`, `CONTACT_INCOMPLETE`. |
| Conditions | `REVIEW_REQUIRED`: review the lender email in BrokerPlus before you change the condition. |
| Consents | `ALREADY_REVOKED`. |
| Suggestions | `APPROVE_IN_BROKERPLUS`: the suggestion reaches a client, so the broker approves it in BrokerPlus. |
| Sandbox | `SANDBOX_ONLY`: the endpoint works only with a sandbox key. |
| Other | `INVALID_PROVINCE`, `INVALID_ROLE_LABEL`, `INVALID_APPROVAL_NUMBER`, `LIMIT_REACHED`. |
The reference entry for each endpoint says what each of these means there.
## A 404 doesn't always mean deleted
A record your key cannot see looks exactly like one that does not exist. A record from another
brokerage, from a team the broker is not on, or one that was moved or deleted, all return `404`.
That is on purpose: the API never confirms that a record exists to a key that cannot read it.
`GET /v1/me` tells you which brokerage and broker your key acts as. Start there when a `404`
surprises you.
## What to retry
| Failure | Retry? |
| ------------------------------- | -------------------------------------------------------------------------------------------- |
| `429 RATE_LIMITED` | Yes, after `Retry-After` seconds. |
| `503` | Yes, the same request, after a short wait. Back off exponentially if it repeats. |
| `409 CONFLICT` | Read the record again, check the change still makes sense, then send it. |
| A timeout, or a `5xx` | Check whether the record changed first. A server error does not prove nothing happened. With an [`Idempotency-Key`](/idempotency), resend the same request with the same key. |
| Any other `4xx` | No. Fix the request, the key, or the data first. Retrying unchanged gets the same answer. |
---
## Document: Run an AI assistant
Connect Claude, ChatGPT, or any MCP client to BrokerPlus, and let it read your book and do routine work for the broker.
URL: /mcp
# Run an AI assistant
Connect Claude, ChatGPT, or any other assistant to BrokerPlus through our MCP server, and ask it about
your deals, contacts, and tasks in plain language. The assistant can also do routine work: add a
note, create a task, move a deal. A tool that reaches a client acts at once, so your assistant asks
you before it calls one.
MCP is the Model Context Protocol, the open standard that assistants use to call outside tools. The
server is in beta, like the rest of the API.
The server covers the whole API: every endpoint in the [API reference](/reference) is a tool. Each
tool is one documented endpoint, called with your credentials. An assistant can't do anything that
the same endpoint can't do for the same broker, and the server stores no data, no keys, and no logic
of its own.
```
https://mcp.mos.getbrokerplus.ca/mcp
```
## Connect Claude on the web, ChatGPT, or another connector
Apps that add a server as a connector sign in to BrokerPlus. You don't need an API key.
1. In the app, add a custom connector with the URL `https://mcp.mos.getbrokerplus.ca/mcp`. In
Claude, that is **Settings > Connectors**.
2. Select **Connect**. The app opens a BrokerPlus page that names it.
3. Pick your team, and **Read only** or **Read and write**. Then select **Connect**.
4. BrokerPlus sends you back to the app, and the tools are ready.
Only an admin of your brokerage can connect an app. The connection shows up with your keys in
**Settings > Developer**, marked **Connected App**. Revoke it there to disconnect the app.
## Sign in instead of a key
This section is for people who build their own MCP client. The server uses OAuth 2.1.
A request with no credentials gets `401`. Its `WWW-Authenticate` header points at
`https://mcp.mos.getbrokerplus.ca/.well-known/oauth-protected-resource/mcp`, which names the BrokerPlus
authorization server. The client discovers the rest from there.
- PKCE with `S256` only.
- Client ID Metadata Documents, and dynamic client registration for clients that still use it.
- Client authentication at the token endpoint: `none` for a public client, or `private_key_jwt` with
an `RS256` or `ES256` signed assertion. A client that names `private_key_jwt` in its metadata
document must publish its keys at `jwks_uri`. It sends a `client_assertion` on every token request,
unless its `token_endpoint_auth_methods_supported` also lists `none`. An assertion that is sent is
always checked. Dynamic registration makes public clients only.
- Scopes: `read` and `write`.
- An access token lasts one hour. The refresh token rotates on every use, so a connection in use
never expires. One left idle for 60 days signs in again.
A token carries the same authority as an API key created for the same team, with the same read-only
choice, and it counts toward the brokerage's ten live keys. See
[Authentication](/authentication).
## Connect with an API key
Claude Code, Claude Desktop, and most local clients send a key instead. Create one for the assistant
in **Settings > Developer**, name it after the assistant, and set it to **Read only** if the assistant
should never change anything. See [Authentication](/authentication) for key limits such as
`no_sending`.
**Claude Code.** Run this in a terminal, then type `/mcp` in Claude Code to check that `brokerplus`
is connected:
```bash
claude mcp add --transport http brokerplus https://mcp.mos.getbrokerplus.ca/mcp \
--header "Authorization: Bearer $BROKERPLUS_API_KEY"
```
**Claude Desktop.** Claude Desktop reaches the server through the `mcp-remote` bridge, which needs
Node.js. Go to **Settings > Developer**, select **Edit Config**, and add the server to
`claude_desktop_config.json`:
```json
{
"mcpServers": {
"brokerplus": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://mcp.mos.getbrokerplus.ca/mcp",
"--header",
"Authorization:${BROKERPLUS_AUTH}"
],
"env": { "BROKERPLUS_AUTH": "Bearer bp_live_..." }
}
}
}
```
Replace `bp_live_...` with your key and restart Claude Desktop. There is no space after the colon in
the `--header` value, because Claude Desktop on Windows splits arguments at spaces. The space lives in
the `env` value instead. This file holds your key in plain text, so keep it on your own computer.
**Any other client.** Speak Streamable HTTP to the URL above, and send the key as a bearer token on
every request, the same as for the API.
The server checks the key by calling `GET /v1/me`. If the API refuses the key, the server answers
`401`. The server hides every tool that the key's limits refuse: a read-only key sees only the tools
that read, and a key with the `no_sending` limit sees no tool that reaches a client.
## Things to ask
- "Which of my deals close in the next 14 days, and which conditions are still open on each one?"
- "List the mortgages in my book that mature in the next 120 days, with the savings to move each one."
- "Summarize the last five emails from Jordan Client, and draft a reply for me to review."
- "Make a task for each deal in the Submitted stage that hasn't moved in seven days."
- "Which leads came in this week, and which of them have no follow-up date?"
## What the tools are
There is one tool for each operation in the API, named after the operation with a `brokerplus_`
prefix. `GET /contacts/{id}/statistics` is `brokerplus_get_contact_statistics`, and `POST /tasks` is
`brokerplus_create_task`. The [API reference](/reference) names every operation.
| Area | Example tools |
| -------------------- | ---------------------------------------------------------------------------------------------------------- |
| Who and what | `brokerplus_get_me`, `brokerplus_list_users`, `brokerplus_list_pipelines` |
| Contacts and leads | `brokerplus_list_contacts`, `brokerplus_create_contact_note`, `brokerplus_create_lead`, `brokerplus_convert_lead` |
| Deals | `brokerplus_list_deals`, `brokerplus_update_deal`, `brokerplus_list_deal_conditions`, `brokerplus_raise_deal_flag` |
| The mortgage | `brokerplus_get_deal_application`, `brokerplus_list_deal_applicants`, `brokerplus_get_deal_financials` |
| Renewals | `brokerplus_list_opportunities`, `brokerplus_create_opportunity` |
| Calendar, mail, calls | `brokerplus_list_appointments`, `brokerplus_book_meeting`, `brokerplus_list_communications`, `brokerplus_log_call` |
| Tasks | `brokerplus_list_tasks`, `brokerplus_create_task`, `brokerplus_complete_task`, `brokerplus_bulk_tasks` |
| Messages to clients | `brokerplus_create_contact_draft`, `brokerplus_send_contact_message`, `brokerplus_request_deal_documents` |
| Follow-up | `brokerplus_create_automation`, `brokerplus_turn_on_automation`, `brokerplus_create_template`, `brokerplus_schedule_campaign` |
| What the broker sees | `brokerplus_list_suggestions`, `brokerplus_list_notifications`, `brokerplus_list_deal_activity` |
A tool's inputs are one flat object: the path id, the query parameters, and the request body sit side
by side. The tool for `POST /deals/{id}/notes` takes `id` and `body` together. Names and types match
the endpoint, because both come from the same contract.
Every tool that makes a `POST` takes an optional `idempotency_key`, sent as the `Idempotency-Key`
header. See [Idempotency](/idempotency).
The tool list is short, so that assistants can load all of it. Each description stops after its
first sentences, and each field's description after two. A tool's `outputSchema` names only `data`,
plus `pagination` for a list. To read the full contract of one tool, with every field of the
response, call `brokerplus_describe_operation` with its operation id.
### Results
A result has two parts:
- `structuredContent` is the `data` of the response, plus `pagination` for a list.
- The text starts with one line that names what came back, by name and id, followed by the JSON for
assistants that read only text.
For example, `brokerplus_list_lead_stages` returns this text:
```
List lead stages: 2 of 2 (page 1 of 1).
- New (cmslst1...)
- Contacted (cmslst2...)
{"data":[{"id":"cmslst1...","name":"New","position":0}, ...],"pagination":{...}}
```
An error comes back as text marked as an error, with the API's status, code, message, and `details`.
The assistant can read `DUPLICATE_CONTACT` and the matching contacts, or `VALIDATION_ERROR` and the
failing fields, and fix its call. See [Errors](/errors).
### Hints for your assistant
Each tool carries MCP annotations. Claude, ChatGPT, and other clients use them to decide which calls
to confirm with you, so the annotations, not a list of blocked tools, keep an assistant from acting
without you.
| Annotation | `true` for |
| ----------------- | ----------------------------------------------------------------------------------------------------------- |
| `readOnlyHint` | Every tool that reads. It changes nothing. |
| `destructiveHint` | Every update, because it replaces the values you send, and every delete. Also every tool that reaches people outside the brokerage, and the writes that can't be undone or that replace a whole set: merging or archiving a contact, approving an intake, revoking a consent, resetting rates, reordering, and batch writes. |
| `idempotentHint` | Reads, updates, and deletes, and the writes that say a second call changes nothing. A create is idempotent only with an `idempotency_key`. |
| `openWorldHint` | Every tool that [reaches a client](/reaching-clients), such as `brokerplus_send_contact_message`, `brokerplus_schedule_campaign`, and `brokerplus_cancel_booking`. Also turning an automation or a lead form's reply on, changes to what clients see in their hub, creating an intake, and webhook endpoints. Each one reaches people outside the brokerage as soon as it runs, and its description says so. |
## Find and call any operation
Three more tools find and call any operation by its id. Use them when an assistant caps how many
tools it loads.
| Tool | What it does |
| ------------------------------- | ------------------------------------------------------------------------------------------------ |
| `brokerplus_search_operations` | Finds operations by the words in `query`, and returns each `operation_id`, method, path, and summary. |
| `brokerplus_describe_operation` | Returns the inputs and the output schema of one `operation_id`. |
| `brokerplus_call_operation` | Makes the operation's request, with its inputs in `arguments`. |
`brokerplus_call_operation` makes the same `/v1` request as the operation's own tool, with the same
credentials, permissions, and key limits. The three tools find and call only what the key's limits
allow.
## Keep an assistant in bounds
**A tool that reaches a client acts at once, as the broker.** An assistant can send a message,
request documents, book or cancel a meeting, or turn on an automation, and each of those goes out
when it runs. Its hints tell your assistant to ask you first.
**A delete is a tool too.** Its hints mark it destructive, and its description says what goes with
it and whether you can undo it.
To keep an assistant away from a kind of action, give its key a limit. The limits work the same way
for the server as for the API. See [Authentication](/authentication).
| Limit | What the assistant can't do |
| -------------- | ----------------------------------------------------------------------------------- |
| `read_only` | Anything that writes. It sees only the tools that read. |
| `no_sending` | Anything that [reaches a client](/reaching-clients). It doesn't see those tools. |
| `no_documents` | Read or add documents and files. |
| `leads_only` | Anything but leads. |
A tool that returns a signed link to a file returns the link, as the API does. The link works for a
short time, and anybody who has it can open the file, so a key that must not hand out files needs
the `no_documents` limit.
## Before you connect an assistant
- **The assistant acts as the broker.** It sees what the broker sees and can make every write the
broker can make, including deletes, and it can reach the broker's clients unless its key has the
`no_sending` limit. Read each change before you confirm it. The BrokerPlus audit log records every
write under the broker's name.
- **Give each assistant its own key**, named after it, so you can revoke it without touching other
integrations.
- **Mind the rate limit.** It is per key, and a question that reads the whole book can reach it. Ask
about a stage, a date range, or a broker instead. A list tool returns 50 records a page, and the
assistant asks for the next page when it needs more.
- **The server keeps nothing.** It logs no request bodies and stores no responses.