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

# Sign Portal.io API Requests with HMAC-SHA256

> 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. Most Portal.io POST endpoints use `application/x-www-form-urlencoded`. The AI Builder endpoints `generate-outline` and `build-proposal` use `application/json`. The AI Builder [upload-content](/api-reference/ai-builder/upload-content) endpoint uses `multipart/form-data` — and for multipart, the value used in the signing string must include the full `boundary=<value>` parameter, not just `multipart/form-data` (see [Sign multipart requests](#sign-multipart-requests) below). Always check the endpoint's documentation for the correct value — the content type in your signing string must match the `Content-Type` header exactly or the request will fail with 401.
* **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/x-www-form-urlencoded`, not `Application/X-WWW-Form-Urlencoded`).
* 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.

<Warning>
  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.
</Warning>

## 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=<value>` 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=`

<Warning>
  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.
</Warning>

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.

<CodeGroup>
  ```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');
  }
  ```
</CodeGroup>

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