# Use Portal.io in Your AI Tool
Source: https://docs.portal.io/ai-tools
Connect Portal.io's documentation to Claude, ChatGPT, Cursor, and other AI tools using MCP. Get accurate API help directly in the assistant you already use.
The Portal.io documentation is available as a **Model Context Protocol (MCP) server**. If your AI tool supports MCP, you can connect it to Portal.io and ask questions about the API, generate integration code, troubleshoot errors, and look up endpoint details — without leaving your editor or chat.
Your AI tool searches Portal.io's documentation on demand and reads full pages when it needs more detail. The result is responses grounded in the current docs rather than guesses from the model's training data.
## What you can do with it
Once connected, ask your AI tool questions like:
* *"How do I authenticate to the Portal.io API in Python?"*
* *"Generate a webhook subscription that triggers on proposal status change."*
* *"What query parameters does the list-proposals endpoint accept?"*
* *"Walk me through creating a proposal with multiple areas and options."*
The tool will retrieve the relevant pages from docs.portal.io and use them to answer.
This is different from the **AI Builder** endpoints in the API. AI Builder is a Portal.io feature for generating proposals. This page is about connecting *external* AI tools (Claude, ChatGPT, Cursor, etc.) to Portal.io's documentation.
## The connection URL
```
https://docs.portal.io/mcp
```
That's all you need. Anonymous access; no authentication required.
## Set up your AI tool
1. Open **Claude Desktop**.
2. Open **Settings** (⌘`,` on macOS, Ctrl `,` on Windows).
3. Go to **Connectors**.
4. Click **Add custom connector**.
5. Set:
* **Name**: `Portal.io`
* **URL**: `https://docs.portal.io/mcp`
6. Click **Add**.
Start a new chat and ask a Portal.io API question. Claude will use the connector automatically when relevant.
1. Sign in to [claude.ai](https://claude.ai).
2. Open **Settings** → **Connectors**.
3. Click **Add custom connector**.
4. Set:
* **Name**: `Portal.io`
* **URL**: `https://docs.portal.io/mcp`
5. Click **Add**.
To use the connector in a chat, click the **attachments button** (the plus icon next to the message input), then select **Portal.io** from your connectors list.
Custom MCP connectors on Claude.ai require a paid plan (Pro, Max, Team, or Enterprise).
**Fastest setup**: open any page on [docs.portal.io](https://docs.portal.io), click the contextual menu at the top of the page, and select **Connect to Cursor**. The connector installs in one click.
**Manual setup**:
1. In Cursor, press ⌘+Shift+P (Ctrl+Shift+P on Windows) to open the command palette.
2. Search for **Open MCP settings**.
3. Click **Add custom MCP**. This opens your `mcp.json` file.
4. Add:
```json theme={null}
{
"mcpServers": {
"Portal.io": {
"url": "https://docs.portal.io/mcp"
}
}
}
```
5. Save the file.
In any Cursor chat or composer panel, ask a Portal.io-related question — Cursor will use the connector.
**Fastest setup**: open any page on [docs.portal.io](https://docs.portal.io), click the contextual menu at the top of the page, and select **Connect to VS Code**.
**Manual setup**: create a `.vscode/mcp.json` file in your workspace and add:
```json theme={null}
{
"servers": {
"Portal.io": {
"type": "http",
"url": "https://docs.portal.io/mcp"
}
}
}
```
Restart your MCP-aware AI extension (Copilot Chat, Continue, etc.) so it picks up the new server.
If you use [Claude Code](https://docs.claude.com/claude-code), add the connector with one command:
```bash theme={null}
claude mcp add --transport http portal-io https://docs.portal.io/mcp
```
Verify it's connected:
```bash theme={null}
claude mcp list
```
You should see `portal-io` in the list. Ask Portal.io questions in any Claude Code session and the docs will be available as a tool.
ChatGPT's MCP support is available through **Custom Connectors** on ChatGPT Business, Enterprise, and Edu plans:
1. Open **ChatGPT Settings**.
2. Go to **Connectors** → **Add custom connector**.
3. Set:
* **Name**: `Portal.io`
* **URL**: `https://docs.portal.io/mcp`
4. Click **Add**.
For ChatGPT Plus and Free, native MCP support is rolling out gradually. As an alternative, you can connect Portal.io to Claude Desktop or Cursor and use those tools for Portal.io-specific work.
1. Open **Windsurf**.
2. Open **Cascade** → **MCP Servers**.
3. Click **Add custom server**.
4. Set:
* **Name**: `portal-io`
* **URL**: `https://docs.portal.io/mcp`
5. Save.
## Try it out
Once connected, try these prompts to confirm the connector is working and to see what it can do:
*"How do I authenticate to the Portal.io API? Walk me through getting credentials and signing a request."*
Your AI tool should retrieve the authentication overview and signing-requests guide, then explain HMAC signing with example code.
*"Write a Node.js Express endpoint that receives a Portal.io webhook for proposal status changes, validates the signature, and logs the payload."*
Your AI tool should pull the webhook documentation and event payload schemas, then generate working code.
*"What query parameters does GET /public/proposals support, and what does the response look like?"*
Your AI tool should retrieve the list-proposals reference and summarize the parameters, defaults, and response shape.
*"I want to build a workflow that creates a proposal, adds two areas with options, and assigns it to a contact. Outline the API calls in order with example payloads."*
Your AI tool should pull the relevant endpoint references and concept pages to plan the sequence.
## Troubleshooting
Some AI tools require you to explicitly invoke the connector. Try prefixing your prompt with the connector name (`@Portal.io`, `@portal-io`) or asking a question that's clearly about Portal.io.
Also confirm the connector was actually added — open your tool's connector list and verify Portal.io appears.
Check the URL is `https://docs.portal.io/mcp` exactly — no trailing slash, no extra path segments.
If your tool requires you to choose a transport, select `http` (not `stdio`).
The MCP endpoint is auto-generated from the live documentation at docs.portal.io. If you see incorrect information, the underlying docs likely need updating — please [file an issue](https://portal.canny.io/developer-api) so we can fix the source.
## Feedback
This integration is new — your feedback shapes what we improve. If your AI tool isn't listed here, or you'd like a better experience with a particular tool, [let us know](https://portal.canny.io/developer-api).
# Build a Proposal from an AI-Generated Outline
Source: https://docs.portal.io/api-reference/ai-builder/build-proposal
POST /public/proposals/{ProposalId}/ai/build
POST /public/proposals/{ProposalId}/ai/build — Triggers an async AI proposal build from a completed outline. Returns proposalId and initial build status.
Triggers an asynchronous AI build for the specified proposal, using its completed AI outline as the source. The proposal must have a `Completed` outline before you call this endpoint — if no completed outline exists, the request is rejected with a `400` error.
The build runs asynchronously. The response confirms the build has been queued, but the proposal content will not be ready immediately. Listen for the `Proposal Build Status Changed` webhook to know when the build finishes, or poll the proposal status directly.
```bash curl theme={null}
curl -i -X POST \
'https://api.portal.io/public/proposals/12345/ai/build' \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-H 'X-MSS-API-APPID: YOUR_API_APPID' \
-H 'X-MSS-API-USERKEY: YOUR_API_USERKEY' \
-H 'X-MSS-CUSTOM-DATE: Thu, 17 Apr 2026 12:00:00 GMT' \
-H 'X-MSS-SIGNATURE: Base64EncodedHMACSHA256Signature' \
-d '{"proposalId": 12345}'
```
```python python theme={null}
import requests
from portal_auth import sign_request
url = "https://api.portal.io/public/proposals/12345/ai/build"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
content_type = "application/json"
signature = sign_request("POST", url, content_type, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.post(url, 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
}, json={
"proposalId": 12345
})
print(response.json())
```
```javascript node.js theme={null}
const { signRequest } = require('./portalAuth');
const url = "https://api.portal.io/public/proposals/12345/ai/build";
const timestamp = new Date().toUTCString();
const contentType = "application/json";
const signature = signRequest("POST", url, contentType, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": contentType,
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
},
body: JSON.stringify({ proposalId: 12345 })
});
const data = await response.json();
console.log(data);
```
```json Response theme={null}
{
"proposalId": 12345,
"status": "Building"
}
```
# Delete AI Builder Content from a Proposal
Source: https://docs.portal.io/api-reference/ai-builder/delete-content
DELETE /public/proposals/{ProposalId}/ai/content/{ContentId}
DELETE /public/proposals/{ProposalId}/ai/content/{ContentId} — Removes an AI Builder content item from a proposal. Proposal must be in Draft status.
Removes a specific AI Builder content item from a proposal. Use the `id` returned when you uploaded the content, or retrieve it from the [list content](/api-reference/ai-builder/list-content) endpoint. The proposal must be in Draft status to allow deletions.
```bash curl theme={null}
curl -i -X DELETE \
'https://api.portal.io/public/proposals/12345/ai/content/42' \
-H 'Accept: application/json' \
-H 'X-MSS-API-APPID: YOUR_API_APPID' \
-H 'X-MSS-API-USERKEY: YOUR_API_USERKEY' \
-H 'X-MSS-CUSTOM-DATE: Thu, 17 Apr 2026 12:00:00 GMT' \
-H 'X-MSS-SIGNATURE: Base64EncodedHMACSHA256Signature'
```
```python python theme={null}
import requests
from portal_auth import sign_request # See authentication guide
url = "https://api.portal.io/public/proposals/12345/ai/content/42"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
signature = sign_request("DELETE", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.delete(url, headers={
"Accept": "application/json",
"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 { signRequest } = require('./portalAuth'); // See authentication guide
const url = "https://api.portal.io/public/proposals/12345/ai/content/42";
const timestamp = new Date().toUTCString();
const signature = signRequest("DELETE", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
method: "DELETE",
headers: {
"Accept": "application/json",
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
}
});
const data = await response.json();
console.log(data);
```
```json Response theme={null}
{
"id": 42,
"deleted": true
}
```
# Generate an AI Outline for a Proposal
Source: https://docs.portal.io/api-reference/ai-builder/generate-outline
POST /public/proposals/{ProposalId}/ai/outline
POST .../ai/outline — Starts async AI outline generation from uploaded content. Returns proposalId and initial status. Requires transcribed content.
Starts asynchronous AI outline generation for the specified proposal. The proposal must contain transcribed project spec content or media transcripts — if no qualifying content is present, the request returns a `400` error. Once triggered, use [Get Proposal Outline](/api-reference/ai-builder/get-outline) to check progress or listen for the `Proposal Outline Status Changed` webhook.
Outline generation is asynchronous. The `200` response confirms the job has been queued, but the outline will not be available immediately. Poll [Get Proposal Outline](/api-reference/ai-builder/get-outline) and check for `status: "Completed"`, or subscribe to the `Proposal Outline Status Changed` webhook.
```bash curl theme={null}
curl -i -X POST \
'https://api.portal.io/public/proposals/12345/ai/outline' \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-H 'X-MSS-API-APPID: YOUR_API_APPID' \
-H 'X-MSS-API-USERKEY: YOUR_API_USERKEY' \
-H 'X-MSS-CUSTOM-DATE: Thu, 17 Apr 2026 12:00:00 GMT' \
-H 'X-MSS-SIGNATURE: Base64EncodedHMACSHA256Signature' \
-d '{"proposalId": 12345}'
```
```python python theme={null}
import requests
from portal_auth import sign_request
url = "https://api.portal.io/public/proposals/12345/ai/outline"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
content_type = "application/json"
signature = sign_request("POST", url, content_type, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.post(url, 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
}, json={
"proposalId": 12345
})
print(response.json())
```
```javascript node.js theme={null}
const { signRequest } = require('./portalAuth');
const url = "https://api.portal.io/public/proposals/12345/ai/outline";
const timestamp = new Date().toUTCString();
const contentType = "application/json";
const signature = signRequest("POST", url, contentType, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": contentType,
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
},
body: JSON.stringify({ proposalId: 12345 })
});
const data = await response.json();
console.log(data);
```
```json Response theme={null}
{
"proposalId": 12345,
"status": "Generating"
}
```
# Get the AI-Generated Outline for a Proposal
Source: https://docs.portal.io/api-reference/ai-builder/get-outline
GET /public/proposals/{ProposalId}/ai/outline
GET .../ai/outline — Returns the AI-generated outline for a proposal. Status is Generating or Completed; the outline text is only present when Completed.
Returns the latest AI-generated outline for the specified proposal. The `status` field indicates whether generation is still in progress (`Generating`) or finished (`Completed`). The `outline` field is only populated once the status reaches `Completed`.
Outline generation is asynchronous. After calling [Generate Proposal Outline](/api-reference/ai-builder/generate-outline), poll this endpoint or listen for the `Proposal Outline Status Changed` webhook to know when the outline is ready.
```bash curl theme={null}
curl -i -X GET \
'https://api.portal.io/public/proposals/12345/ai/outline' \
-H 'Accept: application/json' \
-H 'X-MSS-API-APPID: YOUR_API_APPID' \
-H 'X-MSS-API-USERKEY: YOUR_API_USERKEY' \
-H 'X-MSS-CUSTOM-DATE: Thu, 17 Apr 2026 12:00:00 GMT' \
-H 'X-MSS-SIGNATURE: Base64EncodedHMACSHA256Signature'
```
```python python theme={null}
import requests
from portal_auth import sign_request # See authentication guide
url = "https://api.portal.io/public/proposals/12345/ai/outline"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
signature = sign_request("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.get(url, headers={
"Accept": "application/json",
"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 { signRequest } = require('./portalAuth'); // See authentication guide
const url = "https://api.portal.io/public/proposals/12345/ai/outline";
const timestamp = new Date().toUTCString();
const signature = signRequest("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
headers: {
"Accept": "application/json",
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
}
});
const data = await response.json();
console.log(data);
```
```json Response (Completed) theme={null}
{
"proposalId": 12345,
"status": "Completed",
"outline": "## Executive Summary\nThis proposal covers the development of...\n\n## Scope of Work\n..."
}
```
```json Response (Generating) theme={null}
{
"proposalId": 12345,
"status": "Generating",
"outline": null
}
```
# List AI Builder Content for a Proposal
Source: https://docs.portal.io/api-reference/ai-builder/list-content
GET /public/proposals/{ProposalId}/ai/content
GET /public/proposals/{ProposalId}/ai/content — Returns all AI Builder content items attached to a proposal, including processing status and metadata.
Returns all AI Builder content items (text, audio, and video uploads) attached to a proposal. Each item includes its processing status and metadata. Use this endpoint to check which content has been uploaded and whether transcription is complete before generating an outline.
```bash curl theme={null}
curl -i -X GET \
'https://api.portal.io/public/proposals/12345/ai/content' \
-H 'Accept: application/json' \
-H 'X-MSS-API-APPID: YOUR_API_APPID' \
-H 'X-MSS-API-USERKEY: YOUR_API_USERKEY' \
-H 'X-MSS-CUSTOM-DATE: Thu, 17 Apr 2026 12:00:00 GMT' \
-H 'X-MSS-SIGNATURE: Base64EncodedHMACSHA256Signature'
```
```python python theme={null}
import requests
from portal_auth import sign_request # See authentication guide
url = "https://api.portal.io/public/proposals/12345/ai/content"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
signature = sign_request("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.get(url, headers={
"Accept": "application/json",
"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 { signRequest } = require('./portalAuth'); // See authentication guide
const url = "https://api.portal.io/public/proposals/12345/ai/content";
const timestamp = new Date().toUTCString();
const signature = signRequest("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
headers: {
"Accept": "application/json",
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
}
});
const data = await response.json();
console.log(data);
```
```json Response theme={null}
{
"items": [
{
"id": 42,
"name": "Project Discovery Call",
"summary": "Client discussed requirements for a new e-commerce platform...",
"status": "Transcribed",
"sourceType": "Audio",
"createdDate": "2026-04-10T09:30:00Z",
"userCreated": {
"id": 7,
"firstName": "Jane",
"lastName": "Smith",
"email": "jane.smith@example.com"
}
}
]
}
```
# Upload AI Builder Content to a Proposal
Source: https://docs.portal.io/api-reference/ai-builder/upload-content
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.
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.`
## Request
**`POST /public/api/proposals/{ProposalId}/ai/content`**
### Headers
Must be `application/json`.
Must be `multipart/form-data; 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).
Your API Application Key.
Your User API Key.
Current UTC timestamp in RFC 7231 format.
HMAC-SHA256 signature, Base64-encoded.
### Path parameters
Numeric identifier of the proposal to which the AI content will be attached. The proposal must be in Draft status to accept new content.
### Form fields
The body is a `multipart/form-data` payload containing the following parts.
Human-readable name or title for the content being uploaded. Used for display and search. Provide a concise, descriptive name.
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).
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.
### 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
```
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.
Unique identifier assigned to the new content item. Use this ID for subsequent operations such as deleting the item.
Display name for the content item, as provided in the request.
AI-generated summary of the content. May be empty until processing completes.
Initial processing status. Typically `Uploaded` immediately after creation.
Detected type of the uploaded content (e.g., `Text`, `Audio`, `Video`).
ISO 8601 timestamp of when the content item was created.
The user who performed the upload.
User ID.
First name.
Last name.
Email address.
### 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.
```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);
```
```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
```
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.
### 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 |
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.
# Exchange Credentials for a User API Key
Source: https://docs.portal.io/api-reference/authentication/exchange-api-key
GET /authenticate/apikeyexchange
GET /authenticate/apikeyexchange — Trade your Portal.io username and password for a User API Key required for all subsequent HMAC-authenticated requests.
The API key exchange endpoint authenticates your Portal.io credentials and returns a `meta.apiKey` value you must include in the `X-MSS-API-USERKEY` header on all subsequent requests. This is the entry point for every integration: call it once to obtain the key, then use that key to sign all other requests.
For the initial exchange, `X-MSS-API-USERKEY` must be an empty string and is **excluded** from the HMAC canonical message. The canonical message is: `[HTTP method][base URL without query params][timestamp]` — no content-type (GET request) and no user key. See the [signing guide](/authentication/signing-requests#get-request-credential-exchange) for a worked example.
```bash curl theme={null}
curl -i -X GET \
"https://sandbox.api.portal.io/authenticate/apikeyexchange?UserName=user%40example.com&Password=MyP%40ss123" \
-H "Accept: application/json" \
-H "X-MSS-API-APPID: YOUR_APP_ID" \
-H "X-MSS-API-USERKEY: " \
-H "X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT" \
-H "X-MSS-SIGNATURE: BASE64_SIGNATURE"
```
```python python theme={null}
import requests
from portal_auth import sign_request # See authentication guide
url = "https://sandbox.api.portal.io/authenticate/apikeyexchange?UserName=user%40example.com&Password=MyP%40ss123"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
signature = sign_request("GET", url, "", timestamp, "", "YOUR_SECRET_KEY")
response = requests.get(url, headers={
"Accept": "application/json",
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
})
print(response.json())
```
```javascript node.js theme={null}
const { signRequest } = require('./portalAuth'); // See authentication guide
const url = "https://sandbox.api.portal.io/authenticate/apikeyexchange?UserName=user%40example.com&Password=MyP%40ss123";
const timestamp = new Date().toUTCString();
const signature = signRequest("GET", url, "", timestamp, "", "YOUR_SECRET_KEY");
const response = await fetch(url, {
headers: {
"Accept": "application/json",
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
}
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"userId": "string",
"sessionId": "string",
"userName": "string",
"displayName": "string",
"bearerToken": "string",
"refreshToken": "string",
"refreshTokenExpiry": "2026-04-06T00:22:19Z",
"profileUrl": "string",
"roles": ["string"],
"permissions": ["string"],
"authProvider": "string",
"meta": {
"apiKey": "YOUR_USER_API_KEY"
}
}
```
# Get Catalog Item Details
Source: https://docs.portal.io/api-reference/catalog/get-item
GET /public/catalog/{ItemId}
GET /public/catalog/{ItemId} — Returns a catalog item's details including pricing and supplier info. Pass ExtendedDetails=true for specs, PDFs, and videos.
Returns the complete record for a single catalog item. By default this includes pricing, category data, and supplier information. Pass `ExtendedDetails=true` to also receive the full long description, technical specifications, linked PDF and video resources, and additional image URLs. When `ItemType` is `Labor` or `CustomItem`, the item is sourced from your account's private library rather than the shared catalog.
The Catalog endpoints require separate authorization. Contact your Portal.io representative to confirm your account has catalog API access enabled.
A `204` response (not `404`) is returned when the `ItemId` does not match any catalog item. Check for this status code in your error handling logic.
```bash curl theme={null}
curl -i -X GET \
'https://api.portal.io/public/catalog/8821?ExtendedDetails=true' \
-H 'Accept: application/json' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE'
```
```python python theme={null}
import requests
from portal_auth import sign_request # See authentication guide
url = "https://api.portal.io/public/catalog/8821?ExtendedDetails=true"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
signature = sign_request("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.get(url, headers={
"Accept": "application/json",
"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 { signRequest } = require('./portalAuth'); // See authentication guide
const url = "https://api.portal.io/public/catalog/8821?ExtendedDetails=true";
const timestamp = new Date().toUTCString();
const signature = signRequest("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
headers: {
"Accept": "application/json",
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
}
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"description": "The AVR-X3800H is a 9.4-channel, 105W AV receiver with 8K video support...",
"productUrl": "https://portal.io/catalog/8821",
"manufacturerProductUrl": "https://www.denon.com/avr-x3800h",
"additionalImageUrls": [
"https://images.portal.io/catalog/8821/angle.jpg"
],
"pdfResourceLinks": [
{ "name": "Owner's Manual", "url": "https://docs.denon.com/avr-x3800h-manual.pdf" }
],
"videoResourceLinks": [],
"specs": [
{ "name": "Channels", "value": "9.4" },
{ "name": "Power Output", "value": "105W per channel" }
],
"suppliers": [
{
"id": 5,
"name": "D&H Distributing",
"cost": {
"value": 975.00,
"isPromo": false,
"isInStock": true,
"unitOfMeasure": "Each",
"discountPercentage": 0,
"currency": { "code": "USD", "symbol": "$" },
"lastVerifiedDate": "2026-04-01T00:00:00Z"
}
}
],
"id": 8821,
"brand": "Denon",
"model": "AVR-X3800H",
"shortDescription": "9.4-Channel 105W 8K AV Receiver",
"primaryImageUrl": "https://images.portal.io/catalog/8821/primary.jpg",
"parentCategoryId": 12,
"categoryId": 47,
"categories": ["Audio", "AV Receivers"],
"isFavorite": false,
"isDiscontinued": false,
"rank": 1,
"msrp": {
"msrpUsd": 1299.00,
"value": 1299.00,
"regularValue": 1299.00,
"isCustom": false,
"currency": { "code": "USD", "symbol": "$" },
"lastModifiedDate": "2026-01-15T00:00:00Z"
},
"defaultCost": {
"supplierName": "D&H Distributing",
"supplierSku": "AVR-X3800H",
"managePriceStatus": "Activated",
"accountNumber": "DH-12345",
"isInStock": true,
"isPromo": false,
"lastVerifiedDate": "2026-04-01T00:00:00Z",
"unitOfMeasure": "Each",
"discountPercentage": 0,
"regularValue": 975.00,
"isCustom": false,
"value": 975.00,
"lastModifiedDate": "2026-04-01T00:00:00Z",
"currency": { "code": "USD", "symbol": "$" }
},
"sellPrice": {
"type": "CostMultiplier",
"costMultiplier": 1.4,
"value": 1365.00,
"lastModifiedDate": "2026-03-01T00:00:00Z",
"currency": { "code": "USD", "symbol": "$" }
}
}
```
# List Catalog Categories
Source: https://docs.portal.io/api-reference/catalog/list-categories
GET /public/catalog/categories
GET /public/catalog/categories — Returns the full catalog category hierarchy grouped by industry, including sub-categories and image URLs where available.
Retrieves the complete category tree from the Portal.io catalog. The response is an array of top-level industry objects, each containing a nested `categories` array with their child categories and optional image URLs. Sub-categories may themselves contain additional `subCategories` arrays. Use the `id` values from this response as `CategoryId` or `ParentCategoryId` filters when searching catalog items.
The Catalog endpoints require separate authorization. Contact your Portal.io representative to confirm your account has catalog API access enabled.
```bash curl theme={null}
curl -i -X GET \
'https://api.portal.io/public/catalog/categories' \
-H 'Accept: application/json' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE'
```
```python python theme={null}
import requests
from portal_auth import sign_request # See authentication guide
url = "https://api.portal.io/public/catalog/categories"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
signature = sign_request("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.get(url, headers={
"Accept": "application/json",
"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 { signRequest } = require('./portalAuth'); // See authentication guide
const url = "https://api.portal.io/public/catalog/categories";
const timestamp = new Date().toUTCString();
const signature = signRequest("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
headers: {
"Accept": "application/json",
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
}
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
[
{
"id": 12,
"name": "Audio",
"categories": [
{
"id": 47,
"name": "AV Receivers",
"imageUrl": "https://images.portal.io/categories/47.jpg",
"subCategories": []
},
{
"id": 48,
"name": "Amplifiers",
"imageUrl": null,
"subCategories": []
}
]
},
{
"id": 13,
"name": "Video",
"categories": [
{
"id": 60,
"name": "Displays",
"imageUrl": "https://images.portal.io/categories/60.jpg",
"subCategories": [
{
"id": 61,
"name": "4K Displays",
"imageUrl": null,
"subCategories": []
}
]
}
]
}
]
```
# Search Catalog Items in Your Account
Source: https://docs.portal.io/api-reference/catalog/search-items
GET /public/catalog
GET /public/catalog — Search catalog items by text, category, brand, and stock status. Returns items with pricing, images, and category hierarchy.
The Catalog search endpoint provides full access to the Portal.io catalog with rich filtering options. You can narrow results by free-text search, category hierarchy (`CategoryId`, `CategoryIds`, `ParentCategoryId`, `ParentCategoryIds`), brand, supplier, industry, price range, stock availability, favorites, company-approved items, and item type. `CategoryId` and `CategoryIds` are mutually exclusive, as are `ParentCategoryId` and `ParentCategoryIds`. When `SearchText` is omitted it is treated as an empty string. `ItemType` defaults to `Part`. Set `IsCompanyApproved=true` to return only items your company has approved. It composes with the other filters. Combined with `IsFavorite=true`, it returns items that are both a favorite and company-approved.
The Catalog endpoints require separate authorization. Contact your Portal.io representative to confirm your account has catalog API access enabled.
```bash curl theme={null}
curl -i -X GET \
'https://api.portal.io/public/catalog?SearchText=receiver&PageNumber=1&PageSize=10' \
-H 'Accept: application/json' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE'
```
```python python theme={null}
import requests
from portal_auth import sign_request # See authentication guide
url = "https://api.portal.io/public/catalog?SearchText=receiver&PageNumber=1&PageSize=10"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
signature = sign_request("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.get(url, headers={
"Accept": "application/json",
"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 { signRequest } = require('./portalAuth'); // See authentication guide
const url = "https://api.portal.io/public/catalog?SearchText=receiver&PageNumber=1&PageSize=10";
const timestamp = new Date().toUTCString();
const signature = signRequest("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
headers: {
"Accept": "application/json",
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
}
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"items": [
{
"id": 8821,
"brand": "Denon",
"model": "AVR-X3800H",
"shortDescription": "9.4-Channel 105W 8K AV Receiver",
"primaryImageUrl": "https://images.portal.io/catalog/8821/primary.jpg",
"parentCategoryId": 12,
"categoryId": 47,
"categories": ["Audio", "AV Receivers"],
"isFavorite": false,
"isDiscontinued": false,
"msrp": {
"msrpUsd": 1299.00,
"value": 1299.00,
"regularValue": 1299.00,
"isCustom": false,
"currency": { "code": "USD", "symbol": "$" },
"lastModifiedDate": "2026-01-15T00:00:00Z"
},
"defaultCost": {
"supplierName": "D&H Distributing",
"supplierSku": "AVR-X3800H",
"value": 975.00,
"isInStock": true,
"isPromo": false,
"currency": { "code": "USD", "symbol": "$" }
}
}
],
"totalItemCount": 1,
"processingTimeMS": 42,
"favoriteItemCount": 0,
"favoriteItems": [],
"categoryFacets": [
{ "value": "AV Receivers", "count": 1 }
],
"brandFacets": [
{ "value": "Denon", "count": 1 }
],
"supplierFacets": [
{ "value": "D&H Distributing", "count": 1 }
],
"labors": [],
"customItems": []
}
```
# Get Change Order Details
Source: https://docs.portal.io/api-reference/change-orders/get-change-order
GET /public/proposals/{ProposalId}/changeorders/{ChangeOrderId}
GET .../changeorders/{ChangeOrderId} — Returns full change order detail including financial summary, areas, line items, and customer info.
Use this endpoint to retrieve the full detail of a specific change order. The response includes the complete financial summary (parts, labor, fees, subtotal, and sales tax), all areas with their options and line items, and customer contact information. The HTTP response also sets the `Last-Modified` header from the change order's `lastModifiedDate`, which you can use for conditional requests.
```bash curl theme={null}
curl -i -X GET \
'https://api.portal.io/public/proposals/123/changeorders/42' \
-H 'Accept: application/json' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE'
```
```python python theme={null}
import requests
from portal_auth import sign_request # See authentication guide
url = "https://api.portal.io/public/proposals/123/changeorders/42"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
signature = sign_request("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.get(url, headers={
"Accept": "application/json",
"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 { signRequest } = require('./portalAuth'); // See authentication guide
const url = "https://api.portal.io/public/proposals/123/changeorders/42";
const timestamp = new Date().toUTCString();
const signature = signRequest("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
headers: {
"Accept": "application/json",
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
}
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"id": 42,
"number": 1,
"name": "Smith Residence AV - Change Order 1",
"status": "Draft",
"createdDate": "2026-04-01T10:00:00Z",
"lastModifiedDate": "2026-04-05T14:30:00Z",
"lastModifiedByUserDate": "2026-04-05T14:30:00Z",
"lastSubmittedDate": null,
"clientLastOpenedDate": null,
"clientLastDecisionDate": null,
"lastCompletedDate": null,
"financialSummary": {
"partsSubtotal": 800.00,
"laborTotal": 350.00,
"feeTotal": 100.00,
"changeOrderSubtotal": 1250.00,
"salesTax": {
"taxStatus": "Ok",
"total": 103.13
}
},
"areas": [
{
"id": 55,
"name": "Living Room",
"options": [
{
"id": 201,
"status": "Draft",
"clientDescription": "Standard Installation Package",
"items": []
}
]
}
],
"customer": {
"id": 101,
"firstName": "Jane",
"lastName": "Smith",
"contactEmail": "jane@example.com",
"contactPhone": "555-867-5309"
},
"dealer": {
"id": 5,
"name": "AV Solutions Inc."
},
"coverpageImageUrl": null,
"aboutUs": null,
"projectDescription": null,
"profit": null,
"recurringServices": null,
"paymentSchedule": null,
"paymentRequests": [],
"projectTerms": null,
"lastModifiedUser": {
"id": 12,
"firstName": "Alex",
"lastName": "Johnson"
}
}
```
# List Change Orders for a Proposal
Source: https://docs.portal.io/api-reference/change-orders/list-change-orders
GET /public/proposals/{ProposalId}/changeorders
GET /public/proposals/{ProposalId}/changeorders — Returns all change orders for a proposal with status, totals, currency, and customer details.
Use this endpoint to retrieve all change orders associated with a proposal. The response is an array of change order summary objects, each including the change order's status, financial total, currency, customer contact information, and creation and modification timestamps. Use the `id` from this list to fetch full change order details with the Get Change Order endpoint.
```bash curl theme={null}
curl -i -X GET \
'https://api.portal.io/public/proposals/123/changeorders' \
-H 'Accept: application/json' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE'
```
```python python theme={null}
import requests
from portal_auth import sign_request # See authentication guide
url = "https://api.portal.io/public/proposals/123/changeorders"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
signature = sign_request("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.get(url, headers={
"Accept": "application/json",
"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 { signRequest } = require('./portalAuth'); // See authentication guide
const url = "https://api.portal.io/public/proposals/123/changeorders";
const timestamp = new Date().toUTCString();
const signature = signRequest("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
headers: {
"Accept": "application/json",
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
}
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
[
{
"id": 42,
"number": 1,
"name": "Change Order 1",
"status": "Draft",
"total": {
"changeOrderTotal": 1250.00,
"currency": {
"code": "USD",
"symbol": "$"
}
},
"customer": {
"id": 101,
"partyType": "Person",
"contactType": "Client",
"firstName": "Jane",
"lastName": "Smith",
"companyName": null,
"contactEmail": "jane@example.com",
"contactPhone": "555-867-5309"
},
"createdDate": "2026-04-01T10:00:00Z",
"lastModifiedDate": "2026-04-05T14:30:00Z",
"lastModifiedByUserDate": "2026-04-05T14:30:00Z"
}
]
```
# Create a New Contact in Your Account
Source: https://docs.portal.io/api-reference/people/create-contact
POST /public/people
POST /public/people — Creates a contact. PartyType (Person/Company), ContactType (Client/Employee/Contractor/Other), and FirstName are required.
Use this endpoint to add a new contact to your Portal.io dealer account. Contacts are used as the client on proposals. `PartyType`, `ContactType`, and `FirstName` are always required. When `PartyType` is `Company`, you must also supply `CompanyName`. Valid `PartyType` values: `Person`, `Company`. Valid `ContactType` values: `Client`, `Employee`, `Contractor`, `Other`.
The request body must be submitted as `application/x-www-form-urlencoded`. Encode special characters in field values before sending.
```bash curl theme={null}
curl -i -X POST \
'https://api.portal.io/public/people' \
-H 'Accept: application/json' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE' \
-d 'PartyType=Person' \
-d 'ContactType=Client' \
-d 'FirstName=Jane' \
-d 'LastName=Smith' \
-d 'ContactEmail=jane.smith%40example.com' \
-d 'ContactPhone=555-555-0100'
```
```python python theme={null}
import requests
from portal_auth import sign_request
url = "https://api.portal.io/public/people"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
content_type = "application/x-www-form-urlencoded"
signature = sign_request("POST", url, content_type, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.post(url, 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
}, data={
"PartyType": "Person",
"ContactType": "Client",
"FirstName": "Jane",
"LastName": "Smith",
"ContactEmail": "jane.smith@example.com",
"ContactPhone": "555-555-0100"
})
print(response.json())
```
```javascript node.js theme={null}
const { signRequest } = require('./portalAuth');
const url = "https://api.portal.io/public/people";
const timestamp = new Date().toUTCString();
const contentType = "application/x-www-form-urlencoded";
const signature = signRequest("POST", url, contentType, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": contentType,
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
},
body: new URLSearchParams({
PartyType: "Person",
ContactType: "Client",
FirstName: "Jane",
LastName: "Smith",
ContactEmail: "jane.smith@example.com",
ContactPhone: "555-555-0100"
})
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"notes": "",
"lastModifiedDate": "2026-04-06T00:22:19Z",
"primaryLocation": null,
"billingLocation": null,
"proposalCount": 0,
"paymentCount": 0,
"id": 1042,
"partyType": "Person",
"contactType": "Client",
"firstName": "Jane",
"lastName": "Smith",
"companyName": "",
"contactEmail": "jane.smith@example.com",
"contactEmailCC": "",
"contactPhone": "555-555-0100"
}
```
# Add a Location to a Contact
Source: https://docs.portal.io/api-reference/people/create-location
POST /public/people/{ContactId}/location
POST /public/people/{ContactId}/location — Adds an address to a contact. Street is required. When Country is provided, State is also required.
Adds a new address to an existing contact. `Street` is the only required field. When `Country` is supplied, `State` must also be provided. Use `IsPrimary=true` to replace the contact's current primary address, and `IsBilling=true` to set this as the billing address. A contact can have only one primary and one billing location at a time; setting either flag moves it from the previous location.
The request body must be submitted as `application/x-www-form-urlencoded`.
```bash curl theme={null}
curl -i -X POST \
'https://api.portal.io/public/people/1042/location' \
-H 'Accept: application/json' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE' \
-d 'Street=456+Oak+Ave' \
-d 'City=Austin' \
-d 'State=Texas' \
-d 'PostalCode=78702' \
-d 'Country=United+States' \
-d 'IsPrimary=true'
```
```python python theme={null}
import requests
from portal_auth import sign_request
url = "https://api.portal.io/public/people/1042/location"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
content_type = "application/x-www-form-urlencoded"
signature = sign_request("POST", url, content_type, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.post(url, 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
}, data={
"Street": "456 Oak Ave",
"City": "Austin",
"State": "Texas",
"PostalCode": "78702",
"Country": "United States",
"IsPrimary": "true"
})
print(response.json())
```
```javascript node.js theme={null}
const { signRequest } = require('./portalAuth');
const url = "https://api.portal.io/public/people/1042/location";
const timestamp = new Date().toUTCString();
const contentType = "application/x-www-form-urlencoded";
const signature = signRequest("POST", url, contentType, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": contentType,
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
},
body: new URLSearchParams({
Street: "456 Oak Ave",
City: "Austin",
State: "Texas",
PostalCode: "78702",
Country: "United States",
IsPrimary: "true"
})
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"contactFirstName": "",
"contactLastName": "",
"contactPhoneNumber": "",
"contactEmail": "",
"isPrimary": true,
"isBilling": false,
"id": 305,
"street": "456 Oak Ave",
"suite": "",
"city": "Austin",
"postalCode": "78702",
"state": "Texas",
"stateAbbrev": "TX",
"country": "United States",
"phone": ""
}
```
# Get Contact Details by ID
Source: https://docs.portal.io/api-reference/people/get-contact
GET /public/people/{PersonId}
GET /public/people/{PersonId} — Returns full contact details including primary/billing locations and optional proposal and payment counts.
Retrieve the full record for a single contact by their numeric ID. The response always includes `primaryLocation` and `billingLocation` when they exist. Pass `IncludeCounts=true` to also receive the number of proposals and payments associated with the contact — useful for dashboards and sync logic.
```bash curl theme={null}
curl -i -X GET \
'https://api.portal.io/public/people/1042?IncludeCounts=true' \
-H 'Accept: application/json' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE'
```
```python python theme={null}
import requests
from portal_auth import sign_request # See authentication guide
url = "https://api.portal.io/public/people/1042?IncludeCounts=true"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
signature = sign_request("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.get(url, headers={
"Accept": "application/json",
"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 { signRequest } = require('./portalAuth'); // See authentication guide
const url = "https://api.portal.io/public/people/1042?IncludeCounts=true";
const timestamp = new Date().toUTCString();
const signature = signRequest("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
headers: {
"Accept": "application/json",
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
}
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"notes": "Preferred contact via email.",
"lastModifiedDate": "2026-04-06T00:22:19Z",
"primaryLocation": {
"contactFirstName": "Jane",
"contactLastName": "Smith",
"contactPhoneNumber": "555-555-0100",
"contactEmail": "jane.smith@example.com",
"isPrimary": true,
"isBilling": false,
"id": 201,
"street": "123 Main St",
"suite": "",
"city": "Austin",
"postalCode": "78701",
"state": "Texas",
"stateAbbrev": "TX",
"country": "United States",
"phone": "555-555-0100"
},
"billingLocation": null,
"proposalCount": 4,
"paymentCount": 2,
"id": 1042,
"partyType": "Person",
"contactType": "Client",
"firstName": "Jane",
"lastName": "Smith",
"companyName": "",
"contactEmail": "jane.smith@example.com",
"contactEmailCC": "",
"contactPhone": "555-555-0100"
}
```
# Search and List Contacts in Your Account
Source: https://docs.portal.io/api-reference/people/list-contacts
GET /public/people
GET /public/people — Returns a paged list of contacts. Filter by search text or contact type; sort results; defaults to page 1, size 10.
The People endpoint returns all contacts stored in your Portal.io dealer account. You can narrow results with a free-text search, filter by one or more contact types, control sort order, and paginate using `PageNumber` and `PageSize`. When either pagination value is missing or less than or equal to zero, the API defaults to page 1 with 10 results per page.
```bash curl theme={null}
curl -i -X GET \
'https://api.portal.io/public/people?SearchText=Smith&PageNumber=1&PageSize=10' \
-H 'Accept: application/json' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE'
```
```python python theme={null}
import requests
from portal_auth import sign_request # See authentication guide
url = "https://api.portal.io/public/people?SearchText=Smith&PageNumber=1&PageSize=10"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
signature = sign_request("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.get(url, headers={
"Accept": "application/json",
"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 { signRequest } = require('./portalAuth'); // See authentication guide
const url = "https://api.portal.io/public/people?SearchText=Smith&PageNumber=1&PageSize=10";
const timestamp = new Date().toUTCString();
const signature = signRequest("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
headers: {
"Accept": "application/json",
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
}
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"people": [
{
"id": 1042,
"partyType": "Person",
"contactType": "Client",
"firstName": "Jane",
"lastName": "Smith",
"companyName": "",
"contactEmail": "jane.smith@example.com",
"contactEmailCC": "",
"contactPhone": "555-555-0100"
}
],
"peopleCount": 1
}
```
# List Locations for a Contact
Source: https://docs.portal.io/api-reference/people/list-locations
GET /public/people/{ContactId}/location
GET /public/people/{ContactId}/location — Returns paged locations for a contact, ordered: primary first, then billing, then most recently modified.
Retrieves all addresses stored for a specific contact. Results are ordered with the primary location first, the billing location second, and then the remaining locations sorted by most recently modified. Pagination defaults to page 1 with 10 results per page. `PageNumber` must be 1 or greater — values less than 1 return a `400` error.
```bash curl theme={null}
curl -i -X GET \
'https://api.portal.io/public/people/1042/location?PageNumber=1&PageSize=10' \
-H 'Accept: application/json' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE'
```
```python python theme={null}
import requests
from portal_auth import sign_request # See authentication guide
url = "https://api.portal.io/public/people/1042/location?PageNumber=1&PageSize=10"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
signature = sign_request("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.get(url, headers={
"Accept": "application/json",
"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 { signRequest } = require('./portalAuth'); // See authentication guide
const url = "https://api.portal.io/public/people/1042/location?PageNumber=1&PageSize=10";
const timestamp = new Date().toUTCString();
const signature = signRequest("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
headers: {
"Accept": "application/json",
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
}
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"contactLocations": [
{
"contactFirstName": "Jane",
"contactLastName": "Smith",
"contactPhoneNumber": "555-555-0100",
"contactEmail": "jane.smith@example.com",
"isPrimary": true,
"isBilling": false,
"id": 201,
"street": "123 Main St",
"suite": "",
"city": "Austin",
"postalCode": "78701",
"state": "Texas",
"stateAbbrev": "TX",
"country": "United States",
"phone": "555-555-0100"
}
],
"locationCount": 1
}
```
# Add an Area to a Proposal
Source: https://docs.portal.io/api-reference/proposals/add-area
POST /public/proposals/{ProposalId}/area
POST /public/proposals/{ProposalId}/area — Creates a named area (room) in a proposal. Auto-creates a default Draft option. Returns full proposal detail.
Use this endpoint to add a new area (such as a room or zone) to an existing proposal. Area names must be unique within the proposal. When the area is created, the system automatically generates one default option under it with a status of "Draft" — you do not need to create the first option manually. The response returns the complete updated proposal, including the new area and its default option.
```bash curl theme={null}
curl -i -X POST \
'https://api.portal.io/public/proposals/{ProposalId}/area' \
-H 'Accept: application/json' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE' \
-d 'Name=Living+Room'
```
```python python theme={null}
import requests
from portal_auth import sign_request
url = "https://api.portal.io/public/proposals/{ProposalId}/area"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
content_type = "application/x-www-form-urlencoded"
signature = sign_request("POST", url, content_type, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.post(url, 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
}, data={
"Name": "Living Room"
})
print(response.json())
```
```javascript node.js theme={null}
const { signRequest } = require('./portalAuth');
const url = "https://api.portal.io/public/proposals/{ProposalId}/area";
const timestamp = new Date().toUTCString();
const contentType = "application/x-www-form-urlencoded";
const signature = signRequest("POST", url, contentType, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": contentType,
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
},
body: new URLSearchParams({ Name: "Living Room" })
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"id": 123,
"lastModifiedDate": "2026-04-06T00:22:19Z",
"areas": [
{
"id": 55,
"name": "Living Room",
"options": [
{
"id": 201,
"status": "Draft",
"clientDescription": null,
"installerDescription": null
}
]
}
]
}
```
# Add an Option to a Proposal Area
Source: https://docs.portal.io/api-reference/proposals/add-area-option
POST /public/proposals/{ProposalId}/area/{AreaId}/option
POST .../area/{AreaId}/option — Adds a Draft option to a proposal area. Max 3 options per area. Accepts an optional client description and installer notes.
Use this endpoint to add a new option to an existing proposal area. Options allow you to present multiple installation configurations within a single area — for example, a standard package and a premium package for the same room. The new option is created with "Draft" status, and you can optionally provide a client-facing description and internal installer notes at creation time.
Each area already has one default option created automatically when the area is added. Use this endpoint to add up to 2 more options, for a maximum of 3 per area. Attempting to add a fourth option returns 400.
```bash curl theme={null}
curl -i -X POST \
'https://api.portal.io/public/proposals/123/area/55/option' \
-H 'Accept: application/json' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE' \
-d 'ClientDescription=Premium+Installation+Package' \
-d 'InternalNotes=Use+18-gauge+wire+throughout'
```
```python python theme={null}
import requests
from portal_auth import sign_request
url = "https://api.portal.io/public/proposals/123/area/55/option"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
content_type = "application/x-www-form-urlencoded"
signature = sign_request("POST", url, content_type, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.post(url, 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
}, data={
"ClientDescription": "Premium Installation Package",
"InternalNotes": "Use 18-gauge wire throughout"
})
print(response.json())
```
```javascript node.js theme={null}
const { signRequest } = require('./portalAuth');
const url = "https://api.portal.io/public/proposals/123/area/55/option";
const timestamp = new Date().toUTCString();
const contentType = "application/x-www-form-urlencoded";
const signature = signRequest("POST", url, contentType, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": contentType,
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
},
body: new URLSearchParams({ ClientDescription: "Premium Installation Package", InternalNotes: "Use 18-gauge wire throughout" })
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"id": 123,
"lastModifiedDate": "2026-04-06T00:22:19Z",
"areas": [
{
"id": 55,
"name": "Living Room",
"options": [
{
"id": 201,
"status": "Draft",
"clientDescription": null,
"installerDescription": null
},
{
"id": 202,
"status": "Draft",
"clientDescription": "Premium Installation Package",
"installerDescription": "Use 18-gauge wire throughout"
}
]
}
]
}
```
# Assign a Contact to a Proposal
Source: https://docs.portal.io/api-reference/proposals/assign-contact
POST /public/proposals/{ProposalId}/contact/{ContactId}
POST /public/proposals/{ProposalId}/contact/{ContactId} — Links a contact to a proposal. May auto-assign location and trigger tax recalculations.
Use this endpoint to associate an existing contact with a proposal. Both the proposal and contact must belong to the same account. If the contact has exactly one primary location, the API automatically assigns that location to the proposal at the same time. When a location is set — either automatically here or explicitly via the assign-location endpoint — tax calculations are recalculated based on the contact's location data.
A proposal must have a contact assigned before you can assign a location. Attempting to assign a location to a proposal with no contact returns 409.
```bash curl theme={null}
curl -i -X POST \
'https://api.portal.io/public/proposals/123/contact/456' \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE' \
-d '{"proposalId": 123, "contactId": 456}'
```
```python python theme={null}
import requests
from portal_auth import sign_request
url = "https://api.portal.io/public/proposals/123/contact/456"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
content_type = "application/json"
signature = sign_request("POST", url, content_type, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.post(url, 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
}, json={"proposalId": 123, "contactId": 456})
print(response.status_code) # 200 OK on success
```
```javascript node.js theme={null}
const { signRequest } = require('./portalAuth');
const url = "https://api.portal.io/public/proposals/123/contact/456";
const timestamp = new Date().toUTCString();
const contentType = "application/json";
const signature = signRequest("POST", url, contentType, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": contentType,
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
},
body: JSON.stringify({ proposalId: 123, contactId: 456 })
});
console.log(response.status); // 200 OK on success
```
```text 204 theme={null}
Empty response body.
```
# Assign a Location to a Proposal
Source: https://docs.portal.io/api-reference/proposals/assign-location
POST /public/proposals/{ProposalId}/location/{LocationId}
POST /public/proposals/{ProposalId}/location/{LocationId} — Assigns a location to a proposal and triggers tax recalculation. Returns the updated proposal.
Use this endpoint to assign a specific location to a proposal that already has a contact assigned. The location must belong to the proposal's currently assigned contact and to the same account. Once a location is set, the API recalculates all applicable taxes for the proposal and returns the complete updated proposal object.
You cannot assign a location before assigning a contact. If the proposal has no contact, the API returns 409. Call [Assign a Contact to a Proposal](/api-reference/proposals/assign-contact) first.
```bash curl theme={null}
curl -i -X POST \
'https://api.portal.io/public/proposals/123/location/789' \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE' \
-d '{"proposalId": 123, "locationId": 789}'
```
```python python theme={null}
import requests
from portal_auth import sign_request
url = "https://api.portal.io/public/proposals/123/location/789"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
content_type = "application/json"
signature = sign_request("POST", url, content_type, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.post(url, 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
}, json={"proposalId": 123, "locationId": 789})
print(response.json())
```
```javascript node.js theme={null}
const { signRequest } = require('./portalAuth');
const url = "https://api.portal.io/public/proposals/123/location/789";
const timestamp = new Date().toUTCString();
const contentType = "application/json";
const signature = signRequest("POST", url, contentType, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": contentType,
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
},
body: JSON.stringify({ proposalId: 123, locationId: 789 })
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"id": 123,
"createdDate": "2026-03-01T09:00:00Z",
"lastModifiedDate": "2026-04-06T00:22:19Z",
"financialSummary": {
"partsSubtotal": 2500.00,
"laborTotal": 500.00,
"feeTotal": 0,
"proposalSubtotal": 3000.00,
"salesTax": {
"taxStatus": "Ok",
"total": 247.50
},
"proposalTotal": 3247.50
}
}
```
# Create a New Proposal
Source: https://docs.portal.io/api-reference/proposals/create-proposal
POST /public/proposals
POST /public/proposals — Creates a new proposal. Requires a SalesPersonId; name is optional. Returns full proposal detail including the financial summary.
Creates a new proposal under the authenticated account. You must supply the ID of a salesperson (user) who belongs to the same account. If no name is provided, the system assigns a default name using the same naming logic as the Portal.io UI. The response returns the complete proposal detail object, including the new proposal's ID, number, status, and financial summary.
```bash curl theme={null}
curl -i -X POST \
'https://sandbox.api.portal.io/public/proposals' \
-H 'Accept: application/json' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE' \
-d 'SalesPersonId=42' \
-d 'Name=Smith+Residence+AV'
```
```python python theme={null}
import requests
from portal_auth import sign_request
url = "https://sandbox.api.portal.io/public/proposals"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
content_type = "application/x-www-form-urlencoded"
signature = sign_request("POST", url, content_type, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.post(url, 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
}, data={
"SalesPersonId": "42",
"Name": "Smith Residence AV"
})
print(response.json())
```
```javascript node.js theme={null}
const { signRequest } = require('./portalAuth');
const url = "https://sandbox.api.portal.io/public/proposals";
const timestamp = new Date().toUTCString();
const contentType = "application/x-www-form-urlencoded";
const signature = signRequest("POST", url, contentType, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": contentType,
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
},
body: new URLSearchParams({ SalesPersonId: "42", Name: "Smith Residence AV" })
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"id": 1042,
"number": 1005,
"name": "Smith Residence AV",
"status": "Draft",
"createdDate": "2026-04-06T00:22:19Z",
"lastModifiedDate": "2026-04-06T00:22:19Z",
"lastModifiedByUserDate": "2026-04-06T00:22:19Z",
"lastSubmittedDate": null,
"clientLastOpenedDate": null,
"clientLastDecisionDate": null,
"lastCompletedDate": null,
"financialSummary": {
"partsSubtotal": 0,
"partsTotal": 0,
"laborTotal": 0,
"feeTotal": 0,
"proposalSubtotal": 0,
"salesTax": {
"taxStatus": "Undefined",
"total": 0
},
"proposalTotal": 0
},
"areas": [],
"changeOrders": [],
"customer": null,
"dealer": {
"id": 5,
"name": "AV Solutions Inc."
},
"coverpageImageUrl": null,
"aboutUs": null,
"projectDescription": null,
"profit": null,
"recurringServices": null,
"paymentSchedule": null,
"paymentRequests": [],
"projectTerms": null,
"lastModifiedUser": {
"id": 42,
"firstName": "Alex",
"lastName": "Johnson"
}
}
```
# Get Proposal Details
Source: https://docs.portal.io/api-reference/proposals/get-proposal
GET /public/proposals/{ProposalId}
GET /public/proposals/{ProposalId} — Returns full proposal detail with areas, options, financial summary, and customer info. Sets the Last-Modified header.
Returns complete detail for the specified proposal, including all areas, options, customer information, and a full financial summary. The HTTP response also sets a `Last-Modified` header derived from the proposal's `lastModifiedDate`, which you can use for conditional request patterns.
This is also the only way to read a proposal's line items: they are nested at `areas[].options[].items[]`, and both the area option ids and item ids that the [proposal item endpoints](/api-reference/proposals/items/overview) require come from here. See [reading proposal items](/concepts/proposal-item-model) for what each item field means.
```bash curl theme={null}
curl -i -X GET \
'https://sandbox.api.portal.io/public/proposals/1042' \
-H 'Accept: application/json' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE'
```
```python python theme={null}
import requests
from portal_auth import sign_request # See authentication guide
url = "https://sandbox.api.portal.io/public/proposals/1042"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
signature = sign_request("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.get(url, headers={
"Accept": "application/json",
"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 { signRequest } = require('./portalAuth'); // See authentication guide
const url = "https://sandbox.api.portal.io/public/proposals/1042";
const timestamp = new Date().toUTCString();
const signature = signRequest("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
headers: {
"Accept": "application/json",
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
}
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"id": 1042,
"number": 1001,
"name": "Smith Residence AV",
"status": "Draft",
"createdDate": "2026-03-10T14:00:00Z",
"lastModifiedDate": "2026-04-01T09:30:00Z",
"lastModifiedByUserDate": "2026-04-01T09:30:00Z",
"lastSubmittedDate": null,
"clientLastOpenedDate": null,
"clientLastDecisionDate": null,
"lastCompletedDate": null,
"financialSummary": {
"partsSubtotal": 8000.00,
"partsTotal": 8000.00,
"laborTotal": 2500.00,
"feeTotal": 0,
"proposalSubtotal": 10500.00,
"salesTax": {
"taxStatus": "Ok",
"total": 892.50
},
"proposalTotal": 11392.50
},
"areas": [
{
"id": 55,
"name": "Living Room",
"options": [
{
"id": 201,
"status": "Draft",
"lastModifiedDate": "2026-04-01T09:30:00Z",
"clientDescription": "Standard package",
"installerDescription": "Use 18-gauge wire throughout",
"total": 2095.50,
"totalRecurringService": 0,
"items": [
{
"id": 5001,
"parentId": null,
"itemType": "Part",
"referencedItemId": 88213,
"createdDate": "2026-03-10T14:05:00Z",
"lastModifiedDate": "2026-04-01T09:30:00Z",
"brand": "Sonos",
"model": "Amp",
"name": null,
"shortDescription": "Sonos Amp",
"clientNote": null,
"imageUrl": "https://images.portal.io/catalog/sonos-amp.jpg",
"msrp": 699.00,
"sellPrice": 649.00,
"cost": 499.00,
"costUpdateDate": "2026-04-01T00:00:00Z",
"supplier": "Sonos Inc.",
"quantity": 2,
"total": {
"amount": 1298.00,
"currency": { "code": "USD", "symbol": "$" },
"isCombinedPrice": false
},
"isTaxExempt": false,
"isRecurringService": false,
"linkedOrders": []
},
{
"id": 5002,
"parentId": 5001,
"itemType": "Part",
"referencedItemId": 90144,
"createdDate": "2026-03-10T14:06:00Z",
"lastModifiedDate": "2026-03-10T14:06:00Z",
"brand": "Sonance",
"model": "VP62R",
"name": null,
"shortDescription": "Sonance VP62R In-Ceiling Speaker",
"clientNote": null,
"imageUrl": null,
"msrp": 179.00,
"sellPrice": 137.50,
"cost": 98.00,
"costUpdateDate": "2026-04-01T00:00:00Z",
"supplier": "ADI Global Distribution",
"quantity": 4,
"total": {
"amount": 550.00,
"currency": { "code": "USD", "symbol": "$" },
"isCombinedPrice": false
},
"isTaxExempt": false,
"isRecurringService": false,
"linkedOrders": []
},
{
"id": 5003,
"parentId": null,
"itemType": "Labor",
"referencedItemId": 730,
"createdDate": "2026-03-10T14:08:00Z",
"lastModifiedDate": "2026-03-10T14:08:00Z",
"brand": null,
"model": null,
"name": "Installation Labor",
"shortDescription": "Installation Labor",
"clientNote": null,
"imageUrl": null,
"msrp": null,
"sellPrice": 45.00,
"cost": null,
"costUpdateDate": null,
"supplier": null,
"quantity": 5.5,
"total": {
"amount": 247.50,
"currency": { "code": "USD", "symbol": "$" },
"isCombinedPrice": false
},
"isTaxExempt": false,
"isRecurringService": false,
"linkedOrders": []
}
]
},
{
"id": 202,
"status": "Draft",
"lastModifiedDate": "2026-03-28T16:12:00Z",
"clientDescription": "Premium Installation Package",
"installerDescription": null,
"total": 3450.00,
"totalRecurringService": 25.00,
"items": []
}
]
}
],
"changeOrders": [],
"customer": {
"id": 88,
"firstName": "Jane",
"lastName": "Smith",
"companyName": "",
"contactEmail": "jane.smith@example.com"
},
"dealer": {
"id": 5,
"name": "AV Solutions Inc."
},
"coverpageImageUrl": null,
"aboutUs": "We specialize in smart home integration.",
"projectDescription": "Whole-home AV and lighting control system.",
"profit": null,
"recurringServices": null,
"paymentSchedule": null,
"paymentRequests": [],
"projectTerms": "Payment due within 30 days of completion.",
"lastModifiedUser": {
"id": 12,
"firstName": "Alex",
"lastName": "Johnson"
}
}
```
# Add Items to a Proposal
Source: https://docs.portal.io/api-reference/proposals/items/add-items
POST /public/proposals/{ProposalId}/items
POST /public/proposals/{ProposalId}/items — Adds one or more catalog, custom, labor, or fee items to a proposal area option.
Adds items to a proposal. Each entry in `Items` names a source item, its type, and the area options to put it in — one item is created per area option the entry targets. Area option ids come from [get proposal](/api-reference/proposals/get-proposal); see [reading proposal items](/concepts/proposal-item-model) for where to find them.
The response is the standard [`List`](/api-reference/proposals/items/overview#response-shape) array of created items.
## Item types and their ids
`CatalogItemId` means a different thing for each `ItemType`, and each type takes the item's name in a different field:
| `ItemType` | `CatalogItemId` refers to | Send the name in | Read back as |
| ------------ | ------------------------------------------------------------------------------------------- | ---------------- | ------------ |
| `Part` | A catalog item, searchable with [search catalog items](/api-reference/catalog/search-items) | `Model` | `model` |
| `CustomItem` | A custom item in the account's library | `Model` | `model` |
| `Labor` | A labor item in the account's library | `Name` | `name` |
| `Fee` | A fee item in the account's library | `Name` | `model` |
Supplying the wrong one for the type — `Name` on a `Part`, `Model` on a `Labor` item — returns `400`. `SupplierId` applies to `Part` items only; omit it to use the part's default supplier.
Only `Part` ids are discoverable through the public API today. There is no public endpoint that lists the account's labor, custom, or fee items, so ids for those types have to come from elsewhere until one ships.
## Pricing a new item
Omit `SellPrice` and `SellPercentage` and the item inherits the catalog item's default sell price; the same applies to `Cost` and `CostPercentage`. Supply one of each pair to override — an absolute amount, or a whole-percent value plus its basis (`15` means 15%).
Send exactly one member of each pair. Unlike [update sell price](/api-reference/proposals/items/update-item-sell-price) and [update cost](/api-reference/proposals/items/update-item-cost), this endpoint does not check the pair for you, so a mistake here fails quietly instead of returning a `400`. Send both members and the percentage is used, ignoring the absolute amount. Send a percentage without its basis and there is nothing to calculate it from, so the item is created with no price at all.
Per-item `SetDefault` saves this item's sell price as the default on the catalog item, so later proposals start with it. It persists nothing else on the item.
This endpoint is not idempotent. A retried call adds another copy of the item rather than reconciling with the first, so confirm with [get proposal](/api-reference/proposals/get-proposal) before repeating a request that may have already landed.
## Nesting and attachments
`ParentProposalItemIds` nests the new item under an existing one. Matching is per area option: in each target option, the new item nests under whichever listed parent lives in that same option, and sits at top level where none does. Nesting is read back as `parentId`, and children's prices can be folded into the parent's displayed total with [set combined pricing](/api-reference/proposals/items/set-item-combined-pricing).
`IncludeAttachments` is separate — it adds the items the catalog item is configured to bring along, nested under the new item. **It defaults to `true`**; pass `false` to skip them. Attachment items are not part of this call's response and may take a moment to appear, so re-fetch the proposal to read them.
## Adding several items at once
`items` takes any number of entries, and they do not have to be the same type. This call adds a `Part` and a `Labor` line to area option `201`, with the part nested under the existing item `5000`:
```json theme={null}
{
"items": [
{
"catalogItemId": 88213,
"itemType": "Part",
"parentProposalItemIds": [5000],
"proposalAreaOptions": [{ "proposalAreaOptionId": 201, "quantity": 2 }]
},
{
"catalogItemId": 730,
"itemType": "Labor",
"name": "Installation Labor",
"proposalAreaOptions": [{ "proposalAreaOptionId": 201, "quantity": 5.5 }]
}
]
}
```
The part nests under `5000` because that item sits in option `201`; where none of the listed parents lives in a target option, the new item is added at that option's top level instead. The labor line names no parent, so it starts at top level. Each entry is created once per area option it lists, so adding further `proposalAreaOptions` entries places copies across several options in the same call.
# Copy Proposal Items
Source: https://docs.portal.io/api-reference/proposals/items/copy-items
POST /public/proposals/{ProposalId}/items/copy
POST /public/proposals/{ProposalId}/items/copy — Copies one or more items into one or more destination area options within the same proposal.
Copies items into one or more area options of the same proposal — the way to offer the same equipment across several client-selectable options without re-adding it by hand.
Pass the items in `ProposalItemIds` and the targets in `DestinationAreaOptionIds`. Every destination receives a copy of every listed item, so three items and two destinations produce six new items. Copies are appended to the end of each destination option. Every destination option and item must belong to the proposal, and a repeated id in either list returns `400` rather than being ignored.
`CopyNestedItems` defaults to `true`, so children are copied under their new parent and appear in the response alongside them. The copies are new proposal items with their own ids: later edits to the originals do not follow them.
Each destination option is saved separately. Every id is checked before anything is copied, so one bad id copies nothing at all — but if the call fails part-way through several destinations, the copies already saved stay where they are. Re-fetch [get proposal](/api-reference/proposals/get-proposal) before retrying, or you will copy those items twice.
# Delete Items from a Proposal
Source: https://docs.portal.io/api-reference/proposals/items/delete-items
DELETE /public/proposals/{ProposalId}/items
DELETE /public/proposals/{ProposalId}/items — Removes one or more items from a proposal.
Removes items from a proposal by id. Pass the ids in `ItemIds`, repeating the query parameter once per id.
Every id must belong to the proposal and appear only once — an unknown id or a repeated one returns `404`, and nothing is deleted. There is no partial delete.
`IncludeAttachments` decides what happens to the items nested under those being deleted. It defaults to `false`, which keeps them: they survive as top-level items in the same area option, taking the position their parent held. Pass `true` to delete them along with their parent.
Success is an empty `200` or `204`; treat both as deleted. Along with [refresh item costs](/api-reference/proposals/items/refresh-item-costs), this is one of two item endpoints with no response body, so re-fetch [get proposal](/api-reference/proposals/get-proposal) if you need the remaining items.
To take an item off the client-facing total without removing it, set its quantity to `0` with [update quantity](/api-reference/proposals/items/update-item-quantity) instead.
# List Proposal Item Suppliers
Source: https://docs.portal.io/api-reference/proposals/items/list-item-suppliers
GET /public/proposals/{ProposalId}/items/{ProposalItemId}/suppliers
GET /public/proposals/{ProposalId}/items/{ProposalItemId}/suppliers — Lists the suppliers available for a proposal item, with costs and the one currently assigned.
Lists every supplier the item's catalog item can be bought from, with each supplier's cost. Call it before [set item supplier](/api-reference/proposals/items/set-item-supplier) to get the `id` that endpoint needs.
The entry with `isDefault: true` is the supplier the item currently uses — this is also how you confirm a supplier change, since the item itself only carries the supplier's display name. For that entry, `cost.value` and `cost.lastVerifiedDate` carry the proposal item's own values, including any cost written with [update cost](/api-reference/proposals/items/update-item-cost); for the others they are the supplier's catalog values. When the item has no supplier assigned, no entry is marked default.
This endpoint always returns costs, even when the `cost` field on the item itself comes back `null` for the same credentials.
`Labor`, `CustomItem`, and `Fee` items have no catalog item behind them, so the list comes back empty rather than `404`.
# Move Proposal Items
Source: https://docs.portal.io/api-reference/proposals/items/move-items
POST /public/proposals/{ProposalId}/items/move
POST /public/proposals/{ProposalId}/items/move — Moves one or more items to a different area option within the same proposal.
Moves items to a different area option in the same proposal. Pass the items in `ProposalItemIds` and the target in `DestinationAreaOptionId` — one destination per call, unlike [copy items](/api-reference/proposals/items/copy-items). Both the destination option and every item must belong to the proposal, and a repeated id in `ProposalItemIds` returns `400` rather than being ignored.
Moved items are appended to the end of the destination option. Every item in a single call is given the same position in the destination, so if the order between them matters, move them one call at a time.
Move a parent to move its whole group. Sending a nested item's own id moves just that item, and while `MoveNestedItems` is left at its default its `parentId` keeps pointing at a parent that now sits in the other area option.
`MoveNestedItems` defaults to `true`, which moves each item's children along with it. Setting it to `false` does not preserve the nesting: the children stay behind in the source option and become top-level items there.
With `MoveNestedItems=false`, the items you named lose their own nesting too — their `parentId` is cleared rather than left pointing into the source option. Keep the default when you are moving a nested item and want its parent link intact.
The response lists the targeted items and their children, including children that stayed behind.
# Proposal Items
Source: https://docs.portal.io/api-reference/proposals/items/overview
Conventions shared by every proposal item endpoint: authentication, error codes, the response shape, the setDefault and updateAllInstances flags, and what to expect when driving them from an integration.
The endpoints in this section edit the line items of a proposal — one field per call. This page covers what they all have in common; each endpoint page then describes only its own behavior.
To read items, or to find the item and area option ids these endpoints need, see [reading proposal items](/concepts/proposal-item-model). For an end-to-end walkthrough, see [building a proposal item by item](/concepts/proposal-items-workflow).
## Authentication
Item endpoints accept the same HMAC-signed authentication as the rest of the Portal.io API — see [signing requests](/authentication/signing-requests) for how to build `X-MSS-SIGNATURE`. All requests carry the standard header set:
| Header | Description |
| ------------------- | ------------------------------------ |
| `Accept` | `application/json` |
| `X-MSS-API-APPID` | Application Id |
| `X-MSS-API-USERKEY` | User API Key |
| `X-MSS-CUSTOM-DATE` | Timestamp of the request |
| `X-MSS-SIGNATURE` | HMAC-SHA256 signature of the request |
An authenticated Portal.io session is also accepted, which is how the web app calls these routes. API integrations should sign requests.
The `portal_auth` / `portalAuth` helper imported by the code samples on these pages is the signing routine from [signing requests](/authentication/signing-requests) — copy it from there.
## Error codes
| Code | Meaning |
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400` | Invalid payload. Beyond missing required fields: both members of a mutually exclusive pair (`SellPrice`/`SellPercentage`, `Cost`/`CostPercentage`, `ImageUrl`/`FileData`), a percentage without its basis, duplicate ids in a `move` or `copy` list, `name` on a `Part`, `model` on a `Labor` item, or the recurring-service flag on a `Part`. |
| `401` | Not authorized — missing or invalid signature, or an unauthenticated session. Also returned when editing a proposal template you do not own. |
| `402` | The dealer's subscription is inactive or expired. |
| `403` | You do not have permission to edit this proposal. |
| `404` | Proposal, proposal item, area option, or supplier not found — also a repeated id in a [delete](/api-reference/proposals/items/delete-items) call. Endpoints that take a list of ids validate all of them first, so one bad id fails the whole call without applying anything. |
| `409` | The proposal is `Accepted` or `Completed` and can no longer be edited. Every other status, including `Submitted`, `ViewedByClient`, `Declined`, and `Expired`, still accepts item writes. |
Three endpoints deviate: [refresh item costs](/api-reference/proposals/items/refresh-item-costs) has no `400` (it takes no payload), [set item supplier](/api-reference/proposals/items/set-item-supplier) documents none either, and [list item suppliers](/api-reference/proposals/items/list-item-suppliers) has neither `400` nor `409` (it is a read).
## Response shape
Every endpoint except the three noted below returns `200` with a JSON array of `PublicAreaItemModel` — including endpoints that affect a single item, which return a one-element array. There is no unwrapped single-object response in this section.
The array holds only the items the call actually affected, not the proposal. Ordering follows the proposal structure (area, then option, then display order), not the order of ids in your request, so match results back by `id` rather than by position. Field-by-field meanings are in [reading proposal items](/concepts/proposal-item-model).
[Delete items](/api-reference/proposals/items/delete-items) and [refresh item costs](/api-reference/proposals/items/refresh-item-costs) return no body — either `200` or `204`, so treat both as success. Re-fetch [get proposal](/api-reference/proposals/get-proposal) afterwards to read the resulting state. [List item suppliers](/api-reference/proposals/items/list-item-suppliers) is the third exception: it only reads data, so it returns an array of `PublicSupplierInfoModel` rather than proposal items.
## Which flags an endpoint accepts
`SetDefault` and `UpdateAllInstances` control two independent things, and support for them varies per endpoint:
Saves the new value outside this proposal, so items added later start with it. What it writes depends on the item type: a `Part` updates the company's catalog data, while `Labor`, `CustomItem`, and `Fee` items update their library item. [Client note](/api-reference/proposals/items/update-item-note) is the exception — whatever the item type, it saves a default for you alone rather than for the company. Defaults to `false`.
Applies the new value to the proposal's **other items from the same source item that share its item type**, in any area option — plus the same assigned supplier for [cost](/api-reference/proposals/items/update-item-cost). Defaults to `false`. Writes nothing outside the proposal.
| Endpoint | Flags accepted |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Sell price](/api-reference/proposals/items/update-item-sell-price), [cost](/api-reference/proposals/items/update-item-cost), [MSRP](/api-reference/proposals/items/update-item-msrp) | `SetDefault`, `UpdateAllInstances` |
| [Short description](/api-reference/proposals/items/update-item-short-description), [client note](/api-reference/proposals/items/update-item-note), [image](/api-reference/proposals/items/update-item-image) | `SetDefault`, `UpdateAllInstances` |
| [Tax-exempt flag](/api-reference/proposals/items/update-item-tax-exempt), [recurring-service flag](/api-reference/proposals/items/update-item-recurring-service) | `SetDefault` only, and only while the flag is being set to `true` — it writes to `Labor` and `CustomItem` library items, nothing else |
| [Set supplier](/api-reference/proposals/items/set-item-supplier) | `SetDefault` only — the supplier change always covers every matching item in the proposal, with no flag to narrow it |
| [Add items](/api-reference/proposals/items/add-items) | `SetDefault` per item, applying to the sell price only |
| [Quantity](/api-reference/proposals/items/update-item-quantity), [replace](/api-reference/proposals/items/replace-item), [delete](/api-reference/proposals/items/delete-items) | Neither — they take `IncludeAttachments`, which means something different on each: rescale nested quantities, add the replacement's attachments, or delete nested items |
| [Combined pricing](/api-reference/proposals/items/set-item-combined-pricing) | Neither |
| [Move](/api-reference/proposals/items/move-items), [copy](/api-reference/proposals/items/copy-items) | Neither — they take `MoveNestedItems` / `CopyNestedItems`, which decide what happens to nested items and default to `true` |
`SetDefault` writes outside the proposal you are editing, and some of those writes travel further than the catalog: saving a cost or a supplier also updates the company's other **draft proposals and draft orders** that carry the same part without a cost of their own. It also depends on access your account may not grant, and is silently skipped where that access is missing — the proposal item is still updated and a `200` is still returned. Read the value back if you need to be certain it took effect.
Each endpoint page spells out what its own `SetDefault` saves, since the behaviour differs by field and item type — a percentage-based price, for instance, is not saved on a `Part` at all.
## Driving these endpoints from an integration
* **One field per call, no bulk endpoint.** Repricing 50 items means 50 signed requests. Budget for it.
* **Writes are not idempotent.** Retrying [add items](/api-reference/proposals/items/add-items) after a timeout adds a second copy rather than reconciling with the first. Confirm with a `GET` before retrying.
* **Serialize writes per proposal.** Totals are recalculated on every write, so concurrent calls against the same proposal race each other. Parallelize across proposals instead.
* **Percentage-priced items move on their own.** Every basis is recalculated each time the proposal is read, so editing one item changes the price of others: `ProposalTotal`, `PartTotal`, and `LaborTotal` change when anything in the proposal changes, `AreaTotal`, `PartsInAreaTotal`, and `LaborInAreaTotal` when anything in the same area option changes, and `CostOfSellPrice` when the item's own sell price changes. Re-read the proposal after a batch rather than assembling state from individual responses.
* **No item-level webhooks.** Only proposal-level events fire; poll [get proposal](/api-reference/proposals/get-proposal) and use its `Last-Modified` header to detect changes made in the Portal.io UI.
* **Not everything lands in the response.** Attachment items added by [add items](/api-reference/proposals/items/add-items), and the cross-proposal copies made by [update image](/api-reference/proposals/items/update-item-image), are applied asynchronously. A `GET` a moment later is the only way to see them. [Replace item](/api-reference/proposals/items/replace-item) is the exception: its attachments are added inline and do appear in the response.
# Refresh Proposal Item Costs
Source: https://docs.portal.io/api-reference/proposals/items/refresh-item-costs
POST /public/proposals/{ProposalId}/items/costupdate
POST /public/proposals/{ProposalId}/items/costupdate — Re-reads supplier costs for every item in a proposal from the catalog. Takes no request body.
Re-reads catalog data for the proposal's `Part` items. Use it after supplier pricing changes upstream and the proposal has gone stale — the per-item `costUpdateDate` tells you how old the numbers are.
For each part it re-reads the supplier cost, the in-stock flag, and the MSRP. Where the part's supplier is set to use a future cost, that is the figure applied. An item with no supplier assigned picks up the part's default supplier and its cost.
Items carrying a hand-entered amount are skipped outright — the refresh leaves their cost, stock flag, and MSRP exactly as they are. An item is marked that way as soon as a cost or an MSRP is written on it, including through [update cost](/api-reference/proposals/items/update-item-cost) and [update MSRP](/api-reference/proposals/items/update-item-msrp), so refreshing will never overwrite a price your integration set on purpose.
Costs are also only re-read for suppliers whose pricing your account is allowed to see. Where it is not, the cost and `costUpdateDate` are left unchanged — but the item is not skipped the way a hand-entered one is: its in-stock flag is still cleared and its MSRP is still refreshed from the catalog.
`Labor`, `CustomItem`, and `Fee` items are not touched, and neither are sell prices. The proposal id goes in the path and the request takes no body. This is proposal-wide: there is no per-item variant and no way to limit it to one area option.
Success is an empty `200` or `204`. Along with [delete items](/api-reference/proposals/items/delete-items), this is one of two item endpoints with no response body, so call [get proposal](/api-reference/proposals/get-proposal) afterwards to read the new values.
Where margin is set as a percentage of cost, refreshed costs move those prices too — see [reading proposal items](/concepts/proposal-item-model#percentage-pricing-is-dynamic).
# Replace a Proposal Item
Source: https://docs.portal.io/api-reference/proposals/items/replace-item
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/replace
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/replace — Swaps a proposal item for a different catalog item.
Swaps a proposal item for a different source item, named by `NewCatalogItemId`. `ItemType` describes the incoming item and does not have to match the type of the one being replaced.
What survives the swap and what comes from the new item:
| Kept from the original | Taken from the replacement |
| ------------------------------------- | ----------------------------------------- |
| Quantity, position, area option | Supplier, sell price, cost, MSRP |
| Parent item, combined-pricing setting | Tax-exempt and recurring-service settings |
| | Image, brand, model, description |
The original item is removed and its `id` is no longer valid. Read the new id from the response before making any further per-item calls, and drop any id you had cached.
`ReplaceAll` extends the swap to the proposal's other items that come from the same source item **and share its item type**; instances sitting in a declined area option are left alone. Defaults to `false`.
`IncludeAttachments` also adds the items attached to each replacement, nested under it. It defaults to `false` here and requires the account's plan to include product attachments. Attachment items are listed in the response along with the replacements.
# Set Proposal Item Combined Pricing
Source: https://docs.portal.io/api-reference/proposals/items/set-item-combined-pricing
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/combineprice
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/combineprice — Toggles combined pricing so nested item prices roll up into the parent item's total.
Rolls the totals of an item's nested children into its own `total.amount`, so the client sees one line instead of a parent plus its parts. Send `IsCombinedPrice=true` to combine, `false` to clear it again.
Combined pricing only takes effect on a top-level item that has nested items and is not a recurring service. Marking the item as a recurring service with [update recurring-service flag](/api-reference/proposals/items/update-item-recurring-service) clears it. So does deleting the item's last nested item with [delete items](/api-reference/proposals/items/delete-items), since nothing is left to roll up. In both cases `total.isCombinedPrice` no longer returns `true`, without any call to this endpoint. Send `IsCombinedPrice=true` again once the item has children.
The children keep their own `sellPrice` and `total` in every response, so when combined pricing is on — `total.isCombinedPrice` is `true` on the parent — do not add the children's totals on top of the parent's, or you will count them twice. Items are nested with `ParentProposalItemIds` on [add items](/api-reference/proposals/items/add-items).
This endpoint affects the item you name and nothing else. It has no `SetDefault` flag, and it never changes other items built from the same catalog item.
# Set Proposal Item Supplier
Source: https://docs.portal.io/api-reference/proposals/items/set-item-supplier
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/supplier/{SupplierId}
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/supplier/{SupplierId} — Sets the supplier for a proposal item.
Assigns a supplier to a proposal item and pulls that supplier's cost onto it. The supplier must be one returned by [list item suppliers](/api-reference/proposals/items/list-item-suppliers) for this item; anything else returns `404`.
The change always covers every item in the proposal that comes from the same source item and shares its item type. It cannot be narrowed to the item named in the path.
Each item that moves to the new supplier takes that supplier's catalog cost, **replacing any cost entered on it**, and ends up with no cost at all where the supplier has none for that item. Items already using this supplier are left alone — they keep the cost they carry, hand-entered or not, and are still listed in the response. If the company is not yet linked to the supplier, the call links it.
The response lists every matched item, including ones already using this supplier, and carries the supplier as a display name only. To confirm which supplier id is now in use, re-read [list item suppliers](/api-reference/proposals/items/list-item-suppliers) and look for `isDefault: true`.
`SetDefault` also makes this the company's default supplier for the catalog item, and updates the company's draft proposals and draft orders that carry the item without a cost of their own.
# Update Proposal Item Supplier Cost
Source: https://docs.portal.io/api-reference/proposals/items/update-item-cost
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/cost
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/cost — Sets an absolute supplier cost or a percentage-based cost on a proposal item.
Sets the per-unit supplier cost of a proposal item, as either an absolute `Cost` or a `CostPercentage` plus `CostPercentageBasis`. Exactly one of the two forms is required: supplying both, neither, or a percentage without its basis returns `400`. As with sell price, the two forms replace each other.
Percentages are whole numbers — `15` means 15%, rounded to 2 decimals. The bases match [update sell price](/api-reference/proposals/items/update-item-sell-price), plus `CostOfSellPrice`, which is valid for cost only and makes the cost a percentage of the item's own sell price.
Every basis is resolved when the proposal is read, not when you write it, so a percentage cost is never a fixed number: `ProposalTotal`, `PartTotal`, and `LaborTotal` move with the whole proposal, `AreaTotal`, `PartsInAreaTotal`, and `LaborInAreaTotal` move with the item's own area option, and `CostOfSellPrice` moves with the item's sell price. Re-read the proposal after a batch of writes rather than assuming the cost you sent is the cost stored.
A percentage cost is ignored while the item is marked as a recurring service.
Writing a cost updates the item against its current supplier and refreshes `costUpdateDate`. It does not change which supplier the item uses — [set item supplier](/api-reference/proposals/items/set-item-supplier) does that. `cost` can come back `null` even on a successful write, if you do not have permission to see costs — the write still took effect.
A cost written here marks the item as carrying a hand-entered amount, which takes it out of every later [refresh item costs](/api-reference/proposals/items/refresh-item-costs) run on the proposal. That keeps a price you set on purpose from being reset by a catalog refresh, and there is no flag to opt back in.
`SetDefault` also saves the cost in the catalog against the supplier the item uses, and updates the company's draft proposals and draft orders that carry the same part without a cost of their own. For a `Part` only an absolute cost is saved; a percentage-based update saves nothing and clears any cost saved there before. `Labor`, `CustomItem`, and `Fee` items keep the cost, percentage, and basis on their library item.
`UpdateAllInstances` applies the change to the proposal's other items from the same source item that share both its item type **and its assigned supplier**.
# Update Proposal Item Image
Source: https://docs.portal.io/api-reference/proposals/items/update-item-image
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/image
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/image — Sets the image of a proposal item from a hosted URL or inline file data.
Sets the image shown for a proposal item. Provide exactly one of `ImageUrl` or `FileData`: both, or neither, returns `400`.
* `ImageUrl` must be an absolute `http` or `https` URL and must be reachable from Portal.io, which downloads and stores a copy. A failed download leaves the item with no image. A relative URL or any other scheme returns `400`.
* `FileData` carries the raw bytes, base64-encoded. Animated GIFs are not stored and leave the item without an image.
The response returns `imageUrl` pointing at the stored copy, not at the source you supplied.
This call also affects proposals other than the one you are editing. Portal.io queues a background job that applies the same image to `Part` items using the same catalog item across the company's other **draft** proposals — only to items that have no image of their own, and never to submitted or accepted proposals. Because it runs asynchronously, those items may take a moment to catch up and never appear in this response.
`SetDefault` also makes the image the company's default for the catalog item. Depending on the account's trust level and how many companies already use the same image, it can become the shared catalog primary image as well.
`UpdateAllInstances` decides what happens to the other instances of this item in the proposal that **already have** an image — they are overwritten only when it is `true`. Instances with no image are updated either way.
# Update Proposal Item MSRP
Source: https://docs.portal.io/api-reference/proposals/items/update-item-msrp
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/msrp
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/msrp — Sets the MSRP of a proposal item.
Sets the per-unit MSRP of a proposal item from the required `Msrp` amount. MSRP never changes what the item sells for: it is the list price shown alongside the sell price, and where it exceeds the sell price the difference is reported as MSRP savings. `total.amount` continues to follow the sell price.
`SetDefault` also saves the MSRP for later proposals, and what that means depends on the item type — a `Part` saves it as the company's own MSRP in your locale's currency, a `CustomItem` saves it on the library item, and `Labor` and `Fee` items have no MSRP of their own, so the flag does nothing. Once enough companies record the same MSRP for a part, that figure can also become the catalog's shared MSRP. For a `Part` it also reaches the company's other draft proposals, applied in the background rather than in this response.
An MSRP written here marks the item as carrying a hand-entered amount, which takes it out of every later [refresh item costs](/api-reference/proposals/items/refresh-item-costs) run on the proposal — that endpoint re-reads MSRP as well as cost.
`UpdateAllInstances` applies the change to the proposal's other items from the same source item that share its item type, in any area option.
# Update Proposal Item Client Note
Source: https://docs.portal.io/api-reference/proposals/items/update-item-note
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/note
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/note — Updates the client-facing note of a proposal item.
Sets the client-facing note on a proposal item — the extra line under the item, for install detail or a caveat worth spelling out. Omitting `ClientNote` or sending `null` clears the note. For text the client should not see, use [update area installer notes](/api-reference/proposals/update-area-installer-notes) or the proposal's internal notes instead.
`SetDefault` saves the note as your own default for the catalog item, so items you add to later proposals start with it. It is saved for you alone, not for the whole company. Clearing the note with `SetDefault` set also clears that stored default.
`UpdateAllInstances` applies the change to the proposal's other items from the same source item that share its item type, in any area option.
# Update Proposal Item Quantity
Source: https://docs.portal.io/api-reference/proposals/items/update-item-quantity
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/qty
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/qty — Sets the quantity of a proposal item. Fractional values and zero are allowed.
Sets the `Quantity` of a proposal item. Fractional values are supported and stored rounded to 2 decimals, so `5.5` hours of labor is a valid quantity.
A quantity of `0` is allowed and keeps the item in the proposal at a zero total — use [delete items](/api-reference/proposals/items/delete-items) to remove it.
`IncludeAttachments` rescales the items nested under this one *proportionally* rather than setting them to the same number. Going from 2 to 6 multiplies every nested quantity by 3, so a child at 1 becomes 3, rounded to 2 decimals. A child already at `0` stays at `0`, and if the parent's current quantity is `0` there is no factor to apply, so only the parent changes. Defaults to `false`.
# Update Proposal Item Recurring-Service Flag
Source: https://docs.portal.io/api-reference/proposals/items/update-item-recurring-service
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/recurringservice
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/recurringservice — Marks a proposal item as a recurring service or not.
Marks a proposal item as a recurring service — a monitoring plan, a service contract — rather than a one-off charge, or clears that mark.
A recurring-service item leaves its area option's `total` and is counted in that option's `totalRecurringService` instead, so changing this flag changes which of the two totals the item counts toward, not the item's own price. Turning the mark on also switches [combined pricing](/api-reference/proposals/items/set-item-combined-pricing) off on the item, and any percentage-based cost on it stops being applied.
`Part` items cannot be recurring services: the call returns `400` when the target item's `itemType` is `Part`.
Applies to the targeted item only — there is no `UpdateAllInstances` here. `SetDefault` marks the `Labor` or `CustomItem` library item behind this one as recurring, but only while `IsRecurringService` is `true`; clearing the mark leaves the library item alone, and nothing is written for `Fee` items.
# Update Proposal Item Sell Price
Source: https://docs.portal.io/api-reference/proposals/items/update-item-sell-price
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/sellprice
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/sellprice — Sets an absolute sell price or a percentage-based sell price on a proposal item.
Sets the per-unit sell price of a proposal item, as either an absolute `SellPrice` or a `SellPercentage` plus `SellPercentageBasis`. Exactly one of the two forms is required: supplying both, neither, or a percentage without its basis returns `400`.
The two forms replace each other. Writing an absolute price clears any stored percentage and basis; writing a percentage clears any stored absolute price.
Percentages are whole numbers — `15` means 15%, with the resulting price rounded to 2 decimals.
| Basis | Percentage of |
| ------------------ | --------------------------------------------- |
| `AreaTotal` | Sell total of the item's own area option |
| `PartsInAreaTotal` | Parts-only sell total of that area option |
| `LaborInAreaTotal` | Labor-only sell total of that area option |
| `ProposalTotal` | Proposal subtotal, before any convenience fee |
| `PartTotal` | Parts-only sell total across the proposal |
| `LaborTotal` | Labor-only sell total across the proposal |
`CostOfSellPrice` belongs to [update cost](/api-reference/proposals/items/update-item-cost) and must not be used here.
A percentage price is recalculated rather than stored. With `ProposalTotal`, `PartTotal`, or `LaborTotal` the item's price changes whenever anything else in the proposal changes, so the `sellPrice` in this response is only current as of this call. See [reading proposal items](/concepts/proposal-item-model#percentage-pricing-is-dynamic).
`SetDefault` also saves the price in the catalog so later proposals start with it — but only an absolute price is saved for a `Part`, and a percentage-based update instead clears whatever price was saved there before. `Labor`, `CustomItem`, and `Fee` items keep the price, percentage, and basis on their library item. `UpdateAllInstances` applies the change to the proposal's other items from the same source item that share its item type, in any area option.
# Update Proposal Item Short Description
Source: https://docs.portal.io/api-reference/proposals/items/update-item-short-description
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/shortdescription
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/shortdescription — Updates the short description of a proposal item.
Sets the short description of a proposal item — the descriptive line the client reads, returned as `shortDescription`. It replaces whatever the item carried before, and omitting `ShortDescription` or sending `null` clears it.
`SetDefault` also saves the description for later proposals: a `Part` saves it as the company's own description, a `Labor` or `CustomItem` saves it on the library item. Clearing the description also clears a `Part`'s saved one, while `Labor` and `CustomItem` items keep what they had.
Do not use `SetDefault` on a `Fee` item. It currently overwrites the fee's **name** in the library rather than its description.
`UpdateAllInstances` applies the change to the proposal's other items from the same source item that share its item type, in any area option.
# Update Proposal Item Tax-Exempt Flag
Source: https://docs.portal.io/api-reference/proposals/items/update-item-tax-exempt
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/taxexempt
POST /public/proposals/{ProposalId}/items/{ProposalItemId}/taxexempt — Marks a proposal item as tax-exempt or not.
Marks a proposal item as tax-exempt, excluding it from the proposal's sales tax calculation while leaving it in the subtotal.
Applies to the targeted item only: there is no `UpdateAllInstances` here, so exempting the same product elsewhere in the proposal takes one call per instance.
`SetDefault` saves the setting on the library item behind a `Labor` or `CustomItem`, so items added from it later start tax-exempt. It works only while `IsTaxExempt` is `true`, and does nothing for `Part` or `Fee` items.
# List and Search Proposals
Source: https://docs.portal.io/api-reference/proposals/list-proposals
GET /public/proposals
GET /public/proposals — Returns all proposals for the account with filtering by status, contact, date, and text search. Supports sorting and pagination.
Returns the proposals belonging to the authenticated account. You can narrow results by status, contact, modified date, search text, and archive state. The response includes a flat array of proposal summaries and a total count, making this the standard starting point for most proposal workflows.
```bash curl theme={null}
curl -i -X GET \
'https://sandbox.api.portal.io/public/proposals?PageNumber=1&PageSize=25' \
-H 'Accept: application/json' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE'
```
```python python theme={null}
import requests
from portal_auth import sign_request # See authentication guide
url = "https://sandbox.api.portal.io/public/proposals?PageNumber=1&PageSize=25"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
signature = sign_request("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.get(url, headers={
"Accept": "application/json",
"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 { signRequest } = require('./portalAuth'); // See authentication guide
const url = "https://sandbox.api.portal.io/public/proposals?PageNumber=1&PageSize=25";
const timestamp = new Date().toUTCString();
const signature = signRequest("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
headers: {
"Accept": "application/json",
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
}
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"proposals": [
{
"id": 1042,
"number": 1001,
"name": "Smith Residence AV",
"status": "Draft",
"total": {
"proposalTotal": 12500.00,
"currency": {
"code": "USD",
"symbol": "$"
}
},
"createdDate": "2026-03-10T14:00:00Z",
"lastModifiedDate": "2026-04-01T09:30:00Z",
"customer": {
"id": 88,
"firstName": "Jane",
"lastName": "Smith",
"companyName": "",
"contactEmail": "jane.smith@example.com"
}
}
],
"proposalCount": 1
}
```
# Update an Area Option's Client Description
Source: https://docs.portal.io/api-reference/proposals/update-area-client-description
POST /public/proposals/{ProposalId}/area-options/{AreaOptionId}/clientdescription
POST .../area-options/{AreaOptionId}/clientdescription — Sets the customer-facing description for an area option. Returns the full updated proposal detail.
Use this endpoint to set or update the client-facing description for a specific area option. This description appears on customer-facing proposal documents and helps the customer understand what each option includes. The route requires both the proposal ID and the area option ID to verify that the option belongs to the specified proposal before applying the update.
```bash curl theme={null}
curl -i -X POST \
'https://api.portal.io/public/proposals/123/area-options/201/clientdescription' \
-H 'Accept: application/json' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE' \
-d 'ClientDescription=Standard+Installation+Package'
```
```python python theme={null}
import requests
from portal_auth import sign_request
url = "https://api.portal.io/public/proposals/123/area-options/201/clientdescription"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
content_type = "application/x-www-form-urlencoded"
signature = sign_request("POST", url, content_type, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.post(url, 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
}, data={
"ClientDescription": "Standard Installation Package"
})
print(response.json())
```
```javascript node.js theme={null}
const { signRequest } = require('./portalAuth');
const url = "https://api.portal.io/public/proposals/123/area-options/201/clientdescription";
const timestamp = new Date().toUTCString();
const contentType = "application/x-www-form-urlencoded";
const signature = signRequest("POST", url, contentType, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": contentType,
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
},
body: new URLSearchParams({ ClientDescription: "Standard Installation Package" })
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"id": 123,
"lastModifiedDate": "2026-04-06T00:22:19Z",
"areas": [
{
"id": 55,
"name": "Living Room",
"options": [
{
"id": 201,
"status": "Draft",
"clientDescription": "Standard Installation Package",
"installerDescription": null
}
]
}
]
}
```
# Update an Area Option's Installer Notes
Source: https://docs.portal.io/api-reference/proposals/update-area-installer-notes
POST /public/proposals/{ProposalId}/area-options/{AreaOptionId}/installernotes
POST /public/proposals/{ProposalId}/area-options/{AreaOptionId}/installernotes — Sets internal installer notes for an area option. Not shown to customers.
Use this endpoint to set or update the internal installer notes for a specific area option. These notes are intended for the installer team and are not included on customer-facing proposal documents. Like the client description endpoint, the route requires both the proposal ID and area option ID to confirm that the option belongs to the correct proposal before saving the update.
```bash curl theme={null}
curl -i -X POST \
'https://api.portal.io/public/proposals/123/area-options/201/installernotes' \
-H 'Accept: application/json' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE' \
-d 'InstallerNotes=Use+18-gauge+wire+on+all+runs.+Check+panel+capacity+before+install.'
```
```python python theme={null}
import requests
from portal_auth import sign_request
url = "https://api.portal.io/public/proposals/123/area-options/201/installernotes"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
content_type = "application/x-www-form-urlencoded"
signature = sign_request("POST", url, content_type, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.post(url, 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
}, data={
"InstallerNotes": "Use 18-gauge wire on all runs. Check panel capacity before install."
})
print(response.json())
```
```javascript node.js theme={null}
const { signRequest } = require('./portalAuth');
const url = "https://api.portal.io/public/proposals/123/area-options/201/installernotes";
const timestamp = new Date().toUTCString();
const contentType = "application/x-www-form-urlencoded";
const signature = signRequest("POST", url, contentType, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": contentType,
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
},
body: new URLSearchParams({ InstallerNotes: "Use 18-gauge wire on all runs. Check panel capacity before install." })
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"id": 123,
"lastModifiedDate": "2026-04-06T00:22:19Z",
"areas": [
{
"id": 55,
"name": "Living Room",
"options": [
{
"id": 201,
"status": "Draft",
"clientDescription": "Standard Installation Package",
"installerDescription": "Use 18-gauge wire on all runs. Check panel capacity before install."
}
]
}
]
}
```
# Update Proposal Client-Facing Description
Source: https://docs.portal.io/api-reference/proposals/update-description
POST /public/proposals/{ProposalId}/description
POST /public/proposals/{ProposalId}/description — Updates the client-facing project description shown on customer documents. Returns the updated proposal.
Updates the client-facing project description for a proposal. This description appears on customer-facing proposal documents sent to the client, so it should describe the project in terms the customer will understand. The request accepts a single `Description` string and returns the complete updated proposal detail.
A `409 Conflict` is returned if the proposal is in a terminal state that prevents editing. Check the proposal's `status` field before attempting updates.
```bash curl theme={null}
curl -i -X POST \
'https://sandbox.api.portal.io/public/proposals/1042/description' \
-H 'Accept: application/json' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE' \
-d 'Description=Whole-home+AV+and+lighting+control+system+for+new+construction.'
```
```python python theme={null}
import requests
from portal_auth import sign_request
url = "https://sandbox.api.portal.io/public/proposals/1042/description"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
content_type = "application/x-www-form-urlencoded"
signature = sign_request("POST", url, content_type, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.post(url, 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
}, data={
"Description": "Whole-home AV and lighting control system for new construction."
})
print(response.json())
```
```javascript node.js theme={null}
const { signRequest } = require('./portalAuth');
const url = "https://sandbox.api.portal.io/public/proposals/1042/description";
const timestamp = new Date().toUTCString();
const contentType = "application/x-www-form-urlencoded";
const signature = signRequest("POST", url, contentType, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": contentType,
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
},
body: new URLSearchParams({ Description: "Whole-home AV and lighting control system for new construction." })
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"id": 1042,
"createdDate": "2026-03-10T14:00:00Z",
"lastModifiedDate": "2026-04-06T00:22:19Z",
"lastModifiedByUserDate": "2026-04-06T00:22:19Z",
"financialSummary": {
"partsSubtotal": 8000.00,
"partsTotal": 8000.00,
"laborTotal": 2500.00,
"feeTotal": 0,
"proposalSubtotal": 10500.00,
"salesTax": {
"taxStatus": "Ok",
"total": 892.50
},
"proposalTotal": 11392.50
}
}
```
# Update Proposal Internal Notes
Source: https://docs.portal.io/api-reference/proposals/update-internal-notes
POST /public/proposals/{ProposalId}/internalnotes
POST /public/proposals/{ProposalId}/internalnotes — Sets internal installer notes visible only to the dealer's team. Not shown on client-facing documents.
Updates the internal notes (installer project description) on a proposal. These notes are visible only to your team and are never shown on client-facing proposal documents. Use this field to record installation details, site conditions, or coordination notes that are relevant to your crew but not appropriate for customer documents. The response returns the complete updated proposal detail.
Internal notes are strictly dealer-side content. They are never included in proposal PDFs or portal views that are shared with customers.
```bash curl theme={null}
curl -i -X POST \
'https://sandbox.api.portal.io/public/proposals/1042/internalnotes' \
-H 'Accept: application/json' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE' \
-d 'InternalNotes=Pre-wire+complete.+Confirm+rack+location+with+homeowner+before+rough-in.'
```
```python python theme={null}
import requests
from portal_auth import sign_request
url = "https://sandbox.api.portal.io/public/proposals/1042/internalnotes"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
content_type = "application/x-www-form-urlencoded"
signature = sign_request("POST", url, content_type, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.post(url, 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
}, data={
"InternalNotes": "Pre-wire complete. Confirm rack location with homeowner before rough-in."
})
print(response.json())
```
```javascript node.js theme={null}
const { signRequest } = require('./portalAuth');
const url = "https://sandbox.api.portal.io/public/proposals/1042/internalnotes";
const timestamp = new Date().toUTCString();
const contentType = "application/x-www-form-urlencoded";
const signature = signRequest("POST", url, contentType, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": contentType,
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
},
body: new URLSearchParams({ InternalNotes: "Pre-wire complete. Confirm rack location with homeowner before rough-in." })
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"id": 1042,
"createdDate": "2026-03-10T14:00:00Z",
"lastModifiedDate": "2026-04-06T00:22:19Z",
"lastModifiedByUserDate": "2026-04-06T00:22:19Z",
"financialSummary": {
"partsSubtotal": 8000.00,
"partsTotal": 8000.00,
"laborTotal": 2500.00,
"feeTotal": 0,
"proposalSubtotal": 10500.00,
"salesTax": {
"taxStatus": "Ok",
"total": 892.50
},
"proposalTotal": 11392.50
}
}
```
# Update a Proposal's Name or Salesperson
Source: https://docs.portal.io/api-reference/proposals/update-proposal
POST /public/proposals/{ProposalId}
POST /public/proposals/{ProposalId} — Updates a proposal's name and/or salesperson. Omitted fields keep current values. Returns the full updated proposal.
Performs a partial update on an existing proposal. You can rename the proposal, reassign it to a different salesperson, or do both in a single request. Both body fields are optional — any field you omit keeps its current value. The salesperson must be a user belonging to the same account. The response returns the complete updated proposal detail.
A `409 Conflict` is returned if the proposal is in a terminal state (e.g. accepted, cancelled) that prevents editing. Check the proposal's `status` field before attempting updates.
```bash curl theme={null}
curl -i -X POST \
'https://sandbox.api.portal.io/public/proposals/1042' \
-H 'Accept: application/json' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE' \
-d 'Name=Smith+Residence+Phase+2' \
-d 'SalesPersonId=55'
```
```python python theme={null}
import requests
from portal_auth import sign_request
url = "https://sandbox.api.portal.io/public/proposals/1042"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
content_type = "application/x-www-form-urlencoded"
signature = sign_request("POST", url, content_type, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.post(url, 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
}, data={
"Name": "Smith Residence Phase 2",
"SalesPersonId": "55"
})
print(response.json())
```
```javascript node.js theme={null}
const { signRequest } = require('./portalAuth');
const url = "https://sandbox.api.portal.io/public/proposals/1042";
const timestamp = new Date().toUTCString();
const contentType = "application/x-www-form-urlencoded";
const signature = signRequest("POST", url, contentType, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": contentType,
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
},
body: new URLSearchParams({ Name: "Smith Residence Phase 2", SalesPersonId: "55" })
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"id": 1042,
"createdDate": "2026-03-10T14:00:00Z",
"lastModifiedDate": "2026-04-06T00:22:19Z",
"lastModifiedByUserDate": "2026-04-06T00:22:19Z",
"financialSummary": {
"partsSubtotal": 8000.00,
"partsTotal": 8000.00,
"laborTotal": 2500.00,
"feeTotal": 0,
"proposalSubtotal": 10500.00,
"salesTax": {
"taxStatus": "Ok",
"total": 892.50
},
"proposalTotal": 11392.50
}
}
```
# List Users in Your Account
Source: https://docs.portal.io/api-reference/users/list-users
GET /public/users
GET /public/users — Returns all active users in your dealer account with id, name, email, and permission group. Use to look up SalesPersonId for proposals.
Returns a list of all active users in your Portal.io dealer account. The response contains basic, non-sensitive information: user ID, first name, last name, email address, and the user's permission group. This is a read-only endpoint designed for integrations that need to discover user IDs before assigning them as salespersons on proposals, or for syncing user data with external CRM and ERP systems.
The `id` field from each user in this response is the `SalesPersonId` you supply when creating or updating proposals via the API.
```bash curl theme={null}
curl -i -X GET \
'https://api.portal.io/public/users' \
-H 'Accept: application/json' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE'
```
```python python theme={null}
import requests
from portal_auth import sign_request # See authentication guide
url = "https://api.portal.io/public/users"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
signature = sign_request("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.get(url, headers={
"Accept": "application/json",
"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 { signRequest } = require('./portalAuth'); // See authentication guide
const url = "https://api.portal.io/public/users";
const timestamp = new Date().toUTCString();
const signature = signRequest("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
headers: {
"Accept": "application/json",
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
}
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"users": [
{
"id": 77,
"firstName": "Alex",
"lastName": "Johnson",
"email": "alex.johnson@dealership.com",
"permissionGroup": "Sales"
},
{
"id": 78,
"firstName": "Maria",
"lastName": "Torres",
"email": "maria.torres@dealership.com",
"permissionGroup": "Admin"
}
],
"usersCount": 2
}
```
# Create a Webhook Subscription
Source: https://docs.portal.io/api-reference/webhooks/create-subscription
POST /public/webhook/subscribe
POST /public/webhook/subscribe — Registers a new webhook endpoint for one or more event types. Returns the subscription ID and signing secret key.
Registers a new webhook subscription for your dealer account. Provide an HTTPS callback URL and the list of event types you want to receive. Portal.io will POST a signed JSON payload to your URL whenever a subscribed event occurs. The response includes a `secretKey` — store this securely, as it is used to verify the authenticity of every webhook delivery sent to your endpoint.
The `secretKey` is only returned once at creation time. If you lose it, you will need to update the subscription to generate a new one.
```bash curl theme={null}
curl -i -X POST \
'https://api.portal.io/public/webhook/subscribe' \
-H 'Accept: application/json' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE' \
-d 'Url=https%3A%2F%2Fapp.example.com%2Fwebhooks%2Fportal' \
-d 'Events=proposal.status_changed' \
-d 'Events=proposal.build.status_update' \
-d 'Description=Production+webhook'
```
```python python theme={null}
import requests
from portal_auth import sign_request
url = "https://api.portal.io/public/webhook/subscribe"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
content_type = "application/x-www-form-urlencoded"
signature = sign_request("POST", url, content_type, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.post(url, 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
}, data=[
("Url", "https://app.example.com/webhooks/portal"),
("Events", "proposal.status_changed"),
("Events", "proposal.build.status_update"),
("Description", "Production webhook"),
])
print(response.json())
```
```javascript node.js theme={null}
const { signRequest } = require('./portalAuth');
const url = "https://api.portal.io/public/webhook/subscribe";
const timestamp = new Date().toUTCString();
const contentType = "application/x-www-form-urlencoded";
const signature = signRequest("POST", url, contentType, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": contentType,
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
},
body: new URLSearchParams([
["Url", "https://app.example.com/webhooks/portal"],
["Events", "proposal.status_changed"],
["Events", "proposal.build.status_update"],
["Description", "Production webhook"],
])
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"subscriptionId": 42,
"url": "https://app.example.com/webhooks/portal",
"description": "Production webhook",
"enabled": true,
"secretKey": "whsec_a1b2c3d4e5f6...",
"events": [
"proposal.status_changed",
"proposal.build.status_update"
]
}
```
# Delete a Webhook Subscription
Source: https://docs.portal.io/api-reference/webhooks/delete-subscription
DELETE /public/webhook/unsubscribe/{SubscriptionId}
DELETE /public/webhook/unsubscribe/{SubscriptionId} — Permanently removes a webhook subscription and stops all future event deliveries to its endpoint.
Permanently deletes a webhook subscription and all of its event bindings. Once deleted, Portal.io will no longer send event payloads to the associated endpoint. This action cannot be undone — if you need to re-subscribe later, you must create a new subscription with `POST /public/webhook/subscribe`.
Deletion is immediate and permanent. Ensure you want to stop all deliveries before calling this endpoint.
```bash curl theme={null}
curl -i -X DELETE \
'https://api.portal.io/public/webhook/unsubscribe/42' \
-H 'Accept: application/json' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE'
```
```python python theme={null}
import requests
from portal_auth import sign_request # See authentication guide
url = "https://api.portal.io/public/webhook/unsubscribe/42"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
signature = sign_request("DELETE", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.delete(url, headers={
"Accept": "application/json",
"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 { signRequest } = require('./portalAuth'); // See authentication guide
const url = "https://api.portal.io/public/webhook/unsubscribe/42";
const timestamp = new Date().toUTCString();
const signature = signRequest("DELETE", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
method: "DELETE",
headers: {
"Accept": "application/json",
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
}
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"success": true,
"subscriptionId": 42
}
```
# Portal.io API Webhook Event Reference
Source: https://docs.portal.io/api-reference/webhooks/events
Complete reference for Portal.io API webhook event types, including proposal status changes, AI build status updates, and AI outline generation events.
When you create a webhook subscription, you specify which event types you want to receive. Portal.io delivers a signed HTTP POST payload to your registered HTTPS endpoint each time a subscribed event occurs. This page documents every available event type and the shape of its payload.
Your endpoint must respond with a `2xx` status code to acknowledge receipt. If Portal.io does not receive a `2xx` response, it retries the delivery on this schedule: **1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours**.
## Verifying Webhook Signatures
Every delivery includes an `X-Webhook-Signature` header you should use to verify the request came from Portal.io:
```text theme={null}
X-Webhook-Signature: t=1710000000,v1=5f2b3e7a9d1c4f8e6b2a3c9d7f4e1a6b5c3d2f7e9a1b4c6d8e0f2a3b5c7d9e1
```
* `t` — Unix timestamp when the webhook was generated.
* `v1` — HMAC-SHA256 hex digest.
**Verification steps:**
1. Read the raw request body exactly as received.
2. Extract `t` and `v1` from the `X-Webhook-Signature` header.
3. Rebuild the signed message: `t + "." + rawBody`.
4. Compute `HMAC_SHA256(secretKey, signedMessage)`.
5. Compare your result to `v1`. Reject the request if they do not match.
6. Reject the request if `t` is more than 5 minutes old.
The `secretKey` is the value returned in the `secretKey` field when you created the subscription.
***
## Payload Envelope
All webhook events use a standard envelope format:
```json envelope (data trimmed) theme={null}
{
"id": "evt_abc123",
"type": "proposal.status_changed",
"created": "2026-04-06T00:22:19Z",
"data": {
"id": 4123,
"status": "Accepted"
}
}
```
| Field | Type | Description |
| --------- | ----------------- | --------------------------------------------------------- |
| `id` | string | Unique webhook delivery identifier, prefixed with `evt_`. |
| `type` | string | Event type identifier (dot-notation). |
| `created` | string (ISO 8601) | UTC timestamp when the event was generated. |
| `data` | object | Event-specific payload. |
***
## Event Types
Use these values in the repeated `Events` body parameter when [creating a webhook subscription](/api-reference/webhooks/create-subscription).
AI proposal builder finishes or fails.
AI outline generation completes or fails.
A proposal's status changes (e.g. Draft → Submitted).
***
## proposal.build.status\_update
Fired when the Portal.io AI builder finishes generating a proposal or encounters an error during generation. Subscribe to this event if you need to react when a build completes asynchronously.
### Payload Fields
The `data` object contains:
| Field | Type | Description |
| --------------- | ------ | -------------------------------------------------------------------------------------------- |
| `data.status` | string | Build result status. One of `Building`, `Completed`, `Failed`. |
| `data.proposal` | object | Full proposal detail snapshot (see [proposal object fields](#proposal-object-fields) below). |
```json proposal.build.status_update theme={null}
{
"id": "evt_build_abc123",
"type": "proposal.build.status_update",
"created": "2026-04-06T00:22:19Z",
"data": {
"status": "Completed",
"proposal": {
"id": 4123,
"number": 1001,
"name": "Smith Residence AV",
"status": "Draft",
"createdDate": "2026-04-05T18:00:00Z",
"lastModifiedDate": "2026-04-06T00:22:19Z",
"financialSummary": {
"partsSubtotal": 5200.00,
"partsTotal": 5200.00,
"laborTotal": 800.00,
"feeTotal": 0,
"proposalSubtotal": 6000.00,
"proposalTotal": 6450.00
}
}
}
}
```
***
## proposal.outline.status\_update
Fired when Portal.io's AI outline generation completes or fails. Useful for workflows that wait for an AI-generated scope before proceeding.
### Payload Fields
The `data` object contains:
| Field | Type | Description |
| ----------------- | -------------- | ------------------------------------------------------------------------ |
| `data.proposalId` | integer | ID of the proposal whose outline changed. |
| `data.status` | string | Outline generation status. One of `Generating`, `Completed`, `Failed`. |
| `data.outline` | string or null | The generated outline text. Only populated when `status` is `Completed`. |
```json proposal.outline.status_update theme={null}
{
"id": "evt_outline_def456",
"type": "proposal.outline.status_update",
"created": "2026-04-06T00:20:00Z",
"data": {
"proposalId": 4123,
"status": "Completed",
"outline": "1. Living Room AV System\n2. Master Bedroom Audio\n3. Outdoor Speakers"
}
}
```
***
## proposal.status\_changed
Fired whenever a proposal's status changes. Common transitions include Draft → Submitted, Submitted → Accepted, and Accepted → Completed.
### Payload Fields
The `data` object contains the full proposal detail. See [Proposal Object Fields](#proposal-object-fields) for the complete structure.
```json proposal.status_changed theme={null}
{
"id": "evt_status_ghi789",
"type": "proposal.status_changed",
"created": "2026-04-06T00:22:19Z",
"data": {
"id": 4123,
"number": 1001,
"name": "Smith Residence AV",
"status": "Accepted",
"createdDate": "2026-04-05T18:00:00Z",
"lastSubmittedDate": "2026-04-05T20:00:00Z",
"clientLastOpenedDate": "2026-04-06T00:10:00Z",
"clientLastDecisionDate": "2026-04-06T00:22:00Z",
"lastCompletedDate": null,
"lastModifiedDate": "2026-04-06T00:22:19Z",
"lastModifiedByUserDate": "2026-04-06T00:22:19Z",
"coverpageImageUrl": "https://files.portal.io/covers/4123.jpg",
"aboutUs": "We specialize in custom AV installations.",
"projectDescription": "Full home AV system including living room, bedroom, and outdoor areas.",
"projectTerms": "Net 30 payment terms apply.",
"lastModifiedUser": {
"firstName": "John",
"lastName": "Doe"
},
"customer": {
"id": 88,
"partyType": "Person",
"contactType": "Client",
"firstName": "Jane",
"lastName": "Smith",
"companyName": "",
"contactEmail": "jane.smith@example.com",
"contactEmailCC": "",
"contactPhone": "(555) 123-4567",
"location": {
"id": 1,
"street": "123 Main St",
"suite": "Apt 4B",
"city": "Austin",
"postalCode": "78701",
"state": "Texas",
"stateAbbrev": "TX",
"country": "United States",
"phone": "(555) 123-4567"
}
},
"dealer": {
"companyName": "Premier AV Solutions",
"location": {
"id": 10,
"street": "500 Commerce Dr",
"suite": "Suite 200",
"city": "Dallas",
"postalCode": "75201",
"state": "Texas",
"stateAbbrev": "TX",
"country": "United States",
"phone": "(555) 987-6543"
},
"salesperson": {
"firstName": "John",
"lastName": "Doe",
"id": 5,
"email": "john@premierav.com"
},
"webSiteUrl": "https://premierav.com",
"companyPhone": "(555) 987-6543",
"companyLogoUrl": "https://files.portal.io/logos/premierav.png"
},
"financialSummary": {
"partsSubtotal": 5200.00,
"partsDiscountType": "Percentage",
"partsDiscountPercentage": 0,
"partsDiscount": 0,
"partsDiscountTaxable": 0,
"partsDiscountTaxExempt": 0,
"partsTotal": 5200.00,
"laborTotal": 800.00,
"feeTotal": 0,
"proposalSubtotal": 6000.00,
"salesTax": {
"taxStatus": "Ok",
"total": 450.00,
"calculation": {
"method": "FixedPercentage",
"applyTo": ["Parts", "Labor"],
"partsTax": 8.25,
"partsTaxName": "State Tax",
"laborTax": 8.25,
"laborTaxName": "State Tax",
"hasMultipleTaxSupport": false,
"partsTax2": null,
"partsTax2Name": null,
"laborTax2": null,
"laborTax2Name": null,
"feeTax": null,
"feeTaxName": null,
"feeTax2": null,
"feeTax2Name": null,
"taxLocation": null,
"isTaxJarAvailable": false,
"partsTotalTax": 429.00,
"laborTotalTax": 66.00,
"feeTotalTax": null
}
},
"proposalTotal": 6450.00,
"currency": {
"code": "USD",
"symbol": "$"
}
},
"areas": [
{
"id": 1,
"name": "Living Room",
"options": [
{
"id": 1,
"status": "Accepted",
"lastModifiedDate": "2026-04-06T00:22:19Z",
"clientDescription": "Full surround sound system",
"installerDescription": "Install 5.1 system with in-wall wiring",
"items": [
{
"id": 101,
"parentId": null,
"itemType": "Part",
"referencedItemId": 500,
"createdDate": "2026-04-05T18:00:00Z",
"lastModifiedDate": "2026-04-05T18:00:00Z",
"brand": "Sonos",
"model": "Arc",
"description": "Premium Smart Soundbar",
"name": "Sonos Arc",
"shortDescription": "Soundbar",
"clientNote": null,
"imageUrl": "https://files.portal.io/items/500.jpg",
"msrp": 999.00,
"sellPrice": 899.00,
"cost": 650.00,
"costUpdateDate": "2026-03-01T00:00:00Z",
"supplier": "Acme Audio Distributors",
"quantity": 1,
"total": {
"amount": 899.00,
"currency": {
"code": "USD",
"symbol": "$"
},
"isCombinedPrice": false
},
"isTaxExempt": false,
"isRecurringService": false,
"linkedOrders": [
{
"orderId": 7001,
"orderNumber": 301,
"orderNumberSuffix": "A",
"supplier": "Acme Audio Distributors",
"supplierRef": "PO-12345",
"orderName": "Smith AV Equipment Order",
"orderStatus": "Submitted"
}
]
}
],
"total": 5200.00,
"totalRecurringService": 0
}
]
}
],
"profit": {
"total": 1350.00,
"percentage": 22.5,
"partTotal": 1050.00,
"partPercentage": 20.19,
"laborTotal": 300.00,
"laborPercentage": 37.5,
"isProfitIncludeCos": false
},
"recurringServices": null,
"paymentSchedule": {
"customerDescription": "50% deposit, 50% on completion",
"payments": [
{
"calculation": "Percentage",
"amount": 3225.00,
"due": {
"date": null,
"milestone": "Upon acceptance"
}
},
{
"calculation": "Percentage",
"amount": 3225.00,
"due": {
"date": null,
"milestone": "Upon completion"
}
}
]
},
"paymentRequests": [
{
"id": 3042,
"status": "Paid",
"amount": 3225.00,
"dueDate": "2026-04-20T00:00:00Z",
"description": "50% deposit",
"paymentMethod": "Stripe"
}
],
"changeOrders": []
}
}
```
***
## Proposal Object Fields
The proposal object included in `proposal.build.status_update` and `proposal.status_changed` payloads shares the same structure.
### Top-Level Fields
| Field | Type | Description |
| ------------------------ | ----------------- | ----------------------------------------------------------------------------- |
| `id` | integer | Unique identifier for the proposal. |
| `number` | integer | Human-readable proposal number, unique within the account. |
| `name` | string | Display name of the proposal. |
| `status` | string | Current proposal status (see [proposal statuses](#proposal-statuses)). |
| `createdDate` | string (ISO 8601) | When the proposal was created. |
| `lastSubmittedDate` | string or null | When the proposal was last submitted to the client. |
| `clientLastOpenedDate` | string or null | When the client last opened/viewed the proposal. |
| `clientLastDecisionDate` | string or null | When the client last approved or declined. |
| `lastCompletedDate` | string or null | When the proposal was last marked complete. |
| `lastModifiedDate` | string (ISO 8601) | When the proposal was last modified by any actor. |
| `lastModifiedByUserDate` | string (ISO 8601) | When the proposal was last modified by a human user (not system). |
| `coverpageImageUrl` | string or null | URL of the proposal cover page image. |
| `aboutUs` | string or null | "About Us" text included in the proposal. |
| `projectDescription` | string or null | Project description text. |
| `projectTerms` | string or null | Terms and conditions text. |
| `lastModifiedUser` | object | User who last modified the proposal (see [user fields](#user-fields)). |
| `customer` | object or null | Customer contact (see [customer fields](#customer-fields)). |
| `dealer` | object | Dealer account (see [dealer fields](#dealer-fields)). |
| `financialSummary` | object or null | Financial breakdown (see [financial summary](#financial-summary)). |
| `areas` | array or null | Proposal areas with options and line items (see [area fields](#area-fields)). |
| `profit` | object or null | Profit breakdown (see [profit fields](#profit-fields)). |
| `recurringServices` | object or null | Recurring services summary (see [recurring services](#recurring-services)). |
| `paymentSchedule` | object or null | Payment schedule (see [payment schedule](#payment-schedule)). |
| `paymentRequests` | array or null | Payment requests (see [payment requests](#payment-requests)). |
| `changeOrders` | array | Associated change orders (see [change order fields](#change-order-fields)). |
### Proposal Statuses
| Value | Description |
| ---------------- | ------------------------------------------------- |
| `Undefined` | Unset/default status. |
| `Draft` | Proposal is being edited, not yet sent to client. |
| `Submitted` | Proposal has been sent to the client. |
| `ViewedByClient` | Client has opened/viewed the proposal. |
| `Accepted` | Client has accepted the proposal. |
| `Declined` | Client has declined the proposal. |
| `Delayed` | Proposal has been delayed. |
| `Completed` | Proposal is marked complete. |
| `EmailFailed` | Delivery email to client failed. |
| `Expired` | Proposal has expired. |
### Customer Fields
| Field | Type | Description |
| ---------------- | -------------- | ----------------------------------------------------------- |
| `id` | integer | Contact ID. |
| `partyType` | string | `Person` or `Company`. |
| `contactType` | string | Contact classification (e.g. `Client`). |
| `firstName` | string or null | First name. |
| `lastName` | string or null | Last name. |
| `companyName` | string or null | Company name. |
| `contactEmail` | string or null | Primary email address. |
| `contactEmailCC` | string or null | CC email address. |
| `contactPhone` | string or null | Primary phone number. |
| `location` | object or null | Primary location (see [location fields](#location-fields)). |
### Location Fields
| Field | Type | Description |
| ------------- | --------------- | ------------------------------ |
| `id` | integer or null | Location ID. |
| `street` | string or null | Street address. |
| `suite` | string or null | Suite or unit number. |
| `city` | string or null | City. |
| `postalCode` | string or null | ZIP or postal code. |
| `state` | string or null | Full state name. |
| `stateAbbrev` | string or null | Two-letter state abbreviation. |
| `country` | string or null | Country name. |
| `phone` | string or null | Location phone number. |
### Dealer Fields
| Field | Type | Description |
| ---------------- | -------------- | --------------------------------------------------------------------- |
| `companyName` | string | Company name. |
| `location` | object | Company location (see [location fields](#location-fields)). |
| `salesperson` | object or null | Assigned salesperson (see [salesperson fields](#salesperson-fields)). |
| `webSiteUrl` | string or null | Company website URL. |
| `companyPhone` | string or null | Company phone number. |
| `companyLogoUrl` | string or null | URL of the company logo. |
### Salesperson Fields
Extends [user fields](#user-fields) with:
| Field | Type | Description |
| ----------- | --------------- | -------------- |
| `firstName` | string | First name. |
| `lastName` | string or null | Last name. |
| `id` | integer or null | User ID. |
| `email` | string | Email address. |
### User Fields
| Field | Type | Description |
| ----------- | -------------- | ----------- |
| `firstName` | string | First name. |
| `lastName` | string or null | Last name. |
### Financial Summary
| Field | Type | Description |
| ------------------------- | -------------- | -------------------------------------------------------------------- |
| `partsSubtotal` | number | Subtotal for parts before discount. |
| `partsDiscountType` | string or null | Discount type. One of `Percentage`, `Fixed`. |
| `partsDiscountPercentage` | number or null | Discount percentage applied to parts. |
| `partsDiscount` | number or null | Discount amount applied to parts. |
| `partsDiscountTaxable` | number or null | Taxable portion of parts discount. |
| `partsDiscountTaxExempt` | number or null | Tax-exempt portion of parts discount. |
| `partsTotal` | number | Total for parts after discount. |
| `laborTotal` | number | Total labor cost. |
| `feeTotal` | number | Total fees. |
| `proposalSubtotal` | number | Subtotal before tax. |
| `salesTax` | object or null | Sales tax details (see [sales tax](#sales-tax)). |
| `proposalTotal` | number or null | Grand total including tax. |
| `currency` | object or null | Currency with `code` (ISO 4217, e.g. `USD`) and `symbol` (e.g. `$`). |
### Sales Tax
| Field | Type | Description |
| ------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `taxStatus` | string | One of `Undefined`, `Ok`, `NoState`, `OutOfCountry`, `NoClient`, `NoClientAddress`, `IncompleteClientAddress`, `NoCompanyAddress`, `TaxableStateDeclined`. |
| `total` | number or null | Total sales tax amount. |
| `calculation` | object or null | Tax calculation details (see [tax calculation](#tax-calculation)). |
### Tax Calculation
| Field | Type | Description |
| ----------------------- | -------------- | ----------------------------------------------------------------------------------------- |
| `method` | string | One of `ClientLocation`, `CompanyLocation`, `FixedPercentage`, `None`. |
| `applyTo` | array\ | Array of categories tax applies to. Each value is one of `None`, `Parts`, `Labor`, `Fee`. |
| `partsTax` | number or null | Parts tax rate. |
| `partsTaxName` | string or null | Parts tax label. |
| `laborTax` | number or null | Labor tax rate. |
| `laborTaxName` | string or null | Labor tax label. |
| `hasMultipleTaxSupport` | boolean | Whether multiple tax rates are configured. |
| `partsTax2` | number or null | Second parts tax rate. |
| `partsTax2Name` | string or null | Second parts tax label. |
| `laborTax2` | number or null | Second labor tax rate. |
| `laborTax2Name` | string or null | Second labor tax label. |
| `feeTax` | number or null | Fee tax rate. |
| `feeTaxName` | string or null | Fee tax label. |
| `feeTax2` | number or null | Second fee tax rate. |
| `feeTax2Name` | string or null | Second fee tax label. |
| `taxLocation` | object or null | Tax jurisdiction location (see [location fields](#location-fields)). |
| `isTaxJarAvailable` | boolean | Whether TaxJar integration is available. |
| `partsTotalTax` | number or null | Calculated total tax on parts. |
| `laborTotalTax` | number or null | Calculated total tax on labor. |
| `feeTotalTax` | number or null | Calculated total tax on fees. |
### Area Fields
| Field | Type | Description |
| --------- | ------- | ------------------------------------------------------------- |
| `id` | integer | Area ID. |
| `name` | string | Area name. |
| `options` | array | Area options (see [area option fields](#area-option-fields)). |
### Area Option Fields
| Field | Type | Description |
| ----------------------- | ----------------- | ------------------------------------------------------ |
| `id` | integer | Option ID. |
| `status` | string | Option status. One of `Draft`, `Accepted`, `Declined`. |
| `lastModifiedDate` | string (ISO 8601) | When the option was last modified. |
| `clientDescription` | string or null | Client-facing description. |
| `installerDescription` | string or null | Installer-facing notes. |
| `items` | array or null | Line items (see [item fields](#item-fields)). |
| `total` | number | Option total amount. |
| `totalRecurringService` | number | Recurring service total for this option. |
### Item Fields
| Field | Type | Description |
| -------------------- | ----------------- | -------------------------------------------------------------------------------------------------------- |
| `id` | integer | Item ID. |
| `parentId` | integer or null | Parent item ID (for sub-items). |
| `itemType` | string | One of `Part`, `Labor`, `CustomItem`, `Fee`. |
| `referencedItemId` | integer | Catalog item ID reference. |
| `createdDate` | string (ISO 8601) | When the item was added. |
| `lastModifiedDate` | string (ISO 8601) | When the item was last modified. |
| `brand` | string or null | Item brand. |
| `model` | string or null | Item model. |
| `description` | string or null | Full description. |
| `name` | string or null | Item name. |
| `shortDescription` | string or null | Short description. |
| `clientNote` | string or null | Note visible to client. |
| `imageUrl` | string or null | Item image URL. |
| `msrp` | number or null | Manufacturer's suggested retail price. |
| `sellPrice` | number or null | Sell price per unit. |
| `cost` | number or null | Cost per unit. |
| `costUpdateDate` | string or null | When cost was last updated. |
| `supplier` | string or null | Supplier name. |
| `quantity` | number | Quantity. |
| `total` | object | Item total with `amount` (number), `currency` (object or null), and `isCombinedPrice` (boolean or null). |
| `isTaxExempt` | boolean or null | Whether the item is tax-exempt. |
| `isRecurringService` | boolean or null | Whether this is a recurring service item. |
| `linkedOrders` | array or null | Linked purchase orders (see [linked order fields](#linked-order-fields)). |
### Linked Order Fields
| Field | Type | Description |
| ------------------- | -------------- | ---------------------------------------------------------------------------------------------------- |
| `orderId` | integer | Order ID. |
| `orderNumber` | integer | Order number. |
| `orderNumberSuffix` | string | Order number suffix (e.g. `A`, `B`). |
| `supplier` | string or null | Supplier name. |
| `supplierRef` | string or null | Supplier reference/PO number. |
| `orderName` | string | Order name. |
| `orderStatus` | string | One of `Undefined`, `Draft`, `Submitted`, `ViewedBySupplier`, `Accepted`, `Received`, `EmailFailed`. |
### Change Order Fields
| Field | Type | Description |
| ------------------------ | ----------------- | ------------------------------------------------------------------------------- |
| `id` | integer | Change order ID. |
| `number` | integer | Change order number. |
| `name` | string | Change order name. |
| `status` | string | Status (see [proposal statuses](#proposal-statuses)). |
| `total` | object or null | Total with `changeOrderTotal` (number or null) and `currency` (object or null). |
| `customer` | object or null | Customer contact (see [customer fields](#customer-fields)). |
| `createdDate` | string (ISO 8601) | When the change order was created. |
| `lastModifiedDate` | string (ISO 8601) | When last modified. |
| `lastModifiedByUserDate` | string or null | When last modified by a human user. |
### Profit Fields
| Field | Type | Description |
| -------------------- | --------------- | ------------------------------------------------- |
| `total` | number or null | Total profit amount. |
| `percentage` | number or null | Overall profit percentage. |
| `partTotal` | number or null | Profit on parts. |
| `partPercentage` | number or null | Profit percentage on parts. |
| `laborTotal` | number or null | Profit on labor. |
| `laborPercentage` | number or null | Profit percentage on labor. |
| `isProfitIncludeCos` | boolean or null | Whether profit calculation includes cost of sale. |
### Recurring Services
| Field | Type | Description |
| ----------------------- | ------ | -------------------------------------------------------------------------------------------------------------------- |
| `items` | array | Recurring service items, each with `name` (string), `sellPrice` (number), `quantity` (number), `totalSell` (number). |
| `totalRecurringService` | number | Total recurring service amount. |
### Payment Schedule
| Field | Type | Description |
| --------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `customerDescription` | string or null | Customer-facing payment schedule description. |
| `payments` | array or null | Scheduled payments, each with `calculation` (string or null), `amount` (number), and `due` object containing `date` (string or null) and `milestone` (string or null). |
### Payment Requests
| Field | Type | Description |
| --------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | integer | Payment request ID. |
| `status` | string | Payment status. One of `Undefined`, `Draft`, `Submitted`, `Viewed`, `Paid`, `Declined`, `Refunded`, `Pending`, `RequiresAction`, `Verifying`, `Cancelled`. |
| `amount` | number or null | Payment amount. |
| `dueDate` | string (ISO 8601) | Payment due date. |
| `description` | string or null | Payment description. |
| `paymentMethod` | string or null | Payment method used. |
# List Your Webhook Subscriptions
Source: https://docs.portal.io/api-reference/webhooks/list-subscriptions
GET /public/webhook/subscriptions
GET /public/webhook/subscriptions — Returns all active webhook subscriptions for your account, including URL, events, and enabled status.
Returns all webhook subscriptions registered for your dealer account. Use this endpoint to audit your current subscriptions, retrieve subscription IDs for update or delete operations, or verify which event types are configured for each endpoint.
```bash curl theme={null}
curl -i -X GET \
'https://api.portal.io/public/webhook/subscriptions' \
-H 'Accept: application/json' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE'
```
```python python theme={null}
import requests
from portal_auth import sign_request # See authentication guide
url = "https://api.portal.io/public/webhook/subscriptions"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
signature = sign_request("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.get(url, headers={
"Accept": "application/json",
"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 { signRequest } = require('./portalAuth'); // See authentication guide
const url = "https://api.portal.io/public/webhook/subscriptions";
const timestamp = new Date().toUTCString();
const signature = signRequest("GET", url, "", timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
headers: {
"Accept": "application/json",
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
}
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
[
{
"subscriptionId": 42,
"url": "https://app.example.com/webhooks/portal",
"description": "Production webhook",
"enabled": true,
"events": [
"proposal.status_changed",
"proposal.build.status_update"
]
},
{
"subscriptionId": 43,
"url": "https://staging.example.com/webhooks/portal",
"description": "Staging webhook",
"enabled": false,
"events": [
"proposal.outline.status_update"
]
}
]
```
# Update a Webhook Subscription
Source: https://docs.portal.io/api-reference/webhooks/update-subscription
POST /public/webhook/subscription/{SubscriptionId}
POST .../webhook/subscription/{SubscriptionId} — Partially updates a webhook subscription. Only supplied fields change; omitted fields keep current values.
Partially updates an existing webhook subscription. This is a patch-style operation — only fields you include in the request body are modified; any field you omit (or send as `null`) retains its current value. At least one field must be provided. You can use this endpoint to change the callback URL, update the event list, toggle the subscription on or off, or update the description.
The source API uses `POST` (not `PUT`) for this update operation. Use the path `POST /public/webhook/subscription/{SubscriptionId}`.
```bash curl theme={null}
curl -i -X POST \
'https://api.portal.io/public/webhook/subscription/42' \
-H 'Accept: application/json' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'X-MSS-API-APPID: YOUR_APP_ID' \
-H 'X-MSS-API-USERKEY: YOUR_USER_KEY' \
-H 'X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT' \
-H 'X-MSS-SIGNATURE: BASE64_SIGNATURE' \
-d 'Url=https%3A%2F%2Fapp.example.com%2Fwebhooks%2Fportal-v2' \
-d 'Enabled=true' \
-d 'Events=proposal.status_changed' \
-d 'Events=proposal.build.status_update' \
-d 'Events=proposal.outline.status_update'
```
```python python theme={null}
import requests
from portal_auth import sign_request
url = "https://api.portal.io/public/webhook/subscription/42"
timestamp = "Mon, 06 Apr 2026 00:22:19 GMT"
content_type = "application/x-www-form-urlencoded"
signature = sign_request("POST", url, content_type, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY")
response = requests.post(url, 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
}, data=[
("Url", "https://app.example.com/webhooks/portal-v2"),
("Enabled", "true"),
("Events", "proposal.status_changed"),
("Events", "proposal.build.status_update"),
("Events", "proposal.outline.status_update"),
])
print(response.json())
```
```javascript node.js theme={null}
const { signRequest } = require('./portalAuth');
const url = "https://api.portal.io/public/webhook/subscription/42";
const timestamp = new Date().toUTCString();
const contentType = "application/x-www-form-urlencoded";
const signature = signRequest("POST", url, contentType, timestamp, "YOUR_USER_KEY", "YOUR_SECRET_KEY");
const response = await fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": contentType,
"X-MSS-API-APPID": "YOUR_APP_ID",
"X-MSS-API-USERKEY": "YOUR_USER_KEY",
"X-MSS-CUSTOM-DATE": timestamp,
"X-MSS-SIGNATURE": signature
},
body: new URLSearchParams([
["Url", "https://app.example.com/webhooks/portal-v2"],
["Enabled", "true"],
["Events", "proposal.status_changed"],
["Events", "proposal.build.status_update"],
["Events", "proposal.outline.status_update"],
])
});
const data = await response.json();
console.log(data);
```
```json 200 theme={null}
{
"subscriptionId": 42,
"url": "https://app.example.com/webhooks/portal-v2",
"description": "Production webhook",
"enabled": true,
"secretKey": "whsec_a1b2c3d4e5f6...",
"events": [
"proposal.status_changed",
"proposal.build.status_update",
"proposal.outline.status_update"
]
}
```
# Zapier Trigger: Change Order Status Change
Source: https://docs.portal.io/api-reference/zapier-triggers/change-order-status-change
Zapier trigger that fires when a change order's status changes in Portal.io.
The Portal.io Zapier integration fires this trigger when a change order's status changes in your account. A change order is a child proposal linked to a parent proposal.
There is no separate endpoint to call: change orders travel over the same [proposal status change](/api-reference/zapier-triggers/proposal-status-change) trigger, and the payload is identical. Portal.io decides which of the two Zapier trigger types to deliver from whether the proposal has a parent, so a Zap subscribed to change orders receives only child proposals and a Zap subscribed to proposals receives only top-level ones.
# Zapier Trigger: Order Status Change
Source: https://docs.portal.io/api-reference/zapier-triggers/order-status-change
GET /zapier/trigger/order
Zapier trigger that fires when an order's status changes in Portal.io.
This endpoint is used by the Portal.io Zapier integration. It fires when an order's status changes in your Portal.io account — for example, when an order moves from Draft to Submitted, or from Submitted to Accepted.
# Zapier Trigger: Payment Status Change
Source: https://docs.portal.io/api-reference/zapier-triggers/payment-status-change
GET /zapier/trigger/payment
Zapier trigger that fires when a payment's status changes in Portal.io.
This endpoint is used by the Portal.io Zapier integration. It fires when a payment's status changes in your Portal.io account — for example, when a payment moves from Submitted to Paid, or from Paid to Refunded.
# Zapier Trigger: Person Modification
Source: https://docs.portal.io/api-reference/zapier-triggers/person-modification
GET /zapier/trigger/person
Zapier trigger that fires when a contact record is created, updated, or deleted in Portal.io.
This endpoint is used by the Portal.io Zapier integration. It fires when a contact (person) record is created, updated, or deleted in your Portal.io account.
# Zapier Trigger: Proposal Status Change
Source: https://docs.portal.io/api-reference/zapier-triggers/proposal-status-change
GET /zapier/trigger/proposal
Zapier trigger that fires when a proposal's status changes in Portal.io.
This endpoint is used by the Portal.io Zapier integration. It fires when a proposal's status changes in your Portal.io account — for example, when a proposal moves from Draft to Submitted, or from Submitted to Accepted.
Each Zapier subscription can filter by specific statuses so that your Zap only triggers on transitions you care about.
# Authenticate with the Portal.io API
Source: https://docs.portal.io/authentication/overview
Portal.io API uses HMAC-SHA256 request signing. Learn how to get your API credentials, exchange them for a User Key, and include auth headers on every request.
Every Portal.io API request must be signed using HMAC-SHA256. Authentication works in two stages: first you exchange your Portal.io username and password for a User API Key, then you include that User Key in the signature of all subsequent requests. There are no session cookies and no OAuth flows — each request is independently authenticated by its signature headers.
## How authentication works
**Step 1 — Exchange credentials for a User Key.**
Call `GET /authenticate/apikeyexchange` with your username and password as query parameters. For this request only, the `X-MSS-API-USERKEY` header is an empty string and is excluded from the HMAC canonical message. A successful response returns a User API Key at `meta.apiKey` in the response body.
**Step 2 — Sign all subsequent requests with the User Key.**
Include your User Key in the `X-MSS-API-USERKEY` header and incorporate it into the HMAC canonical message for every request you make after the initial exchange.
See [Signing requests](/authentication/signing-requests) for the full details on building the canonical message and computing the signature.
## Required headers
Every request to the Portal.io API must include the following headers:
| Header | Description | Example |
| ------------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| `Accept` | Must be `application/json` | `application/json` |
| `X-MSS-API-APPID` | Your API Application Key, provided by your Portal.io representative | `D78C5B43-60B7-4F06-9372-0B3F9010D042` |
| `X-MSS-API-USERKEY` | The User API Key obtained from the credential exchange. Use an empty string for the initial exchange request. | `qBOSOYDeZaSzTxqMCL1Kr66JpU2H6wHCLz7xviZUOcA=` |
| `X-MSS-CUSTOM-DATE` | Current UTC date and time in RFC 7231 format. Must exactly match the timestamp used in your HMAC signature. | `Mon, 06 Apr 2026 00:22:19 GMT` |
| `X-MSS-SIGNATURE` | HMAC-SHA256 of the canonical message, Base64 encoded | `3Tsd9...` |
## Obtaining credentials
To get your **API Application Key** and **Secret Key**:
1. Create a free sandbox account at [https://sandbox.portal.io](https://sandbox.portal.io) and verify your email address.
2. Contact your Portal.io representative. Let them know you have a sandbox account, and they will provide both keys.
Your **User API Key** is obtained programmatically by calling the `GET /authenticate/apikeyexchange` endpoint with your Portal.io username and password. See the [quickstart](/quickstart) for a full walkthrough with curl examples.
## Common errors
| Status | Cause | Fix |
| ------ | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `401` | Invalid credentials or unverified email address | Confirm your username and password are correct. Check that you verified your email after signing up. |
| `401` | Signature mismatch (body: `"You are not authorized. Your request signature (hash) is invalid."`) | Ensure the canonical message is assembled in the correct order (see [Signing requests](/authentication/signing-requests)), that you are using the base URL without query parameters, that your timestamp in `X-MSS-CUSTOM-DATE` exactly matches the value in the canonical message, and that you are using the Secret Key as raw ASCII bytes without Base64-decoding it first. For POST requests, confirm the `Content-Type` is included in the canonical message. |
| `403` | Insufficient permissions | The authenticated user does not have permission for the requested resource or action. |
For a detailed explanation of how to construct the HMAC signature, see [Signing requests](/authentication/signing-requests).
# Sign Portal.io API Requests with HMAC-SHA256
Source: https://docs.portal.io/authentication/signing-requests
Step-by-step guide to building the canonical message and computing the HMAC-SHA256 signature required on every Portal.io API request.
Every Portal.io API request must include an `X-MSS-SIGNATURE` header containing a Base64-encoded HMAC-SHA256 signature. The signature is computed from a canonical message you build from properties of the request itself, then signed using your Secret Key. This page explains exactly how to construct the canonical message and compute the signature.
## Canonical message format
The canonical message is a single string formed by concatenating these components **with no separator**:
**For GET requests:**
```
[HTTP method][base URL][timestamp][user API key]
```
**For POST, PUT, and other non-GET requests:**
```
[HTTP method][base URL][content type][timestamp][user API key]
```
The components map to your request as follows:
* **HTTP method** — uppercase, e.g. `GET`, `POST`, `PUT`
* **Base URL** — the scheme, host, and path only. **Do not include query parameters.** For example, use `https://api.portal.io/public/proposals` even if the actual request URL has `?PageNumber=1&PageSize=10` appended.
* **Content type** — the exact value of the `Content-Type` header. Include this segment only for non-GET requests. The value differs per endpoint, so take it from that endpoint's reference page rather than assuming one. Whatever you send, the content type in your signing string must match the `Content-Type` header exactly, or the request fails with `401`. For `multipart/form-data` the signing string must include the full `boundary=` parameter, not just `multipart/form-data` — see [Sign multipart requests](#sign-multipart-requests) below.
* **Timestamp** — the exact value you send in `X-MSS-CUSTOM-DATE`
* **User API key** — the exact value you send in `X-MSS-API-USERKEY`
## Rules
* Use the base URL **without** query parameters. This is by design — query parameters are sent in the request as normal, but the server intentionally excludes them from signature verification. Only the scheme, host, and path are signed.
* For `GET` requests, omit the content-type segment completely. Do not include an empty string in its place.
* For non-`GET` requests, include the exact `Content-Type` value from the request header. The value in the signing string and the value in the header must match exactly — including case and any suffixes (e.g. `application/json`, not `Application/JSON`).
* The request body is **not** part of the canonical message. Only the content type is included, not the body itself.
* The timestamp must exactly match the value in `X-MSS-CUSTOM-DATE`, character for character.
* The user API key must exactly match the value in `X-MSS-API-USERKEY`, character for character.
* For the initial credential exchange, the user API key is an empty string in both the header and the canonical message.
Do NOT Base64-decode the Secret Key before computing the HMAC. Use it exactly as provided — as raw ASCII bytes. Base64-decoding the key before use is a common mistake that produces an invalid signature.
## Examples
### GET request (credential exchange)
For the initial credential exchange, where the user API key is empty, the canonical message looks like this:
```text theme={null}
GEThttps://api.portal.io/authenticate/apikeyexchangeMon, 06 Apr 2026 00:22:19 GMT
```
Breaking that down:
* Method: `GET`
* Base URL: `https://api.portal.io/authenticate/apikeyexchange`
* Content type: *(omitted — this is a GET request)*
* Timestamp: `Mon, 06 Apr 2026 00:22:19 GMT`
* User API key: *(empty string — this is the initial exchange)*
Note that the actual HTTP request includes query parameters (`?UserName=...&Password=...`), but the canonical message uses only the base URL without them.
### GET request (with query parameters)
When listing proposals with pagination, the canonical message is:
```text theme={null}
GEThttps://api.portal.io/public/proposalsMon, 06 Apr 2026 00:22:19 GMTqBOSOYDeZaSzTxqMCL1Kr66JpU2H6wHCLz7xviZUOcA=
```
The actual request URL includes `?PageNumber=1&PageSize=10`, but those query parameters are not in the signed string.
### POST request (adding an area to a proposal)
For a POST request, the content type is included between the URL and the timestamp:
```text theme={null}
POSThttps://api.portal.io/public/proposals/1042/areaapplication/x-www-form-urlencodedMon, 06 Apr 2026 00:22:19 GMTqBOSOYDeZaSzTxqMCL1Kr66JpU2H6wHCLz7xviZUOcA=
```
Breaking that down:
* Method: `POST`
* Base URL: `https://api.portal.io/public/proposals/1042/area`
* Content type: `application/x-www-form-urlencoded`
* Timestamp: `Mon, 06 Apr 2026 00:22:19 GMT`
* User API key: `qBOSOYDeZaSzTxqMCL1Kr66JpU2H6wHCLz7xviZUOcA=`
The request body (`Name=Living+Room`) is sent normally but is **not** part of the canonical message.
### Sign multipart requests
For `multipart/form-data` requests, the content type segment of the canonical message must include the full `boundary=` parameter — the same value you send in the `Content-Type` header on the wire.
For an upload to `POST /public/api/proposals/{ProposalId}/ai/content`, the canonical message looks like this:
```text theme={null}
POSThttps://api.portal.io/public/api/proposals/12345/ai/contentmultipart/form-data; boundary=PortalBoundary1748392012345Mon, 06 Apr 2026 00:22:19 GMTqBOSOYDeZaSzTxqMCL1Kr66JpU2H6wHCLz7xviZUOcA=
```
Breaking that down:
* Method: `POST`
* Base URL: `https://api.portal.io/public/api/proposals/12345/ai/content`
* Content type: `multipart/form-data; boundary=PortalBoundary1748392012345`
* Timestamp: `Mon, 06 Apr 2026 00:22:19 GMT`
* User API key: `qBOSOYDeZaSzTxqMCL1Kr66JpU2H6wHCLz7xviZUOcA=`
Use the same boundary string in both the `Content-Type` header you send and the canonical message you sign. Most HTTP clients generate a random boundary internally and don't expose it. To work around this, either pre-build the multipart body and `Content-Type` yourself before signing, or use a client like Python's `requests-toolbelt.MultipartEncoder` that lets you fix the boundary at construction time. The full request body (form fields and file parts) is still **not** included in the canonical message — only the content type with the boundary parameter is.
If a multipart endpoint is called with a query string (for example `?isMultiChunkUpload=true`), the canonical message still uses the base URL **without** the query string, following the same rule as every other Portal.io endpoint.
## Computing the signature
Once you have the canonical message, compute the HMAC-SHA256 using your Secret Key as raw ASCII bytes, then Base64-encode the raw digest.
```python python theme={null}
import hmac
import hashlib
import base64
from urllib.parse import urlsplit, urlunsplit
def sign_request(method, url, content_type, timestamp, user_key, secret_key):
# Strip query string — only scheme + host + path are signed
parts = urlsplit(url)
base_url = urlunsplit((parts.scheme, parts.netloc, parts.path, '', ''))
# Build canonical message
message_parts = [method.upper(), base_url]
if method.upper() != "GET":
message_parts.append(content_type)
message_parts.append(timestamp)
message_parts.append(user_key)
canonical = "".join(message_parts)
# Compute HMAC-SHA256
# Use secret_key as raw ASCII bytes — do NOT base64-decode it first
signature = hmac.new(
secret_key.encode("ascii"),
canonical.encode("utf-8"),
hashlib.sha256
).digest()
return base64.b64encode(signature).decode("ascii")
```
```javascript node.js theme={null}
const crypto = require('crypto');
function signRequest(method, url, contentType, timestamp, userKey, secretKey) {
// Strip query string — only scheme + host + path are signed
const baseUrl = new URL(url);
baseUrl.search = '';
const urlForSigning = baseUrl.toString().replace(/\/$/, '');
// Build canonical message
let parts = [method.toUpperCase(), urlForSigning];
if (method.toUpperCase() !== 'GET') {
parts.push(contentType);
}
parts.push(timestamp, userKey);
const canonical = parts.join('');
// Compute HMAC-SHA256
// Use secretKey as raw ASCII bytes — do NOT base64-decode it first
const hmac = crypto.createHmac('sha256', Buffer.from(secretKey, 'ascii'));
hmac.update(canonical, 'utf8');
return hmac.digest('base64');
}
```
## Full request example
Here is how the computed signature fits into a complete request. This example performs the initial credential exchange:
```bash theme={null}
curl -i -X GET \
"https://sandbox.api.portal.io/authenticate/apikeyexchange?UserName=user%40example.com&Password=MyP%40ss123" \
-H "Accept: application/json" \
-H "X-MSS-API-APPID: YOUR_APP_ID" \
-H "X-MSS-API-USERKEY: " \
-H "X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT" \
-H "X-MSS-SIGNATURE: BASE64_HMAC_SIGNATURE"
```
Replace `BASE64_HMAC_SIGNATURE` with the output of your signing function. Replace `YOUR_APP_ID` with your API Application Key. The `X-MSS-API-USERKEY` header is intentionally empty for this call.
Once you have your User API Key, include it in both `X-MSS-API-USERKEY` and your canonical message on all subsequent requests.
# Changelog
Source: https://docs.portal.io/changelog
A record of additions, changes, and fixes to the Portal.io API. Check here to see what's new and whether your integration needs updating.
This page tracks changes to the Portal.io API itself — new endpoints, changed behavior, and deprecations. Documentation-only updates are not listed here.
Entries are in reverse chronological order. Breaking changes are marked with 🚨 so you can quickly identify anything that may require updates to your integration.
***
## August 27, 2026
**Catalog search filter**
New optional `IsCompanyApproved` query parameter for filtering catalog search results to items your company has approved:
* [`GET /public/catalog`](/api-reference/catalog/search-items) — Set `IsCompanyApproved=true` to return only company-approved items. It composes with the endpoint's other filters. Combined with `IsFavorite=true`, it returns items that are both a favorite and company-approved.
When `IsCompanyApproved=true`, the response's `favoriteItems` array is empty and `favoriteItemCount` is `0`, regardless of `IsFavorite`. Read matching items from `items`.
***
## July 24, 2026
**Proposal item endpoints**
New endpoints to add, remove, and replace items on a proposal:
* [`POST /public/proposals/{ProposalId}/items`](/api-reference/proposals/items/add-items) — Add items to a proposal
* [`DELETE /public/proposals/{ProposalId}/items`](/api-reference/proposals/items/delete-items) — Delete items from a proposal
* [`POST /public/proposals/{ProposalId}/items/{ProposalItemId}/replace`](/api-reference/proposals/items/replace-item) — Replace a proposal item with a different catalog item
New endpoints to update pricing on proposal items:
* [`POST /public/proposals/{ProposalId}/items/{ProposalItemId}/qty`](/api-reference/proposals/items/update-item-quantity) — Update item quantity
* [`POST /public/proposals/{ProposalId}/items/{ProposalItemId}/sellprice`](/api-reference/proposals/items/update-item-sell-price) — Update item sell price
* [`POST /public/proposals/{ProposalId}/items/{ProposalItemId}/cost`](/api-reference/proposals/items/update-item-cost) — Update item supplier cost
* [`POST /public/proposals/{ProposalId}/items/{ProposalItemId}/msrp`](/api-reference/proposals/items/update-item-msrp) — Update item MSRP
* [`POST /public/proposals/{ProposalId}/items/{ProposalItemId}/combineprice`](/api-reference/proposals/items/set-item-combined-pricing) — Toggle combined pricing for nested items
* [`POST /public/proposals/{ProposalId}/items/costupdate`](/api-reference/proposals/items/refresh-item-costs) — Refresh proposal item costs from the latest catalog costs
New endpoints to update item-level content:
* [`POST /public/proposals/{ProposalId}/items/{ProposalItemId}/shortdescription`](/api-reference/proposals/items/update-item-short-description) — Update item short description
* [`POST /public/proposals/{ProposalId}/items/{ProposalItemId}/note`](/api-reference/proposals/items/update-item-note) — Update item client-facing note
* [`POST /public/proposals/{ProposalId}/items/{ProposalItemId}/image`](/api-reference/proposals/items/update-item-image) — Update item image
New endpoints to update item-level flags:
* [`POST /public/proposals/{ProposalId}/items/{ProposalItemId}/taxexempt`](/api-reference/proposals/items/update-item-tax-exempt) — Update item tax-exempt flag
* [`POST /public/proposals/{ProposalId}/items/{ProposalItemId}/recurringservice`](/api-reference/proposals/items/update-item-recurring-service) — Update item recurring-service flag
New endpoints to reorganize items within a proposal:
* [`POST /public/proposals/{ProposalId}/items/move`](/api-reference/proposals/items/move-items) — Move items to a different area option
* [`POST /public/proposals/{ProposalId}/items/copy`](/api-reference/proposals/items/copy-items) — Copy items to one or more area options
New endpoints to manage item suppliers:
* [`GET /public/proposals/{ProposalId}/items/{ProposalItemId}/suppliers`](/api-reference/proposals/items/list-item-suppliers) — List suppliers available for an item
* [`POST /public/proposals/{ProposalId}/items/{ProposalItemId}/supplier/{SupplierId}`](/api-reference/proposals/items/set-item-supplier) — Set the supplier for an item
***
## April 14, 2026
**Proposal management, AI Builder, and webhook endpoints**
New POST endpoints for the full proposal lifecycle:
* [`POST /public/proposals`](/api-reference/proposals/create-proposal) — Create a new proposal
* [`POST /public/proposals/{ProposalId}`](/api-reference/proposals/update-proposal) — Update proposal name and status
* [`POST /public/proposals/{ProposalId}/description`](/api-reference/proposals/update-description) — Update proposal description
* [`POST /public/proposals/{ProposalId}/internalnotes`](/api-reference/proposals/update-internal-notes) — Update internal notes
* [`POST /public/proposals/{ProposalId}/contact/{ContactId}`](/api-reference/proposals/assign-contact) — Assign a contact to a proposal
* [`POST /public/proposals/{ProposalId}/location/{LocationId}`](/api-reference/proposals/assign-location) — Assign a location to a proposal (triggers tax calculation)
* [`POST /public/proposals/{ProposalId}/area`](/api-reference/proposals/add-area) — Add an area to a proposal
* [`POST /public/proposals/{ProposalId}/area/{AreaId}/option`](/api-reference/proposals/add-area-option) — Add an option to an area
* [`POST /public/proposals/{ProposalId}/area/{AreaId}/option/{OptionId}/clientdescription`](/api-reference/proposals/update-area-client-description) — Update area option client description
* [`POST /public/proposals/{ProposalId}/area/{AreaId}/option/{OptionId}/installernotes`](/api-reference/proposals/update-area-installer-notes) — Update area option installer notes
New endpoints for AI-powered proposal generation:
* [`POST /public/ai/content`](/api-reference/ai-builder/upload-content) — Upload reference content for AI proposals
* [`DELETE /public/ai/content/{ContentId}`](/api-reference/ai-builder/delete-content) — Delete uploaded content
* [`GET /public/ai/content`](/api-reference/ai-builder/list-content) — List uploaded content
* [`POST /public/ai/generate-outline`](/api-reference/ai-builder/generate-outline) — Generate a proposal outline from uploaded content
* [`GET /public/ai/outline/{OutlineId}`](/api-reference/ai-builder/get-outline) — Retrieve a generated outline
* [`POST /public/ai/build-proposal`](/api-reference/ai-builder/build-proposal) — Build a full proposal from an outline
New endpoints for managing webhook subscriptions and receiving real-time event notifications:
* [`POST /public/webhook/subscribe`](/api-reference/webhooks/create-subscription) — Create a webhook subscription
* [`GET /public/webhook/subscriptions`](/api-reference/webhooks/list-subscriptions) — List active subscriptions
* [`POST /public/webhook/subscription/{SubscriptionId}`](/api-reference/webhooks/update-subscription) — Update a subscription
* [`DELETE /public/webhook/unsubscribe/{SubscriptionId}`](/api-reference/webhooks/delete-subscription) — Delete a subscription
Supported event types: `proposal.status_changed`, `proposal.build.status_update`, `proposal.outline.status_update`. See [Webhook Events](/api-reference/webhooks/events) for payload details.
# Understanding the Portal.io Catalog
Source: https://docs.portal.io/concepts/catalog
Learn how the Portal.io catalog is organized into industries, categories, and items, and how to use the API to search, browse, and retrieve product data.
The Portal.io catalog is a shared database of AV equipment, labor items, and custom items that dealers use to build proposals. When you add a line item to a proposal option, you are pulling from this catalog. The Catalog API gives you programmatic access to search, browse, and retrieve item data — including pricing, supplier costs, stock status, specs, and linked resources like PDFs and videos.
The Catalog endpoints require separate authorization beyond your standard API credentials. Contact your Portal.io representative to confirm your account has catalog API access enabled before using these endpoints.
## Catalog structure
The catalog is organized as a three-level hierarchy: **Industries** contain **Categories**, and categories contain **Items**. Categories can also have **Sub-categories** nested within them.
Top-level grouping such as Audio, Video, Networking, or Lighting. Each industry has a unique ID and contains one or more categories.
A product family within an industry — for example, "AV Receivers" or "Displays" under Audio and Video respectively. Categories can contain sub-categories (e.g. "4K Displays" under "Displays").
An individual product, labor entry, or custom item. Items carry brand, model, pricing, supplier data, stock status, and optional extended details like specs and documentation.
Use the [List Categories](/api-reference/catalog/list-categories) endpoint to retrieve the full category tree. The `id` values from that response serve as filters when searching items.
## Item types
The catalog contains three types of items:
| Type | Description | Source |
| ------------ | -------------------------------------------------------------------- | ------------------------------ |
| `Part` | Physical equipment — speakers, receivers, cables, displays, etc. | Shared Portal.io catalog |
| `Labor` | Labor line items representing installation time and service charges. | Your account's private library |
| `CustomItem` | Custom items you have defined in your Portal.io account. | Your account's private library |
When searching or retrieving items, use the `ItemType` query parameter to specify which type you want. It defaults to `Part` if omitted. Labor and CustomItem lookups pull from your account's private library rather than the shared catalog.
## Pricing and supplier data
Each catalog item includes two levels of pricing information:
**MSRP** — the manufacturer's suggested retail price. This is the baseline price shown to customers on proposals. The MSRP object includes the USD value, whether it has been customized by your account, the currency, and a last-modified timestamp. Some items also carry a `futurePrice` if a price change has been announced.
**Supplier costs** — the dealer's acquisition cost from each distributor that carries the item. Each supplier entry includes the cost value, stock availability, promo status, unit of measure, and the date the price was last verified. When you retrieve an item with `ExtendedDetails=true`, you get the full list of suppliers and their costs. The default search response includes only the `defaultCost` (the primary supplier).
## Extended details
By default, item responses include the core fields: brand, model, short description, image, pricing, and category IDs. Pass `ExtendedDetails=true` on the [Get Item](/api-reference/catalog/get-item) endpoint to also receive:
* `description` — full long-form product description
* `specs` — array of name/value pairs for technical specifications
* `suppliers` — complete list of suppliers with detailed cost objects
* `pdfResourceLinks` — linked product documentation (manuals, spec sheets)
* `videoResourceLinks` — linked product videos
* `additionalImageUrls` — extra product images beyond the primary
Extended details are only available on single-item lookups, not on search results.
## Searching the catalog
The [Search Items](/api-reference/catalog/search-items) endpoint supports a combination of filters that can be used together:
* **Free text** (`SearchText`) — matches against item names, brands, and descriptions
* **Category** (`CategoryId` or `CategoryIds`) — filter by one or more category IDs from the category tree
* **Parent category** (`ParentCategoryId` or `ParentCategoryIds`) — filter by industry-level category
* **Stock status** (`IsInStock`) — show only items currently in stock
* **Favorites** (`IsFavorite`) — show only items you have marked as favorites in Portal.io
* **Company-approved** (`IsCompanyApproved`) — show only items your company has approved
Note that `CategoryId` and `CategoryIds` are mutually exclusive (use one or the other), and the same applies to `ParentCategoryId` and `ParentCategoryIds`. `IsFavorite` and `IsCompanyApproved` compose. Setting both returns items that are both a favorite and company-approved.
Results are paginated. Use `PageNumber` and `PageSize` to control pagination, and the `itemCount` field in the response to determine total results.
## Using catalog items in proposals
After you find items in the catalog, you add them to proposal options as line items. The typical workflow is:
1. **Browse or search** the catalog to find the equipment you need.
2. **Get item details** with `ExtendedDetails=true` if you need specs, documentation, or the full supplier list.
3. **Add line items** to an option within a proposal area. Refer to the [proposal building workflow](/concepts/proposals#building-a-proposal-with-the-api) for the full sequence.
The proposal's financial summary automatically recalculates when line items are added, using the pricing from the catalog.
A `204` response (not `404`) is returned when a requested item ID does not exist in the catalog. Make sure your error handling checks for this status code.
## Available API operations
| Operation | Endpoint |
| -------------------- | -------------------------------- |
| Search catalog items | `GET /public/catalog` |
| List category tree | `GET /public/catalog/categories` |
| Get item details | `GET /public/catalog/{ItemId}` |
# Managing Contacts and Locations in Portal.io
Source: https://docs.portal.io/concepts/contacts
Portal.io contacts represent the clients and companies in your account. Learn how to create, search, and assign contacts and locations to proposals.
Contacts in Portal.io represent the people and companies in your dealer account — the clients you build proposals for. Each contact stores identifying information, communication details, and one or more physical locations. Locations serve two purposes: they let you identify where a job takes place, and they drive tax calculations on proposals.
## What is a contact?
A contact (referred to as a "person" in the API) is a record that stores information about an individual or a company. Every contact has a `partyType` that controls how it is classified:
| `partyType` | Description |
| ----------- | -------------------------------------------------- |
| `Person` | An individual person. Requires `firstName`. |
| `Company` | A company or organization. Requires `companyName`. |
Contacts also have a `contactType` that categorizes their relationship to your business:
| `contactType` | Description |
| ------------- | ----------------------- |
| `Client` | A paying customer. |
| `Employee` | A member of your team. |
| `Contractor` | A subcontractor. |
| `Other` | Any other relationship. |
### Contact fields
| Field | Description |
| ---------------- | ---------------------------------------------------------- |
| `id` | Unique numeric identifier for the contact. |
| `partyType` | `Person` or `Company`. |
| `contactType` | Category of the contact relationship. |
| `firstName` | First name (required for individuals). |
| `lastName` | Last name. |
| `companyName` | Company name (required when `partyType` is `Company`). |
| `contactEmail` | Primary email address. |
| `contactEmailCC` | Additional email addresses copied when a proposal is sent. |
| `contactPhone` | Primary phone number. |
## Locations
Each contact can have one or more locations (physical addresses). A location stores:
* Street address, suite, city, postal code, state, and country
* A contact name and phone number for that location
* Flags for `isPrimary` (the contact's main address) and `isBilling` (used for billing)
Locations are returned with the `primaryLocation` and `billingLocation` fields on the contact detail response, and you can also retrieve the full list via `GET /public/people/{ContactId}/location`.
### Why locations matter for proposals
Assigning a location to a proposal is how Portal.io determines the applicable sales tax rates. Portal.io uses the `ClientLocation` method: it looks up tax rates for the address and applies them to the taxable parts of the proposal. Until you assign a location, tax totals on the proposal remain zero.
You must assign a contact to a proposal before you can assign a location. Attempting to assign a location to a proposal with no contact returns a **409 Conflict**.
## How contacts relate to proposals
A proposal can have one assigned contact and one assigned location. The relationship works like this:
* You assign a contact using `POST /public/proposals/{ProposalId}/contact/{ContactId}`.
* If the contact has exactly one primary location, Portal.io **automatically assigns that location** to the proposal as well.
* If the contact has multiple locations, you assign the location separately using `POST /public/proposals/{ProposalId}/location/{LocationId}`. The location must belong to the already-assigned contact.
* Assigning or changing a location triggers an immediate **tax recalculation** on the proposal.
## Creating contacts and locations
When you create a new contact, `PartyType`, `ContactType`, and `FirstName` are required. `CompanyName` is also required when `PartyType` is `Company`.
```bash theme={null}
curl -X POST https://api.portal.io/public/people \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d PartyType=Person \
-d ContactType=Client \
-d FirstName=Jane \
-d LastName=Smith \
-d ContactEmail=jane@example.com
```
To add a location to the contact, use `POST /public/people/{ContactId}/location`. `Street` is the only required field, but if you supply a `Country`, `State` is also required.
```bash theme={null}
curl -X POST https://api.portal.io/public/people/42/location \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d Street="123+Main+St" \
-d City=Austin \
-d State=TX \
-d PostalCode=78701 \
-d Country=US \
-d IsPrimary=true
```
## Searching for contacts
The `GET /public/people` endpoint returns a paged list of contacts for your account. You can filter by `SearchText` and `ContactTypes`, and control pagination with `PageNumber` and `PageSize` (both default to 1 and 10 respectively).
```bash theme={null}
curl -X GET 'https://api.portal.io/public/people?SearchText=Smith&ContactTypes=Client' \
-H 'Accept: application/json'
```
## API operations reference
`GET /public/people`
Returns a paged list of contacts. Supports search text, contact type filter, sort, and pagination.
`POST /public/people`
Creates a new contact in your account.
`GET /public/people/{ContactId}`
Returns full contact details including primary and billing locations. Pass `IncludeCounts=true` to include proposal and payment counts.
`GET /public/people/{ContactId}/location`
Returns all locations for a contact, ordered with primary first, then billing, then most recently modified.
`POST /public/people/{ContactId}/location`
Adds a new location to an existing contact.
`POST /public/proposals/{ProposalId}/contact/{ContactId}`
Links a contact to a proposal. Auto-assigns the location if the contact has one primary location.
`POST /public/proposals/{ProposalId}/location/{LocationId}`
Links a specific location to a proposal and triggers tax recalculation.
# Reading Proposal Items
Source: https://docs.portal.io/concepts/proposal-item-model
Where items live inside a proposal, what each field on an item means, and how quantities, totals, and nesting fit together.
Every item write endpoint returns items, but none of them returns a proposal. To read the current state of a proposal's items — or to discover the ids you need before writing anything — call [get proposal](/api-reference/proposals/get-proposal) and walk into its areas.
## Where items live
Items are nested three levels down. A proposal has areas, an area has client-selectable options, and each option holds the items the client is quoted for if they pick it:
```json theme={null}
{
"id": 1042,
"areas": [
{
"id": 55,
"name": "Living Room",
"options": [
{
"id": 201,
"status": "Draft",
"clientDescription": "Standard package",
"total": 2098.00,
"totalRecurringService": 25.00,
"items": [
{
"id": 5001,
"parentId": null,
"itemType": "Part",
"referencedItemId": 88213,
"brand": "Sonos",
"model": "Amp",
"name": null,
"shortDescription": "Sonos Amp",
"quantity": 2,
"sellPrice": 649.00,
"cost": 499.00,
"supplier": "Sonos Inc.",
"total": { "amount": 1298.00, "currency": { "code": "USD", "symbol": "$" }, "isCombinedPrice": false }
},
{
"id": 5002,
"parentId": 5001,
"itemType": "Part",
"referencedItemId": 90144,
"brand": "Sonance",
"model": "VP62R",
"shortDescription": "Sonance VP62R In-Ceiling Speaker",
"quantity": 4,
"sellPrice": 137.50,
"total": { "amount": 550.00, "currency": { "code": "USD", "symbol": "$" }, "isCombinedPrice": false }
},
{
"id": 5003,
"parentId": null,
"itemType": "Labor",
"referencedItemId": 730,
"brand": null,
"model": null,
"name": "Installation Labor",
"shortDescription": "Installation Labor",
"quantity": 5.5,
"sellPrice": 45.00,
"cost": null,
"supplier": null,
"total": { "amount": 247.50, "currency": { "code": "USD", "symbol": "$" }, "isCombinedPrice": false }
}
]
},
{ "id": 202, "status": "Draft", "total": 3450.00, "totalRecurringService": 25.00, "items": [] }
]
}
]
}
```
Two ids from this payload drive every item call: the option's `id` is the `ProposalAreaOptionId` you target when [adding](/api-reference/proposals/items/add-items), [moving](/api-reference/proposals/items/move-items), or [copying](/api-reference/proposals/items/copy-items) items, and each item's `id` is the `ProposalItemId` in every per-item path. Neither is discoverable any other way, so an integration that edits items starts with a `GET`.
`GET /public/proposals/{ProposalId}` also sets a `Last-Modified` header from the proposal's `lastModifiedDate`, which is the cheapest way to poll for outside changes.
Webhooks fire at proposal level only — `proposal.status_changed`, `proposal.build.status_update`, and `proposal.outline.status_update`. Nothing fires when an item is added, repriced, or deleted, so there is no push signal for item edits made in the Portal.io UI. Re-fetch the proposal instead.
## Identifying an item
| Field | What it tells you |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | The proposal item. Unique to this proposal, and what every per-item endpoint takes. |
| `referencedItemId` | The catalog, labor, custom, or fee item it was created from. Shared by every instance of the same product, which is what `updateAllInstances` fans out across. |
| `parentId` | The item this one is nested under, or `null` at top level. |
| `itemType` | `Part`, `Labor`, `CustomItem`, or `Fee`. |
Item type decides whether the item's name comes back in `model` or in `name`, which is the most common surprise when mapping the model:
* `Part` and `CustomItem` — `brand` and `model`, `name` is `null`.
* `Labor` — `name`, with `brand` and `model` `null`.
* `Fee` — the name you submitted comes back in `model`, not `name`.
`description` is a deprecated legacy field. Read `shortDescription`.
## Quantities and totals
`quantity` is a decimal stored to 2 decimal places, so fractional values are normal — 5.5 hours of labor in the payload above. Zero is legal too, and does not remove the item; [delete items](/api-reference/proposals/items/delete-items) does that.
`total.amount` is the per-unit `sellPrice` multiplied by `quantity`, before tax. It is not a tax-inclusive figure and it does not include nested items unless the parent has combined pricing switched on with [set combined pricing](/api-reference/proposals/items/set-item-combined-pricing) — which only takes effect on a top-level, non-recurring item that has children.
When `total.isCombinedPrice` is `true`, the children's totals are already inside the parent's `amount`, yet each child still reports its own `total` unchanged. Summing parent and children then double-counts. Add up either the top-level items of an option or its leaf items, never both.
Recurring-service items sit outside the option's regular total: they are excluded from `total` on the area option and counted in `totalRecurringService` instead. Sum both if you need the full monthly-plus-one-off picture.
## Percentage pricing is dynamic
[Sell price](/api-reference/proposals/items/update-item-sell-price) and [cost](/api-reference/proposals/items/update-item-cost) accept either an absolute amount or a percentage of a basis. Percentages are whole numbers — `15` means 15%, resolved to 2 decimal places — and the basis decides how stable the resulting price is:
| Basis | Resolved against | Stability |
| ------------------ | --------------------------------------------------------------------- | ------------------------------------- |
| `AreaTotal` | Sell total of the item's own area option | Recalculated when that option changes |
| `PartsInAreaTotal` | Parts-only sell total of that option | Recalculated when that option changes |
| `LaborInAreaTotal` | Labor-only sell total of that option | Recalculated when that option changes |
| `ProposalTotal` | Proposal subtotal, before any convenience fee | Dynamic across the proposal |
| `PartTotal` | Parts-only sell total of the proposal | Dynamic across the proposal |
| `LaborTotal` | Labor-only sell total of the proposal | Dynamic across the proposal |
| `CostOfSellPrice` | The item's own sell price — **cost only**, never valid for sell price | Follows the sell price |
A percentage-priced item has no fixed `sellPrice` of its own: writing to any other item that its basis covers changes it. If your system stores prices, re-read the proposal after a batch of writes rather than trusting the values returned by the individual calls.
## When `cost` is `null`
`cost` on an item is `null` when you do not have permission to see costs. A `null` cost means "you cannot see it", not "this item has no cost" — the difference matters if you are calculating margin or deciding whether to write a cost.
[List item suppliers](/api-reference/proposals/items/list-item-suppliers) is the exception: it returns cost details regardless of that restriction. It is also where the supplier's numeric id lives, since an item only carries `supplier` as a display name.
## Order of items
Items within an option are returned in their display order, and that order is not settable through the public API. Newly added items and copies are appended to the end of the destination option. Moved items are appended too, and every item in a single move is given the same position, so their relative order afterwards is not guaranteed.
# Building a Proposal Item by Item
Source: https://docs.portal.io/concepts/proposal-items-workflow
A short walkthrough of the typical item lifecycle: add an item, price it, set its supplier, then organize it within the proposal.
This guide walks through the typical sequence for managing items on a proposal: add the item, price it, assign a supplier, then organize it alongside other items. Each step links to its full endpoint reference in [Proposal Items](/api-reference/proposals/items/overview).
The calls that change an item all return the same [`List`](/api-reference/proposals/items/overview#response-shape) response shape. See that page for the shared error codes and flag conventions, and [reading proposal items](/concepts/proposal-item-model) for what the returned fields mean. [List item suppliers](/api-reference/proposals/items/list-item-suppliers) is the exception — it returns supplier records rather than items.
The `curl` snippets here are trimmed to the payload. Every real request also needs the four HMAC headers from [signing requests](/authentication/signing-requests). The `POST` calls send a JSON body, so set `Content-Type: application/json` and sign that same value. The `GET` has no body, and its canonical message omits the content-type segment entirely.
Call [add items](/api-reference/proposals/items/add-items) with the catalog item id, its type, and the target area option. Area option ids come from [get proposal](/api-reference/proposals/get-proposal), under `areas[].options[]`. The response includes the new item's `id` — save it for every following step.
```bash curl theme={null}
curl -X POST https://api.portal.io/public/proposals/123/items \
-H 'Content-Type: application/json' \
-d '{
"items": [
{
"catalogItemId": 88213,
"itemType": "Part",
"proposalAreaOptions": [{ "proposalAreaOptionId": 201, "quantity": 2 }]
}
]
}'
```
```json response theme={null}
[
{
"id": 5001,
"itemType": "Part",
"referencedItemId": 88213,
"shortDescription": "Sonos Amp",
"sellPrice": 649.00,
"cost": 499.00,
"quantity": 2,
"supplier": "Sonos Inc.",
"total": { "amount": 1298.00, "currency": { "code": "USD", "symbol": "$" }, "isCombinedPrice": false }
}
]
```
Adjust the sell price, cost, or MSRP if the catalog defaults don't apply. Use [update sell price](/api-reference/proposals/items/update-item-sell-price), [update cost](/api-reference/proposals/items/update-item-cost), or [update MSRP](/api-reference/proposals/items/update-item-msrp) — each accepts either an absolute value or a percentage + basis.
```bash curl theme={null}
curl -X POST https://api.portal.io/public/proposals/123/items/5001/sellprice \
-H 'Content-Type: application/json' \
-d '{ "sellPrice": 599.00, "setDefault": false, "updateAllInstances": false }'
```
```json response theme={null}
[
{
"id": 5001,
"sellPrice": 599.00,
"cost": 499.00,
"quantity": 2,
"total": { "amount": 1198.00, "currency": { "code": "USD", "symbol": "$" }, "isCombinedPrice": false }
}
]
```
Call [list item suppliers](/api-reference/proposals/items/list-item-suppliers) to see which suppliers are available for this item, then [set item supplier](/api-reference/proposals/items/set-item-supplier) with the chosen supplier's id. This updates every instance of the same catalog item in the proposal.
```bash curl theme={null}
curl https://api.portal.io/public/proposals/123/items/5001/suppliers
```
```json response theme={null}
[
{ "id": 771, "name": "Sonos Inc.", "isDefault": true, "isInStock": true },
{ "id": 902, "name": "ADI Global Distribution", "isDefault": false, "isInStock": true }
]
```
```bash curl theme={null}
curl -X POST https://api.portal.io/public/proposals/123/items/5001/supplier/902 \
-H 'Content-Type: application/json' \
-d '{ "setDefault": false }'
```
```json response theme={null}
[
{
"id": 5001,
"supplier": "ADI Global Distribution",
"cost": 512.50,
"sellPrice": 599.00,
"total": { "amount": 1198.00, "currency": { "code": "USD", "symbol": "$" }, "isCombinedPrice": false }
}
]
```
Once pricing and supplier are set, move or copy the item to its final area option with [move items](/api-reference/proposals/items/move-items) or [copy items](/api-reference/proposals/items/copy-items). Nested (child) items move or copy along with their parent unless you suppress that with `moveNestedItems`/`copyNestedItems`.
```bash curl theme={null}
curl -X POST https://api.portal.io/public/proposals/123/items/move \
-H 'Content-Type: application/json' \
-d '{ "destinationAreaOptionId": 305, "proposalItemIds": [5001], "moveNestedItems": true }'
```
```json response theme={null}
[
{
"id": 5001,
"shortDescription": "Sonos Amp",
"supplier": "ADI Global Distribution",
"sellPrice": 599.00,
"quantity": 2,
"total": { "amount": 1198.00, "currency": { "code": "USD", "symbol": "$" }, "isCombinedPrice": false }
}
]
```
## Next steps
* To remove an item instead, see [delete items](/api-reference/proposals/items/delete-items) — note its response is `void`, unlike every step above.
* To pull the item's supplier cost from the catalog after a supplier price change elsewhere, see [refresh item costs](/api-reference/proposals/items/refresh-item-costs) — also `void`.
* For the tax-exempt and recurring-service flags, see the [Flags](/api-reference/proposals/items/update-item-tax-exempt) endpoints.
* No webhook fires when an item changes, so to pick up edits made elsewhere, re-fetch [get proposal](/api-reference/proposals/get-proposal) and watch its `Last-Modified` header.
# Understanding Proposals in the Portal.io API
Source: https://docs.portal.io/concepts/proposals
Learn how Portal.io proposals are structured with areas, options, and change orders, and how to use the API to build and manage them.
A proposal is the primary sales document you send to a client. It captures everything about a project — the rooms and zones involved, the equipment and labor for each configuration, and the financial totals the client sees. Every proposal belongs to a single dealer account and is owned by a salesperson on that account.
## Proposal structure
Proposals follow a strict four-level hierarchy: a **Proposal** contains one or more **Areas**, each Area contains one or more **Options**, and each Option contains **Line Items**.
The top-level entity. Has an ID, a human-readable number, a name, a salesperson, a status, a financial summary, and an optional assigned contact and location.
A named room or zone within the proposal — for example, "Living Room" or "Master Bedroom". Each area gets one default option automatically when it is created. Area names must be unique within the proposal.
A configuration within an area. Each area supports up to 3 options. Options have a status, a client-facing description, and internal installer notes.
Individual equipment or labor items within an option. You add these from the Portal.io catalog.
### Proposal fields
| Field | Description |
| ------------------ | ---------------------------------------------------------------------- |
| `id` | Unique numeric identifier. Use this in all subsequent API calls. |
| `number` | Human-readable sequential number assigned by Portal.io. |
| `name` | Display name for the proposal. |
| `status` | Current lifecycle state (see [Proposal statuses](#proposal-statuses)). |
| `financialSummary` | Totals for parts, labor, fees, discounts, and tax. |
| `customer` | The assigned contact (person or company). |
### Area constraints
* Area names must be unique within the proposal.
* Creating an area automatically creates one default Option in `Draft` status inside it.
* A 400 error is returned if you try to create a second area with the same name.
### Option constraints
* Each area supports a maximum of **3 options**.
* Options are created in `Draft` status.
* Each option has a `ClientDescription` (shown to the customer) and `InternalNotes` (visible to your team only).
* Attempting to add a fourth option to an area returns a 400 error.
## Proposal statuses
Portal.io proposals move through a defined set of statuses:
| Status | Meaning |
| ---------------- | ------------------------------------------------ |
| `Draft` | Proposal is being built and can be edited. |
| `Submitted` | Proposal has been sent to the client for review. |
| `ViewedByClient` | Client has opened and viewed the proposal. |
| `Accepted` | Client has accepted the proposal. |
| `Declined` | Client has declined the proposal. |
| `Delayed` | Proposal has been put on hold. |
| `Completed` | Work on the proposal is finished. |
| `EmailFailed` | The proposal email failed to deliver. |
| `Expired` | The proposal has passed its expiration date. |
This status list is sourced from the current API specification and should be validated against the latest API behavior. If you encounter a status not listed here, please contact [support@portal.io](mailto:support@portal.io).
Once a proposal reaches **Accepted**, **Completed**, or another terminal state, the API will not allow edits — any write operation on that proposal returns a **409 Conflict**. Use a change order to make modifications after approval.
## Financial summary
The financial summary is included on every proposal response. It breaks down costs into:
* **Parts subtotal** — total equipment cost before discounts
* **Parts discount** — applied discount amount and type (Percentage or flat)
* **Labor total** — total labor charges
* **Fee total** — additional fees
* **Sales tax** — calculated automatically based on the assigned location
Tax is calculated using the `ClientLocation` method: Portal.io looks up the applicable tax rates for the address on the assigned location and applies them to the taxable portions of the proposal. Until you assign a location, tax totals remain zero.
## Client vs. dealer content
Every proposal carries two separate content layers:
* **Client-facing content** — the proposal description and each option's `ClientDescription`. This content appears on documents sent to the customer.
* **Internal content** — proposal-level `InternalNotes` and each option's `InternalNotes`. This content is only visible to your dealer team and never appears on customer documents.
Use separate endpoints to update each layer:
* `POST /public/proposals/{ProposalId}/description` — updates the client-facing proposal description
* `POST /public/proposals/{ProposalId}/internalnotes` — updates the internal installer notes
* `POST /public/proposals/{ProposalId}/area-options/{AreaOptionId}/clientdescription` — updates an option's client description
* `POST /public/proposals/{ProposalId}/area-options/{AreaOptionId}/installernotes` — updates an option's installer notes
## Change orders
When a proposal is already approved or completed and the client needs modifications, you create a **change order** rather than editing the original proposal directly. Change orders are linked to their parent proposal and have their own:
* Unique ID and number
* Status (following the same lifecycle as proposals)
* Financial summary (showing only the delta — the incremental cost of the changes)
* Assigned customer and dates
To retrieve change orders for a proposal:
* `GET /public/proposals/{ProposalId}/changeorders` — lists all change orders
* `GET /public/proposals/{ProposalId}/changeorders/{ChangeOrderId}` — retrieves a specific change order
## Building a proposal with the API
Use this sequence to build a complete proposal from scratch:
Call `POST /public/proposals` with a `SalesPersonId` (required) and an optional `Name`. The response includes the new proposal `id` and `number` — save the `id` for all subsequent calls.
```bash theme={null}
curl -X POST https://api.portal.io/public/proposals \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d SalesPersonId=42 \
-d Name="Smith Residence AV"
```
Call `POST /public/proposals/{id}/area` for each room or zone. Each area you create gets a default option automatically.
```bash theme={null}
curl -X POST https://api.portal.io/public/proposals/1001/area \
-d Name="Living+Room"
```
If you want to present the client with multiple configurations per area, call `POST /public/proposals/{id}/area/{AreaId}/option` to add up to 2 more options (3 total per area). You can supply a `ClientDescription` and `InternalNotes` at creation time.
Use the catalog endpoints to search for equipment and labor, then add line items to each option. Refer to the Catalog section of the API reference for the available endpoints.
Assign a contact with `POST /public/proposals/{id}/contact/{ContactId}`. If the contact has a single primary location, Portal.io assigns it automatically. Otherwise, assign the location separately with `POST /public/proposals/{id}/location/{LocationId}`. Assigning a location triggers tax recalculation.
Upload source content (text, audio, or video) and trigger AI outline generation and proposal building. The AI Builder works asynchronously — use the `Proposal Build Status Changed` and `Proposal Outline Status Changed` webhook events to know when results are ready.
## AI Builder
Portal.io includes an AI Builder that can auto-generate proposal content from uploaded source material such as meeting notes, audio recordings, or video files. The AI Builder operates asynchronously across two phases:
1. **Outline generation** — `POST /public/proposals/{ProposalId}/ai/outline` starts the process. Poll `GET /public/proposals/{ProposalId}/ai/outline` or listen for the `Proposal Outline Status Changed` webhook to know when the outline is ready.
2. **Proposal build** — once you have a completed outline, call `POST /public/proposals/{ProposalId}/ai/build`. Listen for the `Proposal Build Status Changed` webhook to know when the build completes.
## Available API operations
| Operation | Endpoint |
| -------------------------------- | ----------------------------------------------------------------- |
| List proposals | `GET /public/proposals` |
| Create proposal | `POST /public/proposals` |
| Get proposal details | `GET /public/proposals/{ProposalId}` |
| Update proposal name/salesperson | `POST /public/proposals/{ProposalId}` |
| Update client description | `POST /public/proposals/{ProposalId}/description` |
| Update internal notes | `POST /public/proposals/{ProposalId}/internalnotes` |
| Add area | `POST /public/proposals/{ProposalId}/area` |
| Add option to area | `POST /public/proposals/{ProposalId}/area/{AreaId}/option` |
| List change orders | `GET /public/proposals/{ProposalId}/changeorders` |
| Get change order | `GET /public/proposals/{ProposalId}/changeorders/{ChangeOrderId}` |
| Assign contact | `POST /public/proposals/{ProposalId}/contact/{ContactId}` |
| Assign location | `POST /public/proposals/{ProposalId}/location/{LocationId}` |
# Portal.io API Webhooks: Real-Time Event Notifications
Source: https://docs.portal.io/concepts/webhooks
Subscribe to Portal.io webhook events to receive real-time HTTP POST notifications when proposals, payments, and orders change status in your account.
Webhooks let your system react to events in Portal.io without polling the API continuously. When a subscribed event occurs — such as a proposal being approved or an AI build completing — Portal.io sends an HTTP POST request to your configured endpoint with a JSON payload describing the event.
Use webhook events alongside targeted API calls to keep your system in sync. For example, listen for `Proposal Status Changed` to trigger a workflow, then call `GET /public/proposals/{ProposalId}` to fetch the full updated proposal details.
## Available events
Portal.io fires webhooks for the following event types:
| Event name | Triggered when |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `Proposal Build Status Changed` | The AI Builder finishes (or fails) building a proposal from an approved outline. |
| `Proposal Outline Status Changed` | The AI outline generation status changes — for example, when the outline moves from `Generating` to `Completed`. |
| `Proposal Status Changed` | A proposal's status changes — for example, from `Draft` to `Sent`, or from `Sent` to `Approved`. |
In addition, Portal.io supports the following Zapier trigger events, which fire on the same underlying data changes:
| Zapier trigger | Fires when |
| ---------------------- | --------------------------------------- |
| Person Modification | A contact record is created or updated. |
| Payment Status Change | A payment's status changes. |
| Proposal Status Change | A proposal's status changes. |
| Order Status Change | An order's status changes. |
## Subscribing to webhooks
Create a webhook subscription by calling `POST /public/webhook/subscribe` with your endpoint URL and the events you want to receive.
```bash theme={null}
curl -X POST https://api.portal.io/public/webhook/subscribe \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d Url=https://yourapp.example.com/portal/webhooks \
-d Description="Production+webhook" \
-d Events=proposal.status_changed
```
The response includes:
* `subscriptionId` — use this to update or delete the subscription later
* `secretKey` — use this to verify that incoming webhook requests are genuinely from Portal.io (see [Verifying webhook signatures](#verifying-webhook-signatures))
* `url`, `description`, `enabled`, and `events`
Your endpoint URL must use HTTPS and must be publicly routable. Portal.io rejects private, loopback, and link-local addresses.
## Managing subscriptions
| Operation | Endpoint |
| ---------------------- | ----------------------------------------------------- |
| Create subscription | `POST /public/webhook/subscribe` |
| List all subscriptions | `GET /public/webhook/subscriptions` |
| Update a subscription | `POST /public/webhook/subscription/{SubscriptionId}` |
| Delete a subscription | `DELETE /public/webhook/unsubscribe/{SubscriptionId}` |
## What your endpoint receives
When an event fires, Portal.io sends an HTTP POST to your endpoint with:
* `Content-Type: application/json`
* An `X-Webhook-Signature` header for verification (see below)
* A JSON body describing the event
Your endpoint must respond with a **2xx HTTP status code** to acknowledge receipt. If Portal.io does not receive a 2xx response, it retries the delivery on this schedule:
| Attempt | Delay after previous failure |
| --------- | ---------------------------- |
| 1st retry | 1 minute |
| 2nd retry | 5 minutes |
| 3rd retry | 30 minutes |
| 4th retry | 2 hours |
| 5th retry | 12 hours |
## Verifying webhook signatures
Every webhook request includes an `X-Webhook-Signature` header so you can confirm the request came from Portal.io and has not been tampered with.
**Header format:**
```text theme={null}
X-Webhook-Signature: t=1710000000,v1=5f2b3e7a9d1c4f8e6b2a3c9d7f4e1a6b5c3d2f7e9a1b4c6d8e0f2a3b5c7d9e1
```
Where:
* `t` — Unix timestamp of when the webhook was generated
* `v1` — HMAC-SHA256 signature
**Signature calculation:**
```text theme={null}
signature = HMAC_SHA256(secretKey, t + "." + requestBody)
```
For example, if `t` is `1710000000` and the request body is `{"id":10025,"number":4123,...}`, the signed message is:
```text theme={null}
1710000000.{"id":10025,"number":4123,...}
```
**Verification steps:**
Use the raw bytes exactly as received — do not parse or normalize the JSON before verifying.
Parse `X-Webhook-Signature` to extract `t` and `v1`.
Concatenate `t`, a literal `.`, and the raw request body.
Run `HMAC_SHA256(secretKey, signedMessage)` using your subscription's `secretKey`.
Compare your computed value with `v1`. Reject the request if they do not match, or if `t` is more than 5 minutes old.
## Zapier integration
Portal.io supports Zapier natively. If you are building automations with Zapier rather than a direct integration, the same underlying events — person modifications, payment status changes, proposal status changes, and order status changes — are available as Zapier triggers. Connect the Portal.io app in Zapier to configure these without writing any code.
# Integration Field Matrix
Source: https://docs.portal.io/field-matrix
Downloadable spreadsheet listing every field in the Portal.io API — use it to plan your integration mapping.
The field matrix is a field-by-field inventory of the Portal.io public API, organized by domain area. It is designed for integration partners who need to plan mappings between Portal.io and their own system (ERP, CRM, accounting, etc.).
## What's included
The spreadsheet contains six tabs:
* **Overview** — Base URLs, tab legend, and field context definitions
* **Authentication** — HMAC headers, credential exchange request and response fields
* **People & Locations** — Contact list, detail, and create fields; location list and create fields; the full location object shape
* **Proposals** — Proposal list, detail, create, update, assignment, area, and option fields; the financial summary object
* **Catalog** — Catalog search, item detail, supplier cost, and category hierarchy fields
* **Webhooks & Events** — Webhook subscription management and event payload fields for all event types
Every field includes its data type, which endpoint it belongs to, whether it is a request or response field, and a description.
Proposal line items are not in the spreadsheet yet. For those fields, use [reading proposal items](/concepts/proposal-item-model) and the [Proposal Items](/api-reference/proposals/items/overview) reference.
View the Portal.io API Field Matrix (Google Sheets)
## How to use it
Add a column for your system's corresponding field (e.g. `res.partner.email` in Odoo, `Contact.Email` in Salesforce) and work through each row to define your mapping. The "Context" column tells you whether a field is something you send to Portal.io or something Portal.io returns to you, which determines the direction of your sync.
For full endpoint documentation including authentication setup, code samples, and error handling, see the [API Reference](/api-reference/authentication/exchange-api-key) and [Authentication](/authentication/overview) sections.
# Explore with Postman
Source: https://docs.portal.io/postman
Use the Portal.io API Postman collection to explore endpoints, test requests, and debug your integration without writing code.
Postman is a free application that lets you build, send, and inspect HTTP requests through a visual interface. Instead of assembling curl commands by hand, you fill in the URL, headers, and body in a form, hit **Send**, and see the full response — status code, headers, and body — right in the app.
For the Portal.io API, Postman is especially useful because every request requires HMAC-SHA256 signature headers. The Portal.io Postman collection includes pre-configured requests and a built-in pre-request script that computes signatures automatically, so you can focus on understanding the API rather than debugging authentication.
## Why use Postman with the Portal.io API
Portal.io's HMAC authentication means every request needs five headers and a correctly assembled signature. Getting any piece wrong produces a `401` with no detail about what failed. Postman eliminates that friction during development:
* **Pre-built requests** — every Portal.io API endpoint is already set up with the right method, URL, headers, and sample body. You just fill in your credentials.
* **Automatic HMAC signing** — the collection's pre-request script builds the canonical message and computes the Base64-encoded HMAC-SHA256 signature on every send. No manual signature work needed.
* **Instant feedback** — see the full response (status, headers, JSON body) immediately. Compare what you expect with what the API actually returns.
* **Environment variables** — store your Application Key, Secret Key, and User Key once, and they apply to every request in the collection.
## Get the collection
Import the Portal.io API collection directly into your Postman workspace.
If you prefer to import manually, copy this link and use **Import → Link** in the Postman app:
```
https://god.gw.postman.com/run-collection/54258156-d5763440-1c26-4cff-9c73-76c347909aa1
```
## Set up your environment
After importing the collection, create a Postman environment with the following variables. These are referenced by the collection's pre-request script and request templates.
| Variable | Value | Where to get it |
| ----------- | ------------------------------- | ---------------------------------------------------------------------------- |
| `appId` | Your API Application Key | Provided by your Portal.io representative |
| `secretKey` | Your Secret Key | Provided by your Portal.io representative |
| `userKey` | Your User API Key | Returned by the credential exchange endpoint (see [quickstart](/quickstart)) |
| `baseUrl` | `https://sandbox.api.portal.io` | Use `https://api.portal.io` for production |
Run the **API Key Exchange** request in the collection first. It returns your User Key, which you can copy into the `userKey` environment variable. After that, every other request in the collection will authenticate automatically.
## Make your first request
Once your environment variables are set:
1. Select the **List Proposals** request from the collection sidebar.
2. Confirm the environment dropdown (top right) is set to the environment you just created.
3. Click **Send**.
You should see a `200` response with a JSON body containing your proposals array. If you get a `401`, double-check that your `appId`, `secretKey`, and `userKey` variables are set correctly and that you selected the right environment.
## Tips for working with the collection
* **Duplicate before customizing.** If you want to modify a request (add query parameters, change the body), duplicate it first so the original stays intact for reference.
* **Check the console.** Open the Postman Console (View → Show Postman Console) to see the exact request that was sent, including the computed signature. This is invaluable when debugging authentication issues.
* **Switch environments for production.** Create a second environment with your production credentials and `baseUrl` set to `https://api.portal.io`. Switching between sandbox and production is a single dropdown change.
## Installing Postman
If you don't have Postman installed, download the free desktop app from [postman.com/downloads](https://www.postman.com/downloads/). Postman is available for macOS, Windows, and Linux. There is also a web version at [go.postman.co](https://go.postman.co) that works in any browser — no installation required.
# Get Started with the Portal.io API
Source: https://docs.portal.io/quickstart
Learn how to set up your sandbox account, obtain credentials, authenticate, and make your first Portal.io API request in five steps.
This guide walks you through everything you need to go from zero to a working Portal.io API request. By the end, you will have a sandbox account, valid credentials, and a confirmed API response.
You can also explore the API interactively using the official Postman collection. Click **Run in Postman** or import the collection directly: [https://god.gw.postman.com/run-collection/54258156-d5763440-1c26-4cff-9c73-76c347909aa1](https://god.gw.postman.com/run-collection/54258156-d5763440-1c26-4cff-9c73-76c347909aa1)
Go to [https://sandbox.portal.io](https://sandbox.portal.io) and create a free sandbox dealer account. This account represents a test dealership and is the identity you will use when exchanging credentials and interacting with the sandbox API.
Verify your email address before proceeding — unverified accounts cannot authenticate.
After you have a sandbox account, contact your Portal.io representative and let them know. They will provide two values:
* **API Application Key** — a UUID that identifies your integration, sent as the `X-MSS-API-APPID` header on every request.
* **Secret Key** — a Base64-encoded string used as the raw key material when computing your HMAC-SHA256 request signatures. Keep this value secret.
You cannot proceed to the next step without both of these credentials.
Call `GET /authenticate/apikeyexchange` with your Portal.io username and password as query parameters, along with the required HMAC authentication headers. A successful response returns a JSON object; the value at `meta.apiKey` is your **User API Key**.
For this initial credential exchange, the `X-MSS-API-USERKEY` header must be an empty string and is excluded from the HMAC canonical message. Do not include a User Key value here — you are exchanging your username and password to obtain one.
```bash theme={null}
curl -i -X GET \
"https://sandbox.api.portal.io/authenticate/apikeyexchange?UserName=user%40example.com&Password=MyP%40ss123" \
-H "Accept: application/json" \
-H "X-MSS-API-APPID: YOUR_APP_ID" \
-H "X-MSS-API-USERKEY: " \
-H "X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT" \
-H "X-MSS-SIGNATURE: BASE64_HMAC_SIGNATURE"
```
Replace `YOUR_APP_ID` with your API Application Key and `BASE64_HMAC_SIGNATURE` with the HMAC-SHA256 signature you computed for this request. See [Signing requests](/authentication/signing-requests) for how to compute the signature.
A `200` response includes the User API Key in the response body:
```json theme={null}
{
"meta": {
"apiKey": "qBOSOYDeZaSzTxqMCL1Kr66JpU2H6wHCLz7xviZUOcA="
}
}
```
Store this value — you will send it as `X-MSS-API-USERKEY` on all subsequent requests.
Every authenticated API call requires the User Key you obtained in the previous step. Include it as the `X-MSS-API-USERKEY` header, and also incorporate its exact value into your HMAC canonical message when computing the signature for each request.
The four required auth headers on every authenticated request are:
| Header | Value |
| ------------------- | ---------------------------------------- |
| `X-MSS-API-APPID` | Your API Application Key |
| `X-MSS-API-USERKEY` | The User Key from the exchange |
| `X-MSS-CUSTOM-DATE` | Current UTC timestamp in RFC 7231 format |
| `X-MSS-SIGNATURE` | HMAC-SHA256 signature, Base64 encoded |
With your credentials in place, confirm everything is working by listing your proposals. A `200` response with a `proposals` array means you are fully set up.
```bash theme={null}
curl -i -X GET \
"https://sandbox.api.portal.io/public/proposals" \
-H "Accept: application/json" \
-H "X-MSS-API-APPID: YOUR_APP_ID" \
-H "X-MSS-API-USERKEY: YOUR_USER_KEY" \
-H "X-MSS-CUSTOM-DATE: Mon, 06 Apr 2026 00:22:19 GMT" \
-H "X-MSS-SIGNATURE: BASE64_HMAC_SIGNATURE"
```
If you receive a `401`, double-check that your signature was computed correctly and that your `X-MSS-CUSTOM-DATE` timestamp exactly matches the value used in the canonical message. See [authentication errors](/authentication/overview) for more detail.
# Error Handling & Troubleshooting
Source: https://docs.portal.io/troubleshooting
Understand Portal.io API error codes, diagnose common authentication issues, and resolve the most frequent integration problems.
The Portal.io API uses standard HTTP status codes to indicate whether a request succeeded or failed. Every error response returns an appropriate status code, and most include a JSON body with additional context. This page covers the status codes you will encounter, the most common causes of each, and how to resolve them.
## HTTP status codes
### Success codes
| Code | Meaning | When you'll see it |
| ---- | ------- | ------------------------------------------------------------------------ |
| 200 | OK | GET requests that return data, and most POST updates |
| 201 | Created | POST requests that create a new resource (proposal, area, contact, etc.) |
### Client error codes
| Code | Meaning | Common cause |
| ---- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400 | Bad Request | Missing or invalid parameters. For example, creating a duplicate area name or exceeding the 3-option limit per area. |
| 401 | Unauthorized | HMAC signature is invalid, missing auth headers, or credentials are wrong. This is the most common error during integration development — see [Debugging 401 errors](#debugging-401-errors) below. |
| 402 | Payment Required | The Portal.io account's subscription is inactive or expired. Contact your Portal.io representative. |
| 403 | Forbidden | The authenticated user does not have permission for the requested action. Check user role and permissions in Portal.io. |
| 404 | Not Found | The resource ID in the URL does not exist, or belongs to a different account. |
| 409 | Conflict | The resource is in a state that prevents the requested action. Most commonly, you are trying to edit a proposal that has reached a terminal status (Accepted, Completed, Declined). Use a [change order](/concepts/proposals#change-orders) instead. |
### Server error codes
| Code | Meaning | What to do |
| ---- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 500 | Internal Server Error | An unexpected error on Portal.io's side. Retry after a brief delay. If it persists, contact [support@portal.io](mailto:support@portal.io) with the request details and timestamp. |
## Debugging 401 errors
A `401 Unauthorized` means the API could not verify your request's authenticity. The response body for a signature mismatch is:
```
"You are not authorized. Your request signature (hash) is invalid."
```
This specific message indicates the HMAC signature didn't match what the server expected. Other 401 causes (expired user key, missing app ID) may return different messages. When you see the "signature (hash) is invalid" message, focus your debugging on the canonical message construction below.
### Checklist
Confirm that your `X-MSS-API-APPID` matches the Application Key provided by Portal.io, and that your `X-MSS-API-USERKEY` matches the User Key returned from the credential exchange. Copy-paste errors (trailing spaces, missing characters) are the most frequent cause.
The `X-MSS-CUSTOM-DATE` header must be the current UTC time in RFC 7231 format (e.g. `Mon, 06 Apr 2026 00:22:19 GMT`). The same exact string must appear in both the header and the HMAC canonical message. If there is any difference — even a single space — the signature will not match.
Common mistakes: using local time instead of UTC, or generating the timestamp at one point but computing the signature later with a new timestamp.
The HMAC canonical message must be assembled in the exact order specified in [Signing requests](/authentication/signing-requests). For GET requests the order is: HTTP method, base URL, timestamp, User Key. For POST requests, content type is added between the URL and timestamp. Two common mistakes: including query parameters in the URL (only the base path is signed), and omitting the content type on POST requests. See the [signing guide](/authentication/signing-requests#examples) for worked examples of both GET and POST.
The Secret Key must be used as-is (ASCII bytes) when creating the HMAC. Do not Base64-decode it first — use the literal string value as the key material.
Use the [Postman console](/postman#tips-for-working-with-the-collection) or your HTTP client's debug output to see the exact headers sent. Compare each header value against what your code intended to send.
The [Postman collection](/postman) computes HMAC signatures automatically. If the same credentials work in Postman but not in your code, the problem is in your signature computation — compare your canonical message string character-by-character against what Postman generates.
## GET vs. POST: different failure modes
Authentication failures behave differently on GET and POST requests, and understanding the asymmetry will save you significant debugging time.
**GET requests** — the server does not include query parameters in signature verification. This means a query-string encoding bug in your signer will not produce a 401. Auth passes, but you may get unexpected results (wrong page, missing filters) because the actual query params differ from what you intended. If you're debugging a GET that returns wrong data but authenticates fine, check your query parameter encoding — not your signing code.
**POST requests** — the server strictly validates the content-type segment of the canonical message against the `Content-Type` header. If there is any mismatch (wrong case, extra suffix, different value), the request fails with 401. When debugging a POST auth failure, check content-type first: is the value in your signing string character-for-character identical to the `Content-Type` header you're sending?
Portal.io's signature verification layer and body parsing layer operate independently. The signature layer is strict about content-type matching (mismatch = 401). But the body parsing layer can be lenient — some endpoints accept multiple body formats or even an empty body, as long as the required identifiers are present in the URL path. This means a request with the wrong body format may authenticate successfully and return a 200/204, even though it doesn't match the documented contract. Always follow the documented content type and body format for each endpoint to avoid silent drift.
## Common integration problems
### "I get 401 on my first request after the credential exchange"
The credential exchange (`GET /authenticate/apikeyexchange`) uses a special auth flow where `X-MSS-API-USERKEY` is an empty string and excluded from the canonical message. After the exchange, every subsequent request must include the returned User Key both in the `X-MSS-API-USERKEY` header and in the canonical message. If you forget to switch, the signature will not match.
### "I get 401 on POST requests but GET requests work fine"
The canonical message for POST requests includes the `Content-Type` value between the URL and the timestamp. GET requests do not include it. If your signing function omits content type for all requests, GETs will pass but POSTs will fail. Make sure the canonical message for non-GET requests carries the exact content-type string you send in the `Content-Type` header. The value differs per endpoint — take it from that endpoint's reference page. See the [POST signing example](/authentication/signing-requests#post-request-adding-an-area-to-a-proposal).
### "I get 402 on every request"
A `402` means the Portal.io account tied to your credentials does not have an active subscription. This can happen in sandbox if your test account was not provisioned correctly. Contact your Portal.io representative to check the account status.
### "I get 409 when updating a proposal"
A `409 Conflict` means the proposal has reached a terminal status — typically Accepted, Completed, or Declined. The API prevents direct edits at that point. To make changes, create a [change order](/concepts/proposals#change-orders) against the proposal instead.
### "I get 400 when adding an area"
Area names must be unique within a proposal. If you try to create an area with the same name as an existing one, the API returns `400`. Similarly, each area supports a maximum of 3 options — adding a fourth returns `400`.
### "I get 400 on a paginated request"
Pagination validation varies by endpoint. Some endpoints reject `PageNumber=0` with a structured `400` error, others silently treat it as page 1, and one (catalog search) accepts `0` but rejects negative values. To avoid issues across all endpoints, always pass `PageNumber=1` or higher. Also note that error response formats differ — some return a structured `responseStatus` object with `errorCode` and field-level details, while others return a bare `Bad Request` string. Your error handling should account for both shapes.
### "My tax totals are always zero"
Tax is calculated based on the location assigned to the proposal. Until you assign a location with `POST /public/proposals/{id}/location/{LocationId}`, all tax amounts remain zero. See [Financial summary](/concepts/proposals#financial-summary) for details.
### "Webhook events are not arriving"
First, confirm your subscription is active by calling `GET /public/webhooks`. Then verify that your endpoint URL is publicly reachable and returns a `200` within a reasonable timeout. Portal.io will retry failed deliveries, but if your endpoint consistently fails, the subscription may be deactivated. Check the [webhook concepts](/concepts/webhooks) page for the full event delivery model.
## Getting help
If you have worked through the troubleshooting steps above and are still stuck, email [support@portal.io](mailto:support@portal.io) with the following details:
* The full request (method, URL, headers — redact your Secret Key)
* The response status code and body
* The UTC timestamp of the request
* Your API Application Key (this is safe to share — it identifies your integration but does not grant access on its own)
This information lets the Portal.io team trace your request in their logs and identify the issue quickly.