# 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=<unix seconds>,v1=<hex>`, where the hex is
`HMAC-SHA256(secret, "<t>.<raw body>")`.

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.
