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.
The canonical message is a single string formed by concatenating these components with no separator:
For GET requests:
For POST, PUT, and other non-GET requests:
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 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 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.
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:
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:
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:
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:
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.
Full request example
Here is how the computed signature fits into a complete request. This example performs the initial credential exchange:
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.