> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nextlevelmca.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Documents and uploads

> Upload files through presigned URLs, download them, request documents from merchants and read statement analysis.

Documents belong to deals. The API never proxies file bytes: uploads and downloads go straight to storage through short-lived signed URLs. Reading needs `documents:read`; uploading and requesting need `documents:write`.

## Upload a document

Three steps: register the document, `PUT` the bytes, finalise.

<Steps>
  <Step title="Register the document">
    ```bash theme={null}
    curl -X POST "$NLMCA_API/deals/$DEAL/documents" \
      -H "Authorization: Bearer $NLMCA_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "chase-2026-08.pdf",
        "type": "bank-statement",
        "mime_type": "application/pdf",
        "file_size": 184322
      }'
    ```

    ```json Response 201 theme={null}
    {
      "data": {
        "document_id": "a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d",
        "upload_url": "https://storage.nextlevelmca.com/…signed…",
        "upload_method": "PUT",
        "headers": { "Content-Type": "application/pdf", "x-upsert": "false" },
        "expires_at": "2026-09-04T16:20:12.000Z"
      },
      "meta": {}
    }
    ```

    The document row exists now (`upload_pending: true`) but stays hidden from the app and from `GET /v1/deals/{id}/documents` until you finalise it. `upload_url` is valid for about two hours, until `expires_at`; if it lapses, register again.
  </Step>

  <Step title="PUT the file to upload_url">
    Send the bytes with exactly the `headers` from the response and nothing else. In particular, no `Authorization` header: the URL carries its own signature. `Content-Type` must match the `mime_type` you registered.

    ```bash theme={null}
    curl -X PUT "$UPLOAD_URL" \
      -H "Content-Type: application/pdf" \
      -H "x-upsert: false" \
      --data-binary @chase-2026-08.pdf
    ```
  </Step>

  <Step title="Finalise">
    ```bash theme={null}
    curl -X POST "$NLMCA_API/documents/a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d/finalize" \
      -H "Authorization: Bearer $NLMCA_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "file_size": 184322 }'
    ```

    ```json Response 200 theme={null}
    {
      "data": {
        "id": "a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d",
        "deal_id": "3c9d1f70-8e5a-4d26-b1f4-2a7e6c5d4b39",
        "name": "chase-2026-08.pdf",
        "type": "bank-statement",
        "mime_type": "application/pdf",
        "file_size": 184322,
        "upload_pending": false,
        "variant": "original",
        "source": "api",
        "ocr_status": null,
        "shared_with_merchant": false,
        "uploaded_by_id": null,
        "uploaded_at": "2026-09-04T14:20:31.000Z",
        "created_at": "2026-09-04T14:20:12.000Z"
      },
      "meta": {}
    }
    ```

    Finalising confirms the object is in storage, records the real size, makes the document visible, fires `document.uploaded` and, for bank statements, recomputes the deal's statement coverage. If nothing was uploaded yet it answers `409` with `code: "upload_not_found"`. Bank statements are **not** analysed automatically (analysis is billed per file): an underwriter starts it from the deal's Underwrite tab in the app. When that runs, `document.processed` fires per file and `statement.analyzed` when the deal's analysis is complete, and `GET /v1/deals/{id}/statement-analysis` fills in.
  </Step>
</Steps>

<Note>
  Finalising is what makes an upload count. If your process dies between the `PUT` and the finalise call, the file is in storage but invisible; call finalise again, it is safe to repeat.
</Note>

## Document types

`type` is one of:

| Type                 | Use                                                                        |
| -------------------- | -------------------------------------------------------------------------- |
| `application`        | Signed application                                                         |
| `bank-statement`     | One file per statement period. These feed statement analysis and coverage. |
| `tax-return`         | Business or personal tax return                                            |
| `voided-check`       | Voided check                                                               |
| `contract`           | Funding contract                                                           |
| `drivers-license`    | Government id                                                              |
| `ach-authorization`  | ACH authorisation form                                                     |
| `business-license`   | Business licence                                                           |
| `proof-of-ownership` | Proof of ownership                                                         |
| `utility-bill`       | Utility bill                                                               |
| `other`              | Anything else. Give it a descriptive `name`.                               |

