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

# Upload invoices, quotes and orders with the API

> Push invoices, quotes and other documents with their PDFs from your own billing or quoting system, so your clients can open them in the portal.

This guide is for MSP staff and developers who connect a billing or quoting system that has no MSPortal integration. Each call carries one record and its PDF:

| What you upload | Where it appears | Who sees it |
| - | - | - |
| Invoice | A row in the **Invoices** table, next to invoices synced from your PSA. **View PDF** opens the PDF you uploaded. | MSP staff, and the company's portal users whose role includes **Invoices** |
| Quote | A row in the **Quotes** table and its quote page, next to synced quotes. **View quote** and **Download PDF** open the PDF you uploaded. | MSP staff, and the company's portal users whose role includes **Quotes** |
| Order or any other document | A file in the company's **Documentation** library | MSP staff, and the company's portal users whose role includes **Documentation** |

Uploaded invoices and quotes sort, filter, search, export and bulk-download with the synced ones. Push the same record again to update it, for example when an invoice is paid.

## Before you start

* **Create an API key.** Go to **Settings > Integrations**, find the **API Access** card, click **Manage API Keys**, then **Create New Key**. Creating keys needs the **Integrations** permission at **Manage**. Pick these scopes:
  * **Write Financial** (`financial.write`) for invoices and quotes.
  * **Write Documentation** (`documentation.write`) for orders and other documents.
  * **Read Companies** (`companies.read`) to look up company IDs.
  * Optional: **Read Financial** (`financial.read`) to list what you uploaded.
* **Create the key as an MSP staff user with write access.** A key acts as the user who created it. Invoice and quote uploads need **Integrations** at **Write**. Document uploads need the **Documentation** settings permission at **Write**. Admin roles have both. A key created by a company (portal) user cannot upload.
* **Use your region's address.** It is the address you sign in to MSPortal at: US tenants call `https://app.msportal.ai`, EU tenants `https://eu.msportal.ai`. The examples use the EU address.
* **No PSA or quoting integration is needed.** The **Invoices** and **Quotes** pages appear after your first upload. To let a client's portal users open them, give their role the **Invoices**, **Quotes** or **Documentation** permission.
* The examples use `curl` and `jq`. Any language that can send JSON over HTTPS works the same way.

Every request sends the key as a Bearer token:

```bash theme={null}
export MSPORTAL=https://eu.msportal.ai/api/public/v1
export KEY=your-api-key
```

The **API Reference** tab lists every field, under **Financial** and **Documentation**.

Leave out optional fields you have no value for. An empty string (`""`) in a text field is refused with a `400`.

## Find the company ID

Each record belongs to one company (your client). List your companies once, match them to your clients by `name`, and store each MSPortal `id` next to the client in your system:

```bash theme={null}
curl -s "$MSPORTAL/companies?limit=100&page=1" -H "Authorization: Bearer $KEY" \
  | jq '.data[] | {id, name}'
```

While `pagination.hasNextPage` is `true`, fetch the next page (`page=2`, and so on). Use the matching company's `id` as `companyId` below.

## Upload an invoice

Send the invoice and its PDF in one request. The PDF goes in `pdf.contentBase64` as standard base64 (no `data:` prefix), up to 3,000,000 bytes.

```bash theme={null}
PDF=$(base64 -w0 invoice-2026-1042.pdf)   # macOS: base64 -i invoice-2026-1042.pdf

curl -s -X POST "$MSPORTAL/financial/invoices" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d @- <<EOF
{
  "externalId": "INV-2026-1042",
  "invoiceNumber": "2026-1042",
  "companyId": "3f0c2a5e-0000-4000-8000-000000000001",
  "invoiceDate": "2026-10-01",
  "dueDate": "2026-10-31",
  "status": "Open",
  "currency": "EUR",
  "subtotal": 1000,
  "salesTax": 190,
  "total": 1190,
  "pdf": { "fileName": "invoice-2026-1042.pdf", "contentBase64": "$PDF" }
}
EOF
```

The answer is `201` with the MSPortal `id`. Store it if you may want to delete the invoice later.

```json theme={null}
{ "data": { "id": "b8c1…", "externalId": "INV-2026-1042", "created": true } }
```

