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

# Upload AI Builder Content to a Proposal

> POST .../ai/content — Uploads a file as AI source content for a proposal. Multipart upload with single-file and multi-chunk modes. Returns the new content item record.

Uploads a file as AI source content for the specified proposal. The request body must be `multipart/form-data` and must include at least one file part. The proposal must be in Draft status to accept new content.

Files up to **25 MB** can be sent in a single request. Files larger than 25 MB must use multi-chunk mode. The maximum supported file size is **1 GB**. For multi-chunk uploads, set `?isMultiChunkUpload=true` and send each chunk as its own multipart file part — see [Multi-chunk upload](#multi-chunk-upload) below.

<Warning>
  This endpoint requires `multipart/form-data`, not `application/x-www-form-urlencoded`. Sending a URL-encoded body returns `No file was provided in the request.`
</Warning>

## Request

**`POST /public/api/proposals/{ProposalId}/ai/content`**

### Headers

<ParamField header="Accept" type="string" required>
  Must be `application/json`.
</ParamField>

<ParamField header="Content-Type" type="string" required>
  Must be `multipart/form-data; boundary=<generated-boundary>`. The full value, including the boundary parameter, must also be used in the HMAC canonical message. See [Sign multipart requests](/authentication/signing-requests#sign-multipart-requests).
</ParamField>

<ParamField header="X-MSS-API-APPID" type="string" required>
  Your API Application Key.
</ParamField>

<ParamField header="X-MSS-API-USERKEY" type="string" required>
  Your User API Key.
</ParamField>

<ParamField header="X-MSS-CUSTOM-DATE" type="string" required>
  Current UTC timestamp in RFC 7231 format.
</ParamField>

<ParamField header="X-MSS-SIGNATURE" type="string" required>
  HMAC-SHA256 signature, Base64-encoded.
</ParamField>

### Path parameters

<ParamField path="ProposalId" type="integer" required>
  Numeric identifier of the proposal to which the AI content will be attached. The proposal must be in Draft status to accept new content.
</ParamField>

### Form fields

The body is a `multipart/form-data` payload containing the following parts.

<ParamField body="name" type="string" required>
  Human-readable name or title for the content being uploaded. Used for display and search. Provide a concise, descriptive name.
</ParamField>

<ParamField body="isMultiChunkUpload" type="boolean">
  Send `false` (or omit) for a normal single-file upload. Send `true` only when also using `?isMultiChunkUpload=true` on the URL — see [Multi-chunk upload](#multi-chunk-upload).
</ParamField>

<ParamField body="<file-part>" type="file" required>
  The actual file to upload, sent as a multipart file part. See the file part naming rule below — the field name must equal the filename, including its extension.
</ParamField>

### File part naming (important)

The form field name of the file part **must include the file extension**. The server reads the file type from the field name, not from the `filename=` parameter or the part's `Content-Type` header.

Set the field name equal to the filename:

```
Content-Disposition: form-data; name="spec-bom.txt"; filename="spec-bom.txt"
Content-Type: text/plain

<file bytes>
```

Common defaults like `name="file"` will fail with:

```
File type '' is not allowed. Accepted types: txt, mp3, mp4, wav, ...
```

The field name has no extension, so the server sees an empty file type.

Supported file extensions: `.txt`, `.mp3`, `.mp4`, `.mp2`, `.aac`, `.wav`, `.flac`, `.pcm`, `.m4a`, `.ogg`, `.opus`, `.webm`, `.mov`, `.mpeg`, `.mpg`.

## Response

### Success

A `200` response returns the created content item.

<ResponseField name="id" type="integer" required>
  Unique identifier assigned to the new content item. Use this ID for subsequent operations such as deleting the item.
</ResponseField>

<ResponseField name="name" type="string" required>
  Display name for the content item, as provided in the request.
</ResponseField>

<ResponseField name="summary" type="string" required>
  AI-generated summary of the content. May be empty until processing completes.
</ResponseField>

<ResponseField name="status" type="string" required>
  Initial processing status. Typically `Uploaded` immediately after creation.
</ResponseField>

<ResponseField name="sourceType" type="string" required>
  Detected type of the uploaded content (e.g., `Text`, `Audio`, `Video`).
</ResponseField>

<ResponseField name="createdDate" type="string" required>
  ISO 8601 timestamp of when the content item was created.
</ResponseField>

<ResponseField name="userCreated" type="object" required>
  The user who performed the upload.

  <Expandable title="User properties">
    <ResponseField name="id" type="integer">User ID.</ResponseField>
    <ResponseField name="firstName" type="string">First name.</ResponseField>
    <ResponseField name="lastName" type="string">Last name.</ResponseField>
    <ResponseField name="email" type="string">Email address.</ResponseField>
  </Expandable>
</ResponseField>

### Error codes

| Code | Meaning                                                                                                                                                                                                 |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400  | Validation failure — missing content name, invalid content type, file too large, or no file part included (`No file was provided in the request.`).                                                     |
| 400  | File type detection failed (`File type '' is not allowed.`) — typically caused by a multipart field name that does not include the file extension. See [File part naming](#file-part-naming-important). |
| 401  | Not authorized — invalid credentials or incorrect HMAC signature.                                                                                                                                       |
| 403  | Forbidden — your account does not have permission for this API call.                                                                                                                                    |
| 404  | Proposal not found.                                                                                                                                                                                     |
| 409  | Conflict — the proposal is in a state that does not allow editing.                                                                                                                                      |

## Example

Each example uploads a single text file named `spec-bom.txt`. Substitute the credentials, the proposal ID, and the file path as appropriate.

<CodeGroup>
  ```bash curl theme={null}
  # Pre-build the multipart body with a fixed boundary so the same boundary
  # is used for signing and on the wire.

  BASE_URL="https://api.portal.io"
  APP_ID="YOUR_APP_ID"
  SECRET_KEY="YOUR_SECRET_KEY"
  USER_KEY="YOUR_USER_KEY"
  PROPOSAL_ID="12345"
  FILE_PATH="./spec-bom.txt"
  FILE_NAME="spec-bom.txt"
  CONTENT_NAME="Project BOM"

  URL_PATH="/public/api/proposals/${PROPOSAL_ID}/ai/content"
  URL="${BASE_URL}${URL_PATH}"
  BOUNDARY="PortalBoundary$(date +%s%N)"
  CONTENT_TYPE="multipart/form-data; boundary=${BOUNDARY}"
  DATE_HEADER="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S GMT')"

  # Canonical message: METHOD + base URL (no query) + content type + date + user key
  MESSAGE="POST${URL}${CONTENT_TYPE}${DATE_HEADER}${USER_KEY}"
  SIGNATURE=$(printf '%s' "$MESSAGE" \
    | openssl dgst -sha256 -hmac "$SECRET_KEY" -binary \
    | base64)

  # Build multipart body. Note: the file part's field name equals the filename.
  BODY_FILE=$(mktemp)
  {
    printf -- '--%s\r\n' "$BOUNDARY"
    printf 'Content-Disposition: form-data; name="name"\r\n\r\n'
    printf '%s\r\n' "$CONTENT_NAME"

    printf -- '--%s\r\n' "$BOUNDARY"
    printf 'Content-Disposition: form-data; name="isMultiChunkUpload"\r\n\r\n'
    printf 'false\r\n'

    printf -- '--%s\r\n' "$BOUNDARY"
    printf 'Content-Disposition: form-data; name="%s"; filename="%s"\r\n' "$FILE_NAME" "$FILE_NAME"
    printf 'Content-Type: text/plain\r\n\r\n'
    cat "$FILE_PATH"
    printf '\r\n'

    printf -- '--%s--\r\n' "$BOUNDARY"
  } > "$BODY_FILE"

  curl -i -X POST "$URL" \
    -H "Accept: application/json" \
    -H "Content-Type: ${CONTENT_TYPE}" \
    -H "X-MSS-API-APPID: ${APP_ID}" \
    -H "X-MSS-API-USERKEY: ${USER_KEY}" \
    -H "X-MSS-CUSTOM-DATE: ${DATE_HEADER}" \
    -H "X-MSS-SIGNATURE: ${SIGNATURE}" \
    --data-binary "@${BODY_FILE}"

  rm "$BODY_FILE"
  ```

  ```python python theme={null}
  import time
  from email.utils import formatdate

  import requests
  from requests_toolbelt.multipart.encoder import MultipartEncoder

  from portal_auth import sign_request

  base_url = "https://api.portal.io"
  proposal_id = 12345
  file_path = "./spec-bom.txt"
  file_name = "spec-bom.txt"
  content_name = "Project BOM"

  url = f"{base_url}/public/api/proposals/{proposal_id}/ai/content"

  with open(file_path, "rb") as fh:
      file_bytes = fh.read()

  # requests_toolbelt's MultipartEncoder lets us fix the boundary at construction
  # time so we can sign the exact Content-Type value sent on the wire.
  encoder = MultipartEncoder(
      fields={
          "name": content_name,
          "isMultiChunkUpload": "false",
          # Field name == filename. The server reads the file type from this name.
          file_name: (file_name, file_bytes, "text/plain"),
      },
      boundary=f"PortalBoundary{int(time.time() * 1000)}",
  )

  content_type = encoder.content_type  # includes "; boundary=..."
  timestamp = formatdate(timeval=None, localtime=False, usegmt=True)
  signature = sign_request("POST", url, content_type, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")

  response = requests.post(
      url,
      data=encoder,
      headers={
          "Accept": "application/json",
          "Content-Type": content_type,
          "X-MSS-API-APPID": "YOUR_APP_ID",
          "X-MSS-API-USERKEY": "YOUR_USER_KEY",
          "X-MSS-CUSTOM-DATE": timestamp,
          "X-MSS-SIGNATURE": signature,
      },
  )
  print(response.json())
  ```

  ```javascript node.js theme={null}
  const fs = require('node:fs');
  const { request } = require('node:https');
  const { URL } = require('node:url');
  const { signRequest } = require('./portalAuth');

  const baseUrl = 'https://api.portal.io';
  const proposalId = 12345;
  const filePath = './spec-bom.txt';
  const fileName = 'spec-bom.txt';
  const contentName = 'Project BOM';

  const url = `${baseUrl}/public/api/proposals/${proposalId}/ai/content`;

  function buildMultipartBody({ boundary, fields, fileFieldName, fileName, fileContent }) {
    const chunks = [];
    for (const [name, value] of Object.entries(fields)) {
      chunks.push(Buffer.from(
        `--${boundary}\r\n` +
        `Content-Disposition: form-data; name="${name}"\r\n\r\n` +
        `${value}\r\n`
      ));
    }
    chunks.push(Buffer.from(
      `--${boundary}\r\n` +
      `Content-Disposition: form-data; name="${fileFieldName}"; filename="${fileName}"\r\n` +
      `Content-Type: text/plain\r\n\r\n`
    ));
    chunks.push(fileContent);
    chunks.push(Buffer.from(`\r\n--${boundary}--\r\n`));
    return Buffer.concat(chunks);
  }

  async function uploadAiContent() {
    const boundary = `PortalBoundary${Date.now()}`;
    const fileContent = fs.readFileSync(filePath);

    const body = buildMultipartBody({
      boundary,
      fields: { name: contentName, isMultiChunkUpload: 'false' },
      // Field name == filename. The server reads the file type from this name.
      fileFieldName: fileName,
      fileName,
      fileContent,
    });

    const contentType = `multipart/form-data; boundary=${boundary}`;
    const timestamp = new Date().toUTCString();
    const signature = signRequest('POST', url, contentType, timestamp, 'YOUR_USER_KEY', 'YOUR_SECRET_KEY');

    const parsed = new URL(url);
    return new Promise((resolve, reject) => {
      const req = request(
        {
          method: 'POST',
          hostname: parsed.hostname,
          path: parsed.pathname,
          headers: {
            'Accept': 'application/json',
            'Content-Type': contentType,
            'Content-Length': body.length,
            'X-MSS-API-APPID': 'YOUR_APP_ID',
            'X-MSS-API-USERKEY': 'YOUR_USER_KEY',
            'X-MSS-CUSTOM-DATE': timestamp,
            'X-MSS-SIGNATURE': signature,
          },
        },
        (res) => {
          const parts = [];
          res.on('data', (c) => parts.push(c));
          res.on('end', () => {
            const text = Buffer.concat(parts).toString('utf8');
            if (res.statusCode >= 200 && res.statusCode < 300) resolve(JSON.parse(text));
            else reject(new Error(`HTTP ${res.statusCode}: ${text}`));
          });
        },
      );
      req.on('error', reject);
      req.write(body);
      req.end();
    });
  }

  uploadAiContent().then(console.log).catch(console.error);
  ```
</CodeGroup>

```json Response theme={null}
{
  "id": 42,
  "name": "Project BOM",
  "summary": "",
  "status": "Uploaded",
  "sourceType": "Text",
  "createdDate": "2026-04-17T12:00:00Z",
  "userCreated": {
    "id": 7,
    "firstName": "Jane",
    "lastName": "Smith",
    "email": "jane.smith@example.com"
  }
}
```

## Multi-chunk upload

For files larger than **25 MB**, switch to multi-chunk mode. The maximum supported file size is **1 GB**. There is no separate initiate or complete call — every chunk is sent as a multipart file part in a single request.

### URL

```
POST /public/api/proposals/{ProposalId}/ai/content?isMultiChunkUpload=true
```

<Note>
  As with all Portal.io endpoints, the HMAC canonical message uses the base URL **without** the query string. The `?isMultiChunkUpload=true` is sent on the wire but not included in the signing input. See [Sign Portal.io API requests](/authentication/signing-requests) for the canonical rules.
</Note>

### Required form fields

| Field         | Value                                                |
| ------------- | ---------------------------------------------------- |
| `name`        | Display name for the content item.                   |
| `proposalId`  | Numeric proposal ID. Must match the path parameter.  |
| `totalChunks` | Total number of chunk parts included in the request. |

### Chunk file parts

Include each chunk as a separate multipart file part. Use the original filename plus a `.partN` suffix as the field name so the extension is preserved:

```
name="spec-bom.txt.part1"
name="spec-bom.txt.part2"
name="spec-bom.txt.part3"
```

### Recommended chunk sizes

| File size      | Recommended chunk size |
| -------------- | ---------------------- |
| 25 MB – 500 MB | 8 MB                   |
| 500 MB – 1 GB  | 16 MB                  |

<Warning>
  Non-final chunks must be at least 5 MB. This is an S3 multipart upload constraint — chunks smaller than 5 MB (other than the last one) will be rejected.
</Warning>