## List and download

`GET /v1/deals/{id}/documents` lists finalised documents with their type and processing state (`ocr_status`), optionally filtered by `?type=`. `GET /v1/documents/{id}` returns the metadata plus `download_url`, a signed link that works for **15 minutes** (`download_url_expires_at` says exactly when). Fetch a fresh one each time you need the file rather than storing the link.

```bash theme={null}
curl "$NLMCA_API/documents/a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d" \
  -H "Authorization: Bearer $NLMCA_KEY"
# then GET data.download_url within 15 minutes, without an Authorization header
```

## Request documents from the merchant

`POST /v1/deals/{id}/document-requests` creates a request the merchant fulfils through a unique upload link, the same flow the app's "Request documents" button uses. It sends messages, so use an `Idempotency-Key`.

```bash theme={null}
curl -X POST "$NLMCA_API/deals/$DEAL/document-requests" \
  -H "Authorization: Bearer $NLMCA_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 1d2c3b4a-5e6f-4a7b-8c9d-0e1f2a3b4c5d" \
  -d '{
    "items": [
      { "type": "bank_statement", "label": "Last 3 months of bank statements" },
      { "type": "voided_check" }
    ],
    "channel": "email",
    "person_id": "5e4d3c2b-1a0f-4e9d-8c7b-6a5f4e3d2c1b",
    "message": "Please upload these so we can move forward."
  }'
```

```json Response 201 theme={null}
{
  "data": {
    "id": "f1e2d3c4-b5a6-4978-8a9b-0c1d2e3f4a5b",
    "deal_id": "3c9d1f70-8e5a-4d26-b1f4-2a7e6c5d4b39",
    "status": "pending",
    "channel": "email",
    "delivery": "email",
    "sent": true,
    "sent_to": "o***r@acmeplumbing.example",
    "upload_url": "https://app.nextlevelmca.com/portal/…",
    "expires_at": "2026-10-04T14:30:00.000Z",
    "items": [
      {
        "id": "4a5b6c7d-8e9f-4a0b-9c1d-2e3f4a5b6c7d",
        "type": "bank_statement",
        "label": "Last 3 months of bank statements",
        "status": "pending",
        "received_document_id": null,
        "received_at": null
      },
      {
        "id": "5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e",
        "type": "voided_check",
        "label": "Voided check",
        "status": "pending",
        "received_document_id": null,
        "received_at": null
      }
    ],
    "created_at": "2026-09-04T14:30:00.000Z"
  },
  "meta": {}
}
```

`items[].type` is one of `bank_statement`, `voided_check`, `drivers_license`, `business_license`, `tax_returns`, `proof_of_ownership` or `utility_bill` (underscores, unlike the hyphenated document `type`); `label` overrides the text the merchant sees. `person_id` defaults to the deal's primary owner, then the business's primary contact. `expires_in_days` (1 to 90, default 30) sets `expires_at`.

| `channel`   | Behaviour                                                                                                                                                                                                                                        |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `email`     | Emails the link to the person through the location's connected mailbox. Fails with `409` and `code: "no_email_sender"` when the location has no active email sender: connect one in Settings, or fall back to `link_only`.                       |
| `sms`       | Texts the link through the CRM conversation when the contact is linked to the CRM. Otherwise the request is still created and returned with `sent: false`, `delivery: "link_only"` and `meta.warning`, so you can deliver `upload_url` yourself. |
| `link_only` | Creates the request and returns `upload_url`. Nothing is sent.                                                                                                                                                                                   |

`delivery` reports what actually happened; `channel` echoes what you asked for. Files the merchant uploads through the link become documents on the deal, fire `document.uploaded` and are matched to the requested items automatically: `items[].status` becomes `received` with `received_document_id`, and the request's own `status` moves from `pending` through `partial` to `complete`.

## Statement analysis

`GET /v1/deals/{id}/statement-analysis` returns what the underwriter sees after bank statements are parsed: per-month figures, a summary with the revenue trend, the deal's MCA positions, and coverage per account. It answers `status: "not_analyzed"` with empty figures when no statement has been analysed yet (not an error), and `pending` or `processing` while analysis is still running.

```json Response 200 theme={null}
{
  "data": {
    "status": "completed",
    "analyzed_at": "2026-09-04T15:02:10.000Z",
    "months": [
      {
        "month": "2026-06",
        "deposits": 39840.5,
        "deposit_count": 61,
        "withdrawals": 37210.8,
        "mca_debits": 6562.5,
        "avg_daily_balance": 5810.2,
        "ending_balance": 4120.75,
        "nsf_count": 1,
        "negative_days": 2,
        "statement_count": 1
      },
      {
        "month": "2026-07",
        "deposits": 40120.0,
        "deposit_count": 58,
        "withdrawals": 38990.4,
        "mca_debits": 6562.5,
        "avg_daily_balance": 6045.9,
        "ending_balance": 5250.35,
        "nsf_count": 1,
        "negative_days": 1,
        "statement_count": 1
      },
      {
        "month": "2026-08",
        "deposits": 43810.2,
        "deposit_count": 64,
        "withdrawals": 41005.6,
        "mca_debits": 6562.5,
        "avg_daily_balance": 6455.3,
        "ending_balance": 8054.95,
        "nsf_count": 0,
        "negative_days": 0,
        "statement_count": 1
      }
    ],
    "summary": {
      "avg_monthly_deposits": 41256.9,
      "avg_daily_balance": 6103.8,
      "nsf_count": 2,
      "negative_days": 3,
      "revenue_trend": "flat",
      "months_analyzed": 3
    },
    "positions": [
      {
        "id": "7c8d9e0f-1a2b-4c3d-8e4f-5a6b7c8d9e0f",
        "funder_name": "Rapid Advance Co",
        "payment_amount": 312.5,
        "payment_frequency": "daily",
        "current_balance": 18400,
        "original_amount": 45000,
        "position_number": 1,
        "detected": true
      }
    ],
    "coverage": {
      "status": "missing",
      "required_months": 3,
      "mtd_required": true,
      "complete": false,
      "missing": [
        { "bank_name": "Chase", "account_last_four": "4421", "month": "2026-09", "label": "Sep 2026 (MTD)" }
      ],
      "summary": "3 of 4 required statements on file for 1 account; missing Sep 2026 (MTD)",
      "unanalyzed_documents": 0
    },
    "accounts": [
      {
        "bank_name": "Chase",
        "account_last_four": "4421",
        "months_covered": ["2026-06", "2026-07", "2026-08"],
        "months_missing": ["2026-09"]
      }
    ]
  },
  "meta": {}
}
```

What the numbers mean:

* **Deposits** (`months[].deposits`) are true deposits: transfers and MCA credits are excluded when the parser identified them. `summary.avg_monthly_deposits` is the bank-verified revenue that lender matching uses instead of the stated `annual_revenue` whenever statements are analysed, which is why uploading statements before matching changes the results.
* **Average daily balance** and **ending balance** are summed across accounts for the month; `summary.avg_daily_balance` is the mean of the monthly figures.
* **NSF count** is the number of returned or non-sufficient-funds items. **Negative days** is the number of days the account closed below zero (the maximum across accounts for the month). Both feed the computed paper grade and many lender gates.
* **Positions** are existing advances on the deal: `detected: true` for those found from recurring debits to known funders in the statements, `false` for positions entered on the deal. Lenders use the count and the total payment when deciding on a new position.
* **Coverage** says whether the months the location requires are on file for every account: the last `required_months` full calendar months (3 by default, a location setting) plus a month-to-date statement when `mtd_required` is true. `status` is `complete`, `missing` (see `missing[]` and `accounts[].months_missing`), `needs_analysis` (statements uploaded but not yet parsed; `unanalyzed_documents` counts them) or `no_statements`.
* **Trend** (`summary.revenue_trend`) compares deposits in the most recent half of the analysed period with the earlier half: `up` at +10% or more, `down` at −10% or less, otherwise `flat`; `null` with fewer than two months.

For the exact field names and types, open the endpoint in the **API reference** tab.


## Related topics

- [Start a document upload](/api-reference/documents/start-a-document-upload.md)
- [Building an AI-first workflow](/guides/ai-first-workflow.md)
- [Quickstart](/getting-started/quickstart.md)