| Field | Required | Notes |
| - | - | - |
| `externalId` | Yes | Your system's ID, up to 255 characters. It identifies the invoice on later calls. |
| `invoiceNumber` | Yes | Shown in the **Invoice #** column. Up to 100 characters, with at most 18 digits in total. |
| `companyId` | Yes | From [Find the company ID](#find-the-company-id). |
| `invoiceDate` | Yes | `YYYY-MM-DD`. |
| `total` | Yes | At least 0.01, with up to two decimals. |
| `pdf` | The first time | `fileName` must end in `.pdf`; the content must be a PDF. |
| `dueDate`, `paidDate` | No | `YYYY-MM-DD`. A `paidDate` marks the invoice closed. |
| `status` | No | Shown in the **Status** column, for example `Open` or `Paid`. |
| `currency` | No | ISO 4217 code, for example `EUR`. |
| `subtotal`, `salesTax`, `payments` | No | Numbers. |
| `balance` | No | Amount still due. Without it, MSPortal stores 0 when `paidDate` is set, otherwise `total` minus `payments`. |
| `reference`, `customerPo` | No | Shown in the **Reference** and **Customer PO** columns. |
| `invoiceType` | No | Defaults to `Standard`. |

### Mark an invoice paid

Send the same `externalId` again with the new values. Leave out `pdf` to keep the stored PDF, or send a new one to replace it. The answer is `200` with `"created": false`.

```bash theme={null}
curl -s -X POST "$MSPORTAL/financial/invoices" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{
    "externalId": "INV-2026-1042",
    "invoiceNumber": "2026-1042",
    "companyId": "3f0c2a5e-0000-4000-8000-000000000001",
    "invoiceDate": "2026-10-01",
    "total": 1190,
    "payments": 1190,
    "balance": 0,
    "paidDate": "2026-10-15",
    "status": "Paid"
  }'
```

Every push replaces the invoice's values, so send all the fields you want to keep, not only the ones that changed.

## Upload a quote

```bash theme={null}
PDF=$(base64 -w0 quote-A-77.pdf)   # macOS: base64 -i quote-A-77.pdf

curl -s -X POST "$MSPORTAL/financial/quotes" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d @- <<EOF
{
  "externalId": "Q-77",
  "companyId": "3f0c2a5e-0000-4000-8000-000000000001",
  "name": "Firewall refresh",
  "number": "A-77",
  "stage": "Sent",
  "contactName": "Erika Muster",
  "ownerName": "Thomas Beispiel",
  "currency": "EUR",
  "oneTimeTotal": 4800,
  "monthlyTotal": 120,
  "quoteDate": "2026-10-20T09:00:00Z",
  "pdf": { "fileName": "quote-A-77.pdf", "contentBase64": "$PDF" }
}
EOF
```

| Field | Required | Notes |
| - | - | - |
| `externalId` | Yes | Your system's ID, up to 255 characters. |
| `companyId` | Yes | From [Find the company ID](#find-the-company-id). |
| `name` | Yes | The quote's title. |
| `pdf` | The first time | `fileName` must end in `.pdf`; the content must be a PDF. |
| `number` | No | Shown in the **Number** column. |
| `stage` | No | For example `Draft`, `Sent`, `Won` or `Lost`. Roles that only show some stages hide quotes without one, so send it. |
| `contactName` | No | The client contact the quote is for. Roles that only show users their own quotes match it against the portal user's full name. |
| `ownerName` | No | The MSP staff member who owns the quote. |
| `oneTimeTotal`, `monthlyTotal`, `annualTotal` | No | Numbers. |
| `currency` | No | ISO 4217 code, for example `EUR`. |
| `quoteDate`, `expiresAt`, `wonAt` | No | ISO 8601 date and time. `quoteDate` defaults to the time of the first upload. |
| `url` | No | An `https` link to the quote in your system. The quote page shows it as **Open quote**. |

Push the same `externalId` again to change the stage or replace the PDF. As with invoices, every push replaces the quote's values; a push without `quoteDate` keeps the stored date.

## Upload an order or another document

Orders have no table of their own, so they go into the company's **Documentation** library. PDF, DOCX, XLSX, XLS and CSV files up to 50 MiB are accepted. A document upload takes three calls: prepare, send the bytes, finalize.

```bash theme={null}
FILE=order-5501.pdf
BODY=$(jq -n --argjson size "$(wc -c < "$FILE" | tr -d ' ')" '{
  name: "Order 5501",
  fileName: "order-5501.pdf",
  fileSize: $size,
  contentType: "application/pdf",
  companyId: "3f0c2a5e-0000-4000-8000-000000000001"
}')

# 1. Prepare: returns documentId, uploadUrl (valid for 2 hours) and contentType.
PREPARED=$(curl -s -X POST "$MSPORTAL/documentation/files" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d "$BODY")
DOCUMENT_ID=$(echo "$PREPARED" | jq -r .data.documentId)
UPLOAD_URL=$(echo "$PREPARED" | jq -r .data.uploadUrl)
CONTENT_TYPE=$(echo "$PREPARED" | jq -r .data.contentType)

# 2. Send the bytes to storage. No Authorization header on this call.
curl -s -X PUT "$UPLOAD_URL" -H "Content-Type: $CONTENT_TYPE" -H "x-upsert: false" \
  --data-binary @"$FILE"

# 3. Finalize with the same body as step 1.
curl -s -X POST "$MSPORTAL/documentation/files/$DOCUMENT_ID/finalize" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d "$BODY"
```

Finalize answers `201`, and the document appears in the company's Documentation library.

* `companyId` is required. A document without a company would be visible to every client, so it is refused.
* `fileSize` must be the exact size in bytes, and `fileName` must end in the file's extension (`.pdf`, `.docx`, `.xlsx`, `.xls` or `.csv`) and must not contain `..`.
* To file orders in their own folder, click **New folder** on the **Documentation** page once, read the folder's `id` from `GET /documentation/folders`, and pass it as `folderId`. A folder without a company works for every client.
* If finalize times out, send it again with the same `documentId` and body: it returns the same document, never a duplicate. A retry that names a different company or folder answers `409`.
* An `uploadUrl` accepts one file. If the bytes you sent had the wrong size or type, finalize answers `400`; start again with a new prepare call.
* Documents uploaded this way can't be deleted through the API yet.

## Delete an upload

Delete an invoice or quote you uploaded by its MSPortal `id`. The PDF is removed with it. Invoices synced from your PSA can't be deleted this way and answer `404`.

```bash theme={null}
curl -s -X DELETE "$MSPORTAL/financial/invoices/b8c1…" -H "Authorization: Bearer $KEY"
curl -s -X DELETE "$MSPORTAL/financial/quotes/7d42…" -H "Authorization: Bearer $KEY"
```

## Retries

* Invoice and quote uploads are keyed by `externalId`, so sending the same request again is always safe: it updates the record instead of creating a second one.
* Document uploads are safe to finalize again with the same `documentId`. If a prepare call fails, start over with a new prepare.

## Errors

Every error answers with a JSON body. `error` is a readable message, `code` a stable value to branch on, and `requestId` identifies the call for support. When fields are invalid, `details` names each one:

```json theme={null}
{
  "error": "Validation failed",
  "code": "VALIDATION_ERROR",
  "requestId": "27fd3b90-…",
  "details": [{ "path": ["total"], "message": "Too small: expected number to be >0" }]
}
```

A rule checked after the fields, such as a missing PDF on the first upload, comes back as `{ "error": "a PDF is required", "code": "VALIDATION_ERROR", "requestId": "…" }`.

| Status | Meaning | What to do |
| - | - | - |
| 400 | A field is missing or invalid, for example no PDF on the first upload, a `total` of 0, or a file that is not a PDF | Read `error`, fix the request and send it again |
| 401 | The key is missing, wrong or revoked | Check the `Authorization` header |
| 403 | The key lacks the scope, or its user lacks the permission | Add the scope, or create the key as a user with the permissions in [Before you start](#before-you-start) |
| 404 | The company, folder or record does not exist in your tenant | Check the ID |
| 409 | A document retry names a different company or folder | Use a new prepare call for a different company |
| 413 | The PDF is over 3,000,000 bytes, or the request body is too large | Compress the PDF |
| 429 | Too many requests | Wait until the time in the `X-RateLimit-Reset` header, then retry |
| 500 | Something failed on our side | Retry later. If it keeps failing, send the `requestId` to MSPortal support |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.