# 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.
