{ }
REST API
Ahsuite REST API BETA
Programmatically create portals and conversations from your server. Authenticate with HTTP Basic auth using your Owner Email and Authorization Token.
🔑
Security
Authentication & Connected Apps
Your API credentials for HTTP Basic auth, and the apps connected to your Ahsuite account through OAuth.

Authentication

Dates: every date-time in a response (created_date, last_updated, approved_date, generated_date, password_changed_at…) is ISO 8601 in the portal's timezone, with its offset, e.g. 2026-10-16T09:15:30-07:00. Date-only fields such as due_date stay YYYY-MM-DD.

There are two ways to authenticate with Ahsuite:

  • API Token (Basic auth) — for your own code: server scripts, cron jobs, Zapier-style tools and direct REST API calls. Use the credentials below.
  • OAuth — for AI apps & third-party apps (Claude, ChatGPT, Cursor...). No token to copy: the app opens Ahsuite in your browser, you log in and click Approve. See Connect from an AI app below.

API Token (Basic auth)

Pass your Owner Email as the username and one of your API tokens as the password in the Authorization header. Tokens are shown only once when generated — if you lose one, revoke it and generate a new one. Never expose a token in client-side code or public repos.


Connect from an AI app (MCP)

Ahsuite is an MCP server — connect it once and your AI app can list portals, create tasks, start conversations and more, just by chatting. Authentication is OAuth: the app opens Ahsuite in your browser, you log in and click Approve. No token needed.

MCP https://ahsuite.com/api/mcp
  • claude.ai (web) — Settings → Connectors → Add custom connector → paste the URL above → Add → log in & Approve.
  • Claude Desktop — Settings → Connectors → Add custom connector → same URL.
  • ChatGPT — Settings → Connectors/Plugins (enable Developer mode) → Add custom connector → same URL.
  • Any app that supports MCP (Cursor, Windsurf, VS Code...) — add Ahsuite as an MCP server using the URL above; the app will open the Ahsuite login & Approve flow automatically.
  • Claude Code (terminal) — run the command below, then /mcp → Authenticate:
claude mcp add --transport http ahsuite https://ahsuite.com/api/mcp

Running from a server or CI where a browser login isn't possible? Use Basic auth on the same URL instead: Authorization: Basic base64(email:token).


OAuth for third-party apps

Building an app that connects to your users' Ahsuite accounts? Use OAuth 2.1 (authorization code + PKCE). Your app never sees the user's password or token — the user logs in on Ahsuite and approves access. The tokens you receive work on the whole REST API and the MCP endpoint.

1. Register your app (once)

curl -X POST https://ahsuite.com/oauth/register \
  -H "Content-Type: application/json" \
  -d '{"client_name":"My App","redirect_uris":["https://myapp.com/callback"]}'

Response includes your client_id. Redirect URIs must be https (or http://localhost for development).

2. Send the user to the consent screen

https://ahsuite.com/oauth/authorize?response_type=code
  &client_id=CLIENT_ID
  &redirect_uri=https://myapp.com/callback
  &state=RANDOM_STATE
  &code_challenge=PKCE_S256_CHALLENGE
  &code_challenge_method=S256
  &resource=https://ahsuite.com/api/mcp

The user logs in and clicks Approve. Ahsuite redirects back to your redirect_uri with ?code=...&state=.... PKCE is required. resource declares which endpoint the token is for (RFC 8707) — required for MCP clients, optional for REST-only apps.

3. Exchange the code for tokens

curl -X POST https://ahsuite.com/oauth/token \
  -d "grant_type=authorization_code&code=CODE&redirect_uri=https://myapp.com/callback&client_id=CLIENT_ID&code_verifier=PKCE_VERIFIER&resource=https://ahsuite.com/api/mcp"

Returns an access_token (valid 1 hour) and a refresh_token (valid 30 days, rotates on every use).

4. Call the API

curl https://ahsuite.com/api/portal/list \
  -H "Authorization: Bearer ACCESS_TOKEN"

5. Refresh when the access token expires

curl -X POST https://ahsuite.com/oauth/token \
  -d "grant_type=refresh_token&refresh_token=REFRESH_TOKEN&client_id=CLIENT_ID"

Machine-readable config is published at /.well-known/oauth-authorization-server — standard OAuth libraries can configure themselves from it. Users can disconnect your app anytime from Connected Apps.


Getting a 403 error?

A 403 response — on the REST API, the MCP endpoint or the OAuth flow — usually means one of these:

  • Plan — API access requires a Professional or Agency plan.
  • OAuth redirect — your redirect_uri must be https://.
  • Blocked by our system — repeated failed or unusual requests are blocked automatically.

If your plan and setup are correct and it keeps happening, contact us at sudhir@ahsuite.com.


Add a Portal

Create a new portal with a primary contact and optional custom variables. Returns the new client_id on success.

POST https://ahsuite.com/api/portal/create
Body parameters
portal_name required
string
The name of the portal to create.
contact_email required
string
Email of the primary contact for this portal.
contact_name required
string
Name of the primary contact.
variables[n][name]
string
Custom variable name. Optional. e.g. address.
variables[n][scope]
string
Custom variable scope. One of portal or user.
variables[n][value]
string
Custom variable value. Optional. e.g. delhi.
apply_template
integer
Optional. Set 0 to create a clean portal without your default portal template (blueprint). Omit or 1 = the default template is applied, same as creating a portal from the app.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  -F 'portal_name=Portal Name' \
  -F 'contact_email=contact@example.com' \
  -F 'contact_name=Contact Name' \
  -F 'variables[0][name]=address' \
  -F 'variables[0][value]=delhi' \
  'https://ahsuite.com/api/portal/create'
$ch = curl_init();
$data = [
    'portal_name'         => 'Portal Name here',
    'contact_email'       => 'contact@example.com',
    'contact_name'        => 'Contact Name here',
    'variables[0][name]'  => 'address',
    'variables[0][value]' => 'delhi',
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/create');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const form = new URLSearchParams();
form.append('portal_name', 'Portal Name');
form.append('contact_email', 'contact@example.com');
form.append('contact_name', 'Contact Name');
form.append('variables[0][name]', 'address');
form.append('variables[0][value]', 'delhi');

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/create', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body: form,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/create',
    auth=('EMAIL', 'TOKEN'),
    data={
        'portal_name':         'Portal Name',
        'contact_email':       'contact@example.com',
        'contact_name':        'Contact Name',
        'variables[0][name]':  'address',
        'variables[0][value]': 'delhi',
    },
)
print(res.json())
Response
{
  "success": true,
  "message": "Portal has been added successfully.",
  "data": {
    "portal_id": "vJW4"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"portal_name\":[\"The portal name field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}

List Portals

Return the portals owned by the authenticated account, newest first. Only active portals are returned. The portal_id in each row is the same ID accepted by every other endpoint on this page. Results are paginated — use page and limit to walk through the full list.

GET https://ahsuite.com/api/portal/list
Query parameters
search
string
Filter by portal name — returns portals whose name contains this text. Max 100 characters.
page
integer
Page number to return. Defaults to 1.
limit
integer
Rows per page. Defaults to 20, maximum 100.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request GET \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/list?search=Acme&page=1&limit=20'
$params = http_build_query([
    'search' => 'Acme',
    'page' => 1,
    'limit' => 20,
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/list?'.$params);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const params = new URLSearchParams({ search: 'Acme', page: 1, limit: 20 });
const res = await fetch(`https://ahsuite.com/api/portal/list?${params}`, {
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.get(
    'https://ahsuite.com/api/portal/list',
    params={'search': 'Acme', 'page': 1, 'limit': 20},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "",
  "data": {
    "portals": [
      {
        "name": "Acme Corp",
        "created_date": "2026-05-12T10:04:33-07:00",
        "portal_id": "vJW4"
      },
      {
        "name": "Acme Digital",
        "created_date": "2026-04-02T08:11:20-07:00",
        "portal_id": "vJW3"
      }
    ],
    "pagination": {
      "total": 42,
      "page": 1,
      "limit": 20,
      "total_pages": 3
    }
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"limit\":[\"The limit may not be greater than 100.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}

Portal Detail

Return the full details of a single portal — name, contact info and the preference fields accepted by Update Portal Preferences, plus nested sidebar, client_user, account_manager, branding, email_notifications and variables sections. Every field uses the same name its settings update endpoint accepts. Requires a valid portal_id (get one from List Portals).

GET https://ahsuite.com/api/portal/detail
Query parameters
portal_id required
string
Unique Portal ID. See example →

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request GET \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/detail?portal_id=vJW4'
$params = http_build_query([
    'portal_id' => 'vJW4',
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/detail?'.$params);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const params = new URLSearchParams({ portal_id: 'vJW4' });
const res = await fetch(`https://ahsuite.com/api/portal/detail?${params}`, {
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.get(
    'https://ahsuite.com/api/portal/detail',
    params={'portal_id': 'vJW4'},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "",
  "data": {
    "name": "Acme Corp",
    "phone": "+1 480 555 0117",
    "website": "acmecorp.com",
    "language": "en",
    "timezone": "America/Phoenix",
    "content_align": "align_left",
    "two_step_auth": 0,
    "portal_search": 1,
    "files_no_login": 1,
    "files_hide_preview": 0,
    "created_date": "2026-05-12T10:04:33-07:00",
    "portal_id": "vJW4",
    "sidebar": {
      "show_pages": 1, "show_files": 1, "show_password": 0, "show_tasks": 1, "show_conversations": 1, "show_projects": 1,
      "label_files": "Files", "label_password": "", "label_tasks": "Tasks", "label_conversations": "", "label_projects": "",
      "order": ["pages", "tasks", "conversations", "files", "password", "projects"],
      "default_tab": "pages",
      "links": [{ "name": "Our Website", "url": "https://example.com", "same": 0 }]
    },
    "client_user": { "allow_add_portal_users": 0, "email_receive": 1, "allow_page_add": 0 },
    "account_manager": {
      "show_card": 1, "show_avatar": 1, "show_name": 1, "show_title": 1, "show_email": 1, "show_phone": 0,
      "display_name": "", "title": "Account Manager", "email": "", "phone": "",
      "avatar_source": "profile_picture",
      "action_buttons": {
        "primary": "message",
        "request": { "enabled": 1, "text": "" },
        "schedule": { "enabled": 1, "text": "", "calendar_link": "", "open_in_iframe": 0 },
        "message": { "enabled": 1, "text": "" }
      }
    },
    "branding": { "logo_url": "https://d1ea3bsweroqag.cloudfront.net/assets/client/logo/cl-1234.png", "custom_css": "" },
    "email_notifications": {
      "summary_email_frequency": "daily",
      "emails": {
        "invitation": { "subject": "", "body": "" },
        "task_completed": { "subject": "", "button_text": "", "body": "" },
        "action_required": { "subject": "", "button_text": "", "body": "" },
        "file_requested": { "subject": "", "button_text": "", "body": "" },
        "needs_attention": { "subject": "", "button_text": "", "body": { "main_heading": "", "task_heading": "", "notification_heading": "", "last_heading": "" } }
      }
    },
    "variables": [{ "name": "project_manager", "scope": "portal", "value": "Jane" }]
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"portal_id\":[\"The portal id field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

Create a Conversation

Create a new conversation inside an existing portal. Requires a valid portal_id.

POST https://ahsuite.com/api/portal/conversation/create
Query parameters
portal_id required
string
Unique Portal ID. See example →
name required
string
The conversation name.
email_not_send
integer
Set 1 to not create an email thread for this conversation. Default 0. Always treated as 1 on HIPAA-compliant portals.
share_with_client
integer
Set 1 to make the conversation visible to the client. Default 0.
share_with_team
integer
Set 1 to share the conversation with your internal team. Default 0.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/conversation/create?portal_id=PORTAL_ID&name=Conversation+Name'
$ch = curl_init();
$data = [ 'portal_id' => 'PORTAL_ID', 'name' => 'Conversation Name' ];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/conversation/create');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const url = new URL('https://ahsuite.com/api/portal/conversation/create');
url.searchParams.set('portal_id', 'PORTAL_ID');
url.searchParams.set('name', 'Conversation Name');

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch(url, {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/conversation/create',
    auth=('EMAIL', 'TOKEN'),
    params={'portal_id': 'PORTAL_ID', 'name': 'Conversation Name'},
)
print(res.json())
Response
{
  "success": true,
  "message": "Conversation has been added successfully.",
  "data": {
    "conversation_id": 812,
    "reply_email": "c-812-x9k2@reply.ahsuite.com"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"name\":[\"The name field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

List Conversations

Return the conversations of a portal, most recently updated first. Archived conversations are excluded, and visibility matches what the account owner sees in the app — conversations shared with the team plus the owner's own, while other agents' private conversations stay hidden. Results are paginated.

GET https://ahsuite.com/api/portal/conversation/list
Query parameters
portal_id required
string
Unique Portal ID. See example →
search
string
Filter by conversation name — returns conversations whose name contains this text. Max 100 characters.
page
integer
Page number to return. Defaults to 1.
limit
integer
Rows per page. Defaults to 20, maximum 100.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request GET \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/conversation/list?portal_id=vJW4&page=1&limit=20'
$params = http_build_query([
    'portal_id' => 'vJW4',
    'page' => 1,
    'limit' => 20,
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/conversation/list?'.$params);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const params = new URLSearchParams({ portal_id: 'vJW4', page: 1, limit: 20 });
const res = await fetch(`https://ahsuite.com/api/portal/conversation/list?${params}`, {
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.get(
    'https://ahsuite.com/api/portal/conversation/list',
    params={'portal_id': 'vJW4', 'page': 1, 'limit': 20},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "",
  "data": {
    "conversations": [
      {
        "name": "Website revamp discussion",
        "new_message": 1,
        "created_date": "2026-06-10T09:22:45-07:00",
        "last_updated": "2026-06-14T16:03:12-07:00",
        "conversation_id": 271
      },
      {
        "name": "Onboarding questions",
        "new_message": 0,
        "created_date": "2026-05-28T11:40:03-07:00",
        "last_updated": "2026-06-01T10:15:57-07:00",
        "conversation_id": 265
      }
    ],
    "pagination": {
      "total": 12,
      "page": 1,
      "limit": 20,
      "total_pages": 1
    }
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"portal_id\":[\"The portal id field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

Conversation Detail

Return a single conversation with its last 25 messages (oldest of those first). The conversation_id comes from List Conversations. Share flags use the same names as Create Conversation.

GET https://ahsuite.com/api/portal/conversation/detail
Query parameters
portal_id required
string
Unique Portal ID. See example →
conversation_id required
integer
The conversation ID, as returned by List Conversations.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request GET \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/conversation/detail?portal_id=vJW4&conversation_id=271'
$params = http_build_query([
    'portal_id' => 'vJW4',
    'conversation_id' => 271,
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/conversation/detail?'.$params);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const params = new URLSearchParams({ portal_id: 'vJW4', conversation_id: 271 });
const res = await fetch(`https://ahsuite.com/api/portal/conversation/detail?${params}`, {
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.get(
    'https://ahsuite.com/api/portal/conversation/detail',
    params={'portal_id': 'vJW4', 'conversation_id': 271},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "",
  "data": {
    "name": "Website revamp discussion",
    "share_with_client": 1,
    "share_with_team": 1,
    "email_not_send": 0,
    "created_date": "2026-06-10T09:22:45-07:00",
    "last_updated": "2026-06-14T16:03:12-07:00",
    "logs": [
      {
        "message": "<p>Here is the first homepage draft.</p>",
        "from": "team",
        "created_date": "2026-06-14T15:58:02-07:00"
      },
      {
        "message": "<p>Looks great, approved!</p>",
        "from": "client",
        "created_date": "2026-06-14T16:03:12-07:00"
      }
    ],
    "conversation_id": 271
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"conversation_id\":[\"The conversation id field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Conversation record not found."
}

Reply to a Conversation

Post a reply in an existing conversation — exactly like replying in the app. The team is notified and, when the conversation is shared with the client, the client is notified and emailed (unless emails are turned off for that conversation). One optional attachment per request (multipart/form-data).

POST https://ahsuite.com/api/portal/conversation/reply
Body parameters
portal_id required
string
Unique Portal ID. See example →
conversation_id required
integer
The conversation, from List Conversations or Conversation Detail. A conversation of another portal, or one you are not part of, returns 404.
message required
string
The reply text. Max 4100 characters. HTML allowed: p, br, strong, em, ul, ol, li, a; other tags are stripped.
attachment
file
Optional file to attach to the reply (multipart/form-data). Same allowed types as the app; max 9 MB. Not available through the MCP connector.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "conversation_id=276" \
  --data-urlencode "message=Thanks, reviewing this now." \
  'https://ahsuite.com/api/portal/conversation/reply'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'conversation_id' => 276,
    'message' => 'Thanks, reviewing this now.',
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/conversation/reply');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({ portal_id: 'vJW4', conversation_id: '276', message: 'Thanks, reviewing this now.' });

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/conversation/reply', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/conversation/reply',
    data={'portal_id': 'vJW4', 'conversation_id': 276, 'message': 'Thanks, reviewing this now.'},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Message has been added successfully.",
  "data": {
    "conversation_id": 276,
    "message_id": 9120
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "This Conversation is maxed out! You'll need to create a new Conversation to continue the discussion."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Conversation record not found."
}

Update a Conversation

Rename a conversation and/or turn its email notifications on or off. This is a partial update: only the parameters you send are changed. On HIPAA compliant portals conversation emails are always off and cannot be turned on, as in the app.

POST https://ahsuite.com/api/portal/conversation/update
Body parameters
portal_id required
string
Unique Portal ID. See example →
conversation_id required
integer
The conversation, from List Conversations.
name
string
New conversation name. Max 200 characters. Cannot be blank.
email_not_send
integer
1 stops emailing the client about this conversation (they still see it in the portal), 0 emails them again — the "email thread" toggle in the app. Returns 400 when trying to turn emails on for a HIPAA compliant portal.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "conversation_id=276" \
  --data-urlencode "name=Q3 planning" \
  --data-urlencode "email_not_send=0" \
  'https://ahsuite.com/api/portal/conversation/update'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'conversation_id' => 276,
    'name' => 'Q3 planning',
    'email_not_send' => 0,
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/conversation/update');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({ portal_id: 'vJW4', conversation_id: '276', name: 'Q3 planning', email_not_send: '0' });

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/conversation/update', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/conversation/update',
    data={'portal_id': 'vJW4', 'conversation_id': 276, 'name': 'Q3 planning', 'email_not_send': 0},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Conversation has been updated successfully.",
  "data": {
    "conversation_id": 276
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "This setting cannot be changed for a HIPAA compliant client."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Conversation record not found."
}

Create a Page

Create a new page inside an existing portal with text, HTML code, an embedded URL, a video, an image, files (uploads or file requests) and/or form questions. The content blocks are auto-detected — any content field you send becomes visible on the page. All content fields are optional; sending only a title creates a blank page you can edit later. Returns the new page_id on success.

POST https://ahsuite.com/api/portal/page/create
Body parameters
portal_id required
string
Unique Portal ID. See example →
title required
string
The page title. Max 50 characters.
text Text block
text[content]
string
Rich text content of the page.
Block heading
text[heading]
string
Heading shown above the text content. Optional, used only when text[content] is sent.
text[description]
string
Short description shown under the text heading. Optional.
code HTML code block
code[content]
string
Raw HTML code rendered on the page.
Block heading
code[heading]
string
Heading shown above the code block. Optional, used only when code[content] is sent.
code[description]
string
Short description shown under the code heading. Optional.
embed Embedded URL
embed[url]
string
A URL to embed on the page (website, Google Doc, Figma, etc.).
embed[height]
integer
Height of the embed frame in pixels. Optional.
Block heading
embed[heading]
string
Heading shown above the embed. Optional, used only when embed[url] is sent.
embed[description]
string
Short description shown under the embed heading. Optional.
video Video
video[url]
string
A video URL to show on the page (YouTube, Vimeo, Loom, etc.).
Block heading
video[heading]
string
Heading shown above the video. Optional, used only when video[url] is sent.
video[description]
string
Short description shown under the video heading. Optional.
image Image
image[file]
file
Image file sent as multipart/form-data. Allowed types: jpg, png, jpeg, gif, svg, webp. Max 5MB.
Block heading
image[heading]
string
Heading shown above the image. Optional, used only when image[file] is sent.
image[description]
string
Short description shown under the image heading. Optional.
files Files — each files[N] index is ONE item: either a file upload or a file request, never both. Max 10 items per page.
Item type 1 — upload a file, e.g. files[0]
files[0][file]
file
File sent as multipart/form-data. Added to the portal's Files and shown on the page. Allowed types: doc, pdf, docx, zip, txt, xls, xlsx, csv, jpg, png, jpeg, webp, gif, html, svg, mp4, mp3, wav, mov and more. Max 15MB per file.
files[0][file_name]
string
Display name for the uploaded file. Optional, defaults to the original filename.
Item type 2 — request a file from the client user, e.g. files[1] (its own index, no file field)
files[1][title]
string
Title of the file you are asking the client user to upload. Max 100 characters.
files[1][description]
string
Short description shown with the file request. Optional.
Block heading
files[heading]
string
Heading shown above the files block. Optional, used only when a file or file request is sent.
files[description]
string
Short description shown under the files heading. Optional.
questions Form questions — each questions[N] index is ONE question. Max 20 questions per page.
questions[0][question_type]
string Also section and repeat_section — a group of questions (a repeat section can be filled in several times by the client); put its questions in questions[0][questions][M][...].
One of paragraph (default), multiple_choice, checkbox, date, heading, file, signature.
questions[0][question_name]
string
The question text shown to the client user. Max 500 characters. Increment the index (0, 1, 2…) to add multiple questions.
questions[0][description]
string
Short help text shown under the question. Optional.
questions[0][required]
integer
Set 1 to make the question required. Default 0.
questions[0][options][]
string
Answer options — required for multiple_choice and checkbox types only. Max 20 options per question.
questions[0][variable_name]
string
Saves the answer into a custom variable with this name. Optional.
questions[0][variable_scope]
string
portal (default) or user. Used only when variable_name is sent.
questions[0][questions][0][...]
array
Only when questions[0][question_type] is section or repeat_section: the questions inside that section, with the same fields (question_type, question_name, description, required, options[], variable_name, variable_scope). One level only. They count toward the 20-question limit.
Block heading
questions[heading]
string
Heading shown above the questions block. Optional, used only when a question is sent.
questions[description]
string
Short description shown under the questions heading. Optional.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  -F 'portal_id=PORTAL_ID' \
  -F 'title=Page Title' \
  -F 'text[content]=Welcome to your new page.' \
  -F 'text[heading]=Getting Started' \
  -F 'code[content]=<div><h3>Hello</h3></div>' \
  -F 'embed[url]=https://docs.google.com/document/d/XXXX' \
  -F 'video[url]=https://www.youtube.com/watch?v=XXXX' \
  -F 'image[file]=@/path/to/photo.jpg' \
  -F 'files[0][file]=@/path/to/brief.pdf' \
  -F 'files[0][file_name]=Project Brief' \
  -F 'files[1][title]=Signed contract' \
  -F 'questions[0][question_name]=What is your main business goal?' \
  -F 'questions[1][question_type]=multiple_choice' \
  -F 'questions[1][question_name]=Preferred contact method?' \
  -F 'questions[1][options][]=Email' \
  -F 'questions[1][options][]=Phone' \
  'https://ahsuite.com/api/portal/page/create'
$ch = curl_init();
$data = [
    'portal_id'       => 'PORTAL_ID',
    'title'           => 'Page Title',
    'text[content]'   => 'Welcome to your new page.',
    'text[heading]'   => 'Getting Started',
    'code[content]'   => '<div><h3>Hello</h3></div>',
    'embed[url]'      => 'https://docs.google.com/document/d/XXXX',
    'video[url]'      => 'https://www.youtube.com/watch?v=XXXX',
    'image[file]'     => new CURLFile('/path/to/photo.jpg'),
    'files[0][file]'  => new CURLFile('/path/to/brief.pdf'),
    'files[0][file_name]' => 'Project Brief',
    'files[1][title]' => 'Signed contract',
    'questions[0][question_name]' => 'What is your main business goal?',
    'questions[1][question_type]' => 'multiple_choice',
    'questions[1][question_name]' => 'Preferred contact method?',
    'questions[1][options][0]' => 'Email',
    'questions[1][options][1]' => 'Phone',
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/page/create');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data); // array = multipart/form-data (required for image[file])
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';
import FormData from 'form-data';
import fs from 'fs';

const form = new FormData();
form.append('portal_id', 'PORTAL_ID');
form.append('title', 'Page Title');
form.append('text[content]', 'Welcome to your new page.');
form.append('text[heading]', 'Getting Started');
form.append('code[content]', '<div><h3>Hello</h3></div>');
form.append('embed[url]', 'https://docs.google.com/document/d/XXXX');
form.append('video[url]', 'https://www.youtube.com/watch?v=XXXX');
form.append('image[file]', fs.createReadStream('/path/to/photo.jpg'));
form.append('files[0][file]', fs.createReadStream('/path/to/brief.pdf'));
form.append('files[0][file_name]', 'Project Brief');
form.append('files[1][title]', 'Signed contract');
form.append('questions[0][question_name]', 'What is your main business goal?');
form.append('questions[1][question_type]', 'multiple_choice');
form.append('questions[1][question_name]', 'Preferred contact method?');
form.append('questions[1][options][]', 'Email');
form.append('questions[1][options][]', 'Phone');

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/page/create', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body: form,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/page/create',
    auth=('EMAIL', 'TOKEN'),
    data={
        'portal_id':       'PORTAL_ID',
        'title':           'Page Title',
        'text[content]':   'Welcome to your new page.',
        'text[heading]':   'Getting Started',
        'code[content]':   '<div><h3>Hello</h3></div>',
        'embed[url]':      'https://docs.google.com/document/d/XXXX',
        'video[url]':      'https://www.youtube.com/watch?v=XXXX',
        'files[0][file_name]': 'Project Brief',
        'files[1][title]': 'Signed contract',
        'questions[0][question_name]': 'What is your main business goal?',
        'questions[1][question_type]': 'multiple_choice',
        'questions[1][question_name]': 'Preferred contact method?',
        'questions[1][options][0]': 'Email',
        'questions[1][options][1]': 'Phone',
    },
    files={
        'image[file]': open('/path/to/photo.jpg', 'rb'),
        'files[0][file]': open('/path/to/brief.pdf', 'rb'),
    },
)
print(res.json())
Response
{
  "success": true,
  "message": "Page has been added successfully.",
  "data": {
    "page_id": "vJW4"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"title\":[\"The title field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

List Pages

Return the pages of a portal, in the same order they appear in the app (grouped by category, then by manual order). Archived pages are excluded. Results are paginated.

GET https://ahsuite.com/api/portal/page/list
Query parameters
portal_id required
string
Unique Portal ID. See example →
search
string
Filter by page title — returns pages whose title contains this text. Max 100 characters.
page
integer
Page number to return. Defaults to 1.
limit
integer
Rows per page. Defaults to 20, maximum 100.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request GET \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/page/list?portal_id=vJW4&page=1&limit=20'
$params = http_build_query([
    'portal_id' => 'vJW4',
    'page' => 1,
    'limit' => 20,
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/page/list?'.$params);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const params = new URLSearchParams({ portal_id: 'vJW4', page: 1, limit: 20 });
const res = await fetch(`https://ahsuite.com/api/portal/page/list?${params}`, {
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.get(
    'https://ahsuite.com/api/portal/page/list',
    params={'portal_id': 'vJW4', 'page': 1, 'limit': 20},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "",
  "data": {
    "pages": [
      {
        "title": "Monthly SEO Report",
        "category": "Reports",
        "draft": 0,
        "created_date": "2026-06-02T14:20:11-07:00",
        "page_id": "9dRt"
      },
      {
        "title": "Onboarding Checklist",
        "category": "",
        "draft": 1,
        "created_date": "2026-05-18T09:31:47-07:00",
        "page_id": "8kLm"
      }
    ],
    "pagination": {
      "total": 8,
      "page": 1,
      "limit": 20,
      "total_pages": 1
    }
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"portal_id\":[\"The portal id field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

Page Detail

Return a single page with its content — title, category, draft flag and its tabs in the order the page editor shows them. Each tab returns its text block (HTML content) embed block (embedded URL or embed code, plus height) image block (file_name plus a temporary url valid for 20 minutes) and files block (items[] — the files and file requests placed on the tab, with the same file / file_name / title / description fields Create a Page accepts (file = the uploaded file, file_name = its display name; title and description belong to file requests), type (file or url), a url (the link itself for url items, a temporary download link valid for 20 minutes for uploaded files, empty for an open file request), is_request = 1 for a file request, and a file_id that matches List Files) video block (YouTube url), code block (raw HTML/CSS/JS content) and questions block (items[] — the form questions, with the same fields Create a Page accepts plus a question_id; section and repeat_section containers carry their child questions in a nested questions[]), each with the optional block heading and description, using the same field names Create a Page accepts; a block the tab does not have is returned as null. Archived pages are not returned. Get a page_id from List Pages.

GET https://ahsuite.com/api/portal/page/detail
Query parameters
portal_id required
string
Unique Portal ID. See example →
page_id required
string
The page to return, as given by List Pages or returned by Create a Page.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request GET \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/page/detail?portal_id=vJW4&page_id=9dRt'
$params = http_build_query([
    'portal_id' => 'vJW4',
    'page_id' => '9dRt',
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/page/detail?'.$params);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const params = new URLSearchParams({ portal_id: 'vJW4', page_id: '9dRt' });
const res = await fetch(`https://ahsuite.com/api/portal/page/detail?${params}`, {
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.get(
    'https://ahsuite.com/api/portal/page/detail',
    params={'portal_id': 'vJW4', 'page_id': '9dRt'},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "",
  "data": {
    "title": "Monthly SEO Report",
    "category": "Reports",
    "draft": 0,
    "created_date": "2026-06-02T14:20:11-07:00",
    "page_id": "9dRt",
    "tabs": [
      {
        "tab_id": "3xQa",
        "title": "Tab 1",
        "text": {
          "content": "<h2>Summary</h2><p>Organic traffic grew 18% this month.</p>",
          "heading": "June overview",
          "description": ""
        },
        "embed": null,
        "image": {
          "file_name": "traffic-chart.png",
          "url": "https://s3.amazonaws.com/.../view/API-1718000000-traffic-chart.png?X-Amz-Expires=1200&...",
          "heading": "",
          "description": ""
        },
        "files": null,
        "video": null,
        "code": {
          "content": "<div class=\"cta\"><a href=\"https://example.com/book\">Book a call</a></div>\n<style>.cta a{background:#2f80ed;color:#fff;padding:10px 16px;border-radius:6px}</style>",
          "heading": "",
          "description": ""
        },
        "questions": null
      },
      {
        "tab_id": "3xQb",
        "title": "Tab 2",
        "text": null,
        "embed": {
          "url": "https://docs.google.com/spreadsheets/d/1AbC/edit",
          "height": 800,
          "heading": "Live report",
          "description": "Updated every Monday."
        },
        "image": null,
        "files": {
          "heading": "Documents",
          "description": "",
          "items": [
            { "type": "file", "file": "Contract.pdf", "url": "https://s3.amazonaws.com/.../tool/API-1718000000-Contract.pdf?X-Amz-Expires=1200&...", "file_name": "Signed contract", "title": "", "description": "", "is_request": 0, "file_id": "7pQ1" },
            { "type": "file", "file": "", "url": "", "file_name": "", "title": "Please upload your logo", "description": "PNG or SVG, transparent background.", "is_request": 1, "file_id": "7pQ2" }
          ]
        },
        "video": {
          "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
          "heading": "Walkthrough",
          "description": ""
        },
        "code": null,
        "questions": {
          "heading": "Kick-off form",
          "description": "Takes 2 minutes.",
          "items": [
            { "question_type": "paragraph", "question_name": "What is your main goal this quarter?", "description": "", "required": 1, "options": [], "variable_name": "main_goal", "variable_scope": "portal", "question_id": "5wA1" },
            { "question_type": "multiple_choice", "question_name": "Preferred contact method", "description": "", "required": 0, "options": ["Email", "Phone", "Slack"], "variable_name": "", "variable_scope": "", "question_id": "5wA2" },
            { "question_type": "repeat_section", "question_name": "Team members", "description": "", "required": 0, "options": [], "variable_name": "", "variable_scope": "", "question_id": "5wA3",
              "questions": [
                { "question_type": "paragraph", "question_name": "Full name", "description": "", "required": 1, "options": [], "variable_name": "", "variable_scope": "", "question_id": "5wA4" }
              ]
            }
          ]
        }
      }
    ]
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"page_id\":[\"The page id field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Page record not found."
}

Update a Page

Update an existing page. This is a partial update: only the parameters you send are changed, every other field and block on the page is left untouched. Sending a parameter as an empty string clears that field. Page-level fields (title, draft, category) need only the page_id; content blocks are edited per tab and need the tab_id from Page Detail. All content blocks are supported: text, code, video, image, files, questions and embed. Send the request as multipart/form-data when uploading an image or files.

POST https://ahsuite.com/api/portal/page/update
Body parameters
portal_id required
string
Unique Portal ID. See example →
page_id required
string
The page to update, from List Pages or Page Detail.
title
string
New page title. Max 50 characters.
draft
integer
1 marks the page as a draft (hidden from the client), 0 publishes it.
category
string
Category to place the page in, by name (max 20 characters). If no category with that name exists in the portal yet, it is created — the same as when a template is applied. Send an empty string to remove the page from its category.
tab_id
string
The tab whose content block you are editing, from Page Detail tabs[].tab_id. Required when any block parameter or tab_title is sent. New tabs are created with Add a Tab.
tab_title
string
Rename the tab given by tab_id. Max 100 characters.
text Text block
text[enabled]
integer
0 hides the text block on this tab, 1 shows it again. Content is kept either way (the X Text / + Text toggle). Sending any other text field turns the block on automatically.
text[content]
string
Rich text (HTML) content of the block. Max 50000 characters. Replaces the current content — read it with Page Detail first if you only want to change part of it. Empty string clears it.
Block heading
text[heading]
string
Heading shown above the text. Max 250 characters.
text[description]
string
Short description shown under the heading. Max 500 characters.
code Code block
code[enabled]
integer
0 hides the code block on this tab, 1 shows it again. Content is kept either way. Sending any other code field turns the block on automatically.
code[content]
string
Raw HTML/CSS/JS rendered on the page. Max 50000 characters. Replaces the current content. Empty string clears it.
Block heading
code[heading]
string
Heading shown above the code block. Max 250 characters.
code[description]
string
Short description shown under the heading. Max 500 characters.
video Video block
video[enabled]
integer
0 hides the video block on this tab, 1 shows it again. Content is kept either way. Sending any other video field turns the block on automatically.
video[url]
string
YouTube video URL. Max 1000 characters. Empty string clears it.
Block heading
video[heading]
string
Heading shown above the video. Max 250 characters.
video[description]
string
Short description shown under the heading. Max 500 characters.
image Image block
image[enabled]
integer
0 hides the image block on this tab, 1 shows it again. The image is kept either way. Sending any other image field turns the block on automatically.
image[file]
file
A new image for the block (multipart/form-data). Allowed types: jpg, jpeg, png, gif, svg, webp. Max 5 MB. Replaces the current image on the block. Not available through the MCP connector.
Block heading
image[heading]
string
Heading shown above the image. Max 250 characters.
image[description]
string
Short description shown under the heading. Max 500 characters.
files Files block
files[enabled]
integer
0 hides the files block on this tab, 1 shows it again. The files are kept either way. Sending any other files field turns the block on automatically.
files[items][0][file]
file
A file to upload and add to the tab (multipart/form-data). Same types and 15 MB limit as Create a Page. Existing files on the tab are kept; the portal-wide file limit applies to new files. Not available through the MCP connector.
files[items][0][file_name]
string
Display name for the uploaded file. Max 100 characters. Defaults to the original file name.
files[items][0][file_id]
string
Select from existing files — the file_id of a file already in this portal (from List Files, uploads and links alike) to place on the tab. A file already on the tab is skipped. Files of other portals return 404.
files[items][0][title]
string
Send a title without a file to add a file request — a card asking the client to upload something. Max 100 characters.
files[items][0][description]
string
Details for the file request. Max 500 characters.
Block heading
files[heading]
string
Heading shown above the files. Max 250 characters.
files[description]
string
Short description shown under the heading. Max 500 characters.
questions Form block
questions[enabled]
integer
0 hides the form block on this tab, 1 shows it again. The questions are kept either way. Sending any other questions field turns the block on automatically.
questions[0][question_type]
string
Add new questions with the same questions[N][...] items as Create a Page: paragraph (default), multiple_choice, checkbox, date, heading, file, signature, plus section and repeat_section (a group of questions; a repeat section can be filled in several times by the client). Every call appends — existing questions are never changed or removed. A question whose name already exists on the tab (case-insensitive) is skipped and returned in skipped_questions. Max 20 per request, 250 per tab.
questions[0][question_name]
string
The question text. Max 500 characters. An item without it is skipped.
questions[0][description]
string
Help text shown under the question. Max 500 characters.
questions[0][required]
integer
1 makes the question mandatory. Default 0.
questions[0][options][]
array
The choices for a multiple_choice or checkbox question, one per options[] entry. Required for those types. Max 20, each max 500 characters.
questions[0][variable_name]
string
Save the client's answer into this portal variable. Created if it does not exist yet. Max 255 characters.
questions[0][variable_scope]
string
portal (default) or user — scope of variable_name.
questions[0][section_id]
string
Place the new question inside an existing section or repeat_section of this tab — its question_id from Page Detail. Not allowed on section types themselves. A section of another tab or portal returns 404.
questions[0][questions][0][...]
array
Only when questions[0][question_type] is section or repeat_section: the questions to create inside the new section, with the same fields (question_type, question_name, description, required, options[], variable_name, variable_scope). One level only — a section cannot contain another section. Max 20.
Block heading
questions[heading]
string
Heading shown above the form. Max 250 characters.
questions[description]
string
Short description shown under the heading. Max 500 characters.
embed Embed block
embed[enabled]
integer
0 hides the embed block on this tab, 1 shows it again. The block's content is kept either way — the same as the X Embed / + Embed toggle in the page editor. Sending any other embed field turns the block on automatically.
embed[url]
string
The URL to embed (Google Docs / Sheets / Slides, Airtable, Calendly, etc). Max 1000 characters. Empty string clears it. If the tab has no embed block yet, one is added.
embed[height]
integer
Frame height in pixels, max 10000. Empty string resets it to automatic.
Block heading
embed[heading]
string
Heading shown above the embed. Max 250 characters.
embed[description]
string
Short description shown under the heading. Max 500 characters.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "page_id=9dRt" \
  --data-urlencode "title=Monthly SEO Report (June)" \
  --data-urlencode "tab_id=3xQb" \
  --data-urlencode "embed[url]=https://docs.google.com/spreadsheets/d/1AbC/edit" \
  --data-urlencode "embed[height]=800" \
  'https://ahsuite.com/api/portal/page/update'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'page_id' => '9dRt',
    'title' => 'Monthly SEO Report (June)',
    'tab_id' => '3xQb',
    'embed' => ['url' => 'https://docs.google.com/spreadsheets/d/1AbC/edit', 'height' => 800],
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/page/update');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({
  portal_id: 'vJW4',
  page_id: '9dRt',
  title: 'Monthly SEO Report (June)',
  tab_id: '3xQb',
  'embed[url]': 'https://docs.google.com/spreadsheets/d/1AbC/edit',
  'embed[height]': '800',
});

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/page/update', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/page/update',
    data={
        'portal_id': 'vJW4',
        'page_id': '9dRt',
        'title': 'Monthly SEO Report (June)',
        'tab_id': '3xQb',
        'embed[url]': 'https://docs.google.com/spreadsheets/d/1AbC/edit',
        'embed[height]': 800,
    },
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Page has been updated successfully.",
  "data": {
    "page_id": "9dRt",
    "skipped_questions": ["Preferred contact"]
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "tab_id is required when sending content blocks (get it from page/detail)."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Tab record not found."
}

Add a Tab

Add a new, empty tab to a page — the same as + Add Tab in the page editor. Returns the new tab_id; fill it with content blocks using Update a Page. A page can have at most 20 tabs.

POST https://ahsuite.com/api/portal/page/tab/add
Body parameters
portal_id required
string
Unique Portal ID. See example →
page_id required
string
The page to add the tab to, from List Pages or Page Detail.
title
string
Name of the tab shown to the client. Max 100 characters. Defaults to Tab N. Can be changed later with tab_title on Update a Page.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "page_id=9dRt" \
  --data-urlencode "title=Resources" \
  'https://ahsuite.com/api/portal/page/tab/add'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'page_id' => '9dRt',
    'title' => 'Resources',
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/page/tab/add');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({ portal_id: 'vJW4', page_id: '9dRt', title: 'Resources' });

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/page/tab/add', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/page/tab/add',
    data={'portal_id': 'vJW4', 'page_id': '9dRt', 'title': 'Resources'},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Tab has been added successfully.",
  "data": {
    "tab_id": "3xQc",
    "title": "Resources",
    "page_id": "9dRt"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "Maximum 20 tabs can be added."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Page record not found."
}

Update a Question

Edit one form question of a page — the same as the pencil icon in the page editor. This is a partial update: only the parameters you send are changed. As in the app, only the question text, its description and the required flag can be edited; the type, options and variable are fixed once a question is created (add a new question with Update a Page and delete the old one instead). Get the question_id from Page Detail.

POST https://ahsuite.com/api/portal/page/question/update
Body parameters
portal_id required
string
Unique Portal ID. See example →
page_id required
string
The page the question belongs to, from List Pages or Page Detail.
question_id required
string
The question to edit — its question_id from Page Detail. A question of another page or portal returns 404.
question_name
string
New question text. Max 500 characters. Cannot be blank.
description
string
Help text shown under the question. Max 500 characters. Empty string clears it.
required
integer
1 makes the question mandatory, 0 optional.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "page_id=9dRt" \
  --data-urlencode "question_id=5wA2" \
  --data-urlencode "question_name=Preferred contact method" \
  --data-urlencode "required=1" \
  'https://ahsuite.com/api/portal/page/question/update'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'page_id' => '9dRt',
    'question_id' => '5wA2',
    'question_name' => 'Preferred contact method',
    'required' => 1,
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/page/question/update');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({
  portal_id: 'vJW4',
  page_id: '9dRt',
  question_id: '5wA2',
  question_name: 'Preferred contact method',
  required: '1',
});

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/page/question/update', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/page/question/update',
    data={'portal_id': 'vJW4', 'page_id': '9dRt', 'question_id': '5wA2', 'question_name': 'Preferred contact method', 'required': 1},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Question has been updated successfully.",
  "data": {
    "question_id": "5wA2",
    "page_id": "9dRt"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "No settings were sent to update."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Question record not found on this page."
}

Move a Question

Move an existing question into a section or repeat_section on the same tab — the same as Add Question inside a section in the page editor — or move it back out to the top level (Move Out). Sections themselves and questions with conditional navigation cannot be placed inside a section. To create a new question directly inside a section, use Update a Page with questions[0][section_id].

POST https://ahsuite.com/api/portal/page/question/move
Body parameters
portal_id required
string
Unique Portal ID. See example →
page_id required
string
The page the question belongs to, from List Pages or Page Detail.
question_id required
string
The question to move — its question_id from Page Detail.
section_id required
string
The question_id of the target section or repeat_section; it must be on the same tab as the question. Send an empty string to move the question out of its section.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "page_id=9dRt" \
  --data-urlencode "question_id=5wA2" \
  --data-urlencode "section_id=5wA3" \
  'https://ahsuite.com/api/portal/page/question/move'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'page_id' => '9dRt',
    'question_id' => '5wA2',
    'section_id' => '5wA3',
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/page/question/move');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({ portal_id: 'vJW4', page_id: '9dRt', question_id: '5wA2', section_id: '5wA3' });

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/page/question/move', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/page/question/move',
    data={'portal_id': 'vJW4', 'page_id': '9dRt', 'question_id': '5wA2', 'section_id': '5wA3'},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Question has been moved into the section successfully.",
  "data": {
    "question_id": "5wA2",
    "page_id": "9dRt",
    "section_id": "5wA3"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "A section cannot be moved inside another section."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Section record not found on the same tab as the question."
}

Delete a Question

Remove one form question from a page — the same as the trash icon in the page editor. Deleting a section or repeat_section also removes the questions inside it. Answers the client already gave stay available in the form responses. This cannot be undone. Get the question_id from Page Detail.

POST https://ahsuite.com/api/portal/page/question/delete
Body parameters
portal_id required
string
Unique Portal ID. See example →
page_id required
string
The page the question belongs to, from List Pages or Page Detail. The question must be on this page.
question_id required
string
The question to delete — its question_id from Page Detail. A question of another page or portal returns 404.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "page_id=9dRt" \
  --data-urlencode "question_id=5wA2" \
  'https://ahsuite.com/api/portal/page/question/delete'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'page_id' => '9dRt',
    'question_id' => '5wA2',
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/page/question/delete');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({ portal_id: 'vJW4', page_id: '9dRt', question_id: '5wA2' });

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/page/question/delete', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/page/question/delete',
    data={'portal_id': 'vJW4', 'page_id': '9dRt', 'question_id': '5wA2'},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Question has been deleted successfully.",
  "data": {
    "question_id": "5wA2",
    "page_id": "9dRt"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"question_id\":[\"The question id field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Question record not found on this page."
}

Create a Task

Create a new task inside an existing portal. By default the task is private and assigned to you; send assign_to_client=1 to make it visible to the client user (client users with email notifications enabled are notified). Returns the new task_id on success.

POST https://ahsuite.com/api/portal/task/create
Body parameters
portal_id required
string
Unique Portal ID. See example →
name required
string
The task name. Max 200 characters.
description
string
Task description — plain text or basic HTML (p, br, strong, em, ul, ol, li, a). Max 5000 characters. Optional.
due_date
string
Task deadline in YYYY-MM-DD format. Optional.
assign_to_client
integer
0 (default) = private task assigned to you. 1 = client task, visible to the client user — client users who have email notifications enabled are notified by email.
checklist[]
string
Checklist item name — send the parameter multiple times to add multiple items. Max 20 items per task. Optional.
labels[0][name]
string
Label name. Max 20 characters, max 5 labels per task. Increment the index (0, 1, 2…) to add multiple labels. An existing label with the same name and color is reused. Optional.
labels[0][color]
string
One of green, yellow, orange, red, purple, blue, sky, lime, pink, black. Optional, default = no color.
attachments[]
file
Attachment file sent as multipart/form-data — send the parameter multiple times to attach multiple files. Allowed types: doc, pdf, docx, zip, txt, xls, xlsx, csv, jpg, png, jpeg, webp, gif, html, svg, mp4, mp3, wav, mov and more. Max 9MB per file, max 10 files per request. Optional.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  -F 'portal_id=PORTAL_ID' \
  -F 'name=Follow up with the client' \
  -F 'description=Call to review the latest deliverables.' \
  -F 'due_date=2026-08-01' \
  -F 'assign_to_client=0' \
  -F 'checklist[]=Draft the report' \
  -F 'checklist[]=Send for review' \
  -F 'labels[0][name]=Priority' \
  -F 'labels[0][color]=red' \
  -F 'attachments[]=@/path/to/spec.pdf' \
  'https://ahsuite.com/api/portal/task/create'
$ch = curl_init();
$data = [
    'portal_id'        => 'PORTAL_ID',
    'name'             => 'Follow up with the client',
    'description'      => 'Call to review the latest deliverables.',
    'due_date'         => '2026-08-01',
    'assign_to_client' => 0,
    'checklist[0]'     => 'Draft the report',
    'checklist[1]'     => 'Send for review',
    'labels[0][name]'  => 'Priority',
    'labels[0][color]' => 'red',
    'attachments[0]'   => new CURLFile('/path/to/spec.pdf'),
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/task/create');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data); // array = multipart/form-data (required for attachments[])
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';
import FormData from 'form-data';
import fs from 'fs';

const form = new FormData();
form.append('portal_id', 'PORTAL_ID');
form.append('name', 'Follow up with the client');
form.append('description', 'Call to review the latest deliverables.');
form.append('due_date', '2026-08-01');
form.append('assign_to_client', '0');
form.append('checklist[]', 'Draft the report');
form.append('checklist[]', 'Send for review');
form.append('labels[0][name]', 'Priority');
form.append('labels[0][color]', 'red');
form.append('attachments[]', fs.createReadStream('/path/to/spec.pdf'));

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/task/create', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body: form,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/task/create',
    auth=('EMAIL', 'TOKEN'),
    data={
        'portal_id':      'PORTAL_ID',
        'name':           'Follow up with the client',
        'description':    'Call to review the latest deliverables.',
        'due_date':       '2026-08-01',
        'assign_to_client': 0,
        'checklist[]':      ['Draft the report', 'Send for review'],
        'labels[0][name]':  'Priority',
        'labels[0][color]': 'red',
    },
    files={'attachments[]': open('/path/to/spec.pdf', 'rb')},
)
print(res.json())
Response
{
  "success": true,
  "message": "Task has been added successfully.",
  "data": {
    "task_id": "vJW4"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"name\":[\"The name field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

List Tasks

Return the tasks of a portal, in the same order the app shows them (tasks being worked on first). By default every non-cancelled, non-archived task is returned — use status to filter. Results are paginated.

GET https://ahsuite.com/api/portal/task/list
Query parameters
portal_id required
string
Unique Portal ID. See example →
status
string
Return only tasks with this status — the same statuses you see in the app: ready_for_work, pending, in_progress, on_hold, done, canceled. When omitted, every status except canceled is returned.
search
string
Filter by task name — returns tasks whose name contains this text. Max 100 characters.
page
integer
Page number to return. Defaults to 1.
limit
integer
Rows per page. Defaults to 20, maximum 100.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request GET \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/task/list?portal_id=vJW4&status=in_progress&page=1&limit=20'
$params = http_build_query([
    'portal_id' => 'vJW4',
    'status' => 'in_progress',
    'page' => 1,
    'limit' => 20,
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/task/list?'.$params);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const params = new URLSearchParams({ portal_id: 'vJW4', status: 'in_progress', page: 1, limit: 20 });
const res = await fetch(`https://ahsuite.com/api/portal/task/list?${params}`, {
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.get(
    'https://ahsuite.com/api/portal/task/list',
    params={'portal_id': 'vJW4', 'status': 'in_progress', 'page': 1, 'limit': 20},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "",
  "data": {
    "tasks": [
      {
        "name": "Design homepage mockup",
        "status": "in_progress",
        "assign_to_client": 0,
        "assigned_to": "John Smith",
        "due_date": "2026-06-20",
        "created_date": "2026-06-05T10:12:30-07:00",
        "task_id": 512
      },
      {
        "name": "Send onboarding documents",
        "status": "pending",
        "assign_to_client": 1,
        "assigned_to": "Client",
        "due_date": "2026-06-25",
        "created_date": "2026-06-08T13:45:19-07:00",
        "task_id": 518
      }
    ],
    "pagination": {
      "total": 14,
      "page": 1,
      "limit": 20,
      "total_pages": 1
    }
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"status\":[\"The selected status is invalid.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

Task Detail

Return a single task in full — name, description, status, dates, assignee, project, checklist, labels, attachments and comments, as shown on the task detail screen in the app. Field names match Create Task. Hidden and archived tasks are not returned. Get a task_id from List Tasks.

GET https://ahsuite.com/api/portal/task/detail
Query parameters
portal_id required
string
Unique Portal ID. See example →
task_id required
integer
The task to return, as given by List Tasks or returned by Create Task. A task of another portal returns 404.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request GET \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/task/detail?portal_id=vJW4&task_id=3659'
$params = http_build_query([
    'portal_id' => 'vJW4',
    'task_id' => 3659,
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/task/detail?'.$params);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const params = new URLSearchParams({ portal_id: 'vJW4', task_id: '3659' });
const res = await fetch(`https://ahsuite.com/api/portal/task/detail?${params}`, {
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.get(
    'https://ahsuite.com/api/portal/task/detail',
    params={'portal_id': 'vJW4', 'task_id': 3659},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "",
  "data": {
    "name": "Send the June report",
    "description": "<p>Export the analytics and share the PDF.</p>",
    "status": "in_progress",
    "due_date": "2026-06-30",
    "assign_to_client": 0,
    "assigned_to": "Jane Cooper",
    "project": "Q2 Reporting",
    "created_date": "2026-06-02T14:20:11-07:00",
    "task_id": 3659,
    "checklist": [
      { "name": "Export analytics", "completed": 1, "item_id": 8101 },
      { "name": "Write summary", "completed": 0, "item_id": 8102 }
    ],
    "labels": [
      { "name": "Reporting", "color": "blue" }
    ],
    "attachments": [
      { "name": "may-report.pdf", "url": "https://s3.amazonaws.com/.../task/T-1718000000-may-report.pdf?X-Amz-Expires=900&...", "attachment_id": 512 }
    ],
    "comments": [
      { "comment": "Draft is ready for review.", "by": "Jane Cooper", "from": "agent", "created_date": "2026-06-10T09:15:02-07:00", "comment_id": 2201 },
      { "comment": "Please include the ads data too.", "by": "Sam Lee (Client)", "from": "client", "created_date": "2026-06-09T17:40:11-07:00", "comment_id": 2198 }
    ]
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"task_id\":[\"The task id field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Task record not found."
}

Update a Task

Update an existing task. This is a partial update: only the parameters you send are changed, everything else on the task is left untouched. Each change is written to the task's history, as in the app. Supported: name, description, due_date, status, dependency_task_id and project_id. Assignee, labels, checklist items, attachments and comments have their own endpoints below. Get a task_id from List Tasks.

POST https://ahsuite.com/api/portal/task/update
Body parameters
portal_id required
string
Unique Portal ID. See example →
task_id required
integer
The task to update, from List Tasks or Task Detail. A task of another portal returns 404.
name
string
New task name. Max 200 characters. Cannot be blank.
description
string
Task description. HTML allowed (p, br, strong, em, ul, ol, li, a), other tags are stripped. Max 5000 characters. Replaces the current description. Empty string clears it.
due_date
string
Due date in YYYY-MM-DD format. Empty string removes the due date.
status
string
New status: ready_for_work, pending, in_progress, on_hold, done or canceled — the same values List Tasks returns. Behaves exactly like changing the status in the app: marking a task done notifies the team (and emails the client when they created the task), and tasks that were waiting on this one become ready for work.
dependency_task_id
integer
The task_id of another open task in this portal that must be finished first (the "Dependency" field). The task is put on pending until that task is done or canceled, then becomes ready for work automatically, as in the app. Send 0 to remove the dependency. A done, canceled or unknown task returns 404.
project_id
integer
The project_id from List Projects to place the task in. Send 0 to take the task out of its project. A project of another portal returns 404.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "task_id=3659" \
  --data-urlencode "name=Send the June report" \
  --data-urlencode "due_date=2026-06-30" \
  'https://ahsuite.com/api/portal/task/update'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'task_id' => 3659,
    'name' => 'Send the June report',
    'due_date' => '2026-06-30',
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/task/update');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({
  portal_id: 'vJW4',
  task_id: '3659',
  name: 'Send the June report',
  due_date: '2026-06-30',
});

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/task/update', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/task/update',
    data={'portal_id': 'vJW4', 'task_id': 3659, 'name': 'Send the June report', 'due_date': '2026-06-30'},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Task has been updated successfully.",
  "data": {
    "task_id": 3659
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "No settings were sent to update."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Task record not found."
}

Assign a Task

Change who a task is assigned to — a team member, the client (any client user) or one specific client user. Behaves exactly like the assignee dropdown in the app: assigning to the client or a client user shares the task with them and sends the "action required" email; assigning to a team member notifies them; the change is written to the task history. A task created by the client always stays shared with all client users: you can add a team member to it (team_member), but client and client_user return 400 for such a task, as in the app.

POST https://ahsuite.com/api/portal/task/assign
Body parameters
portal_id required
string
Unique Portal ID. See example →
task_id required
integer
The task to assign, from List Tasks or Task Detail.
assign_to required
string
team_member — a member of the portal's team (send their email); client — any client user of the portal; client_user — one specific client user (send their email).
email
string
Required for team_member and client_user: the person's email address. Team members must be assigned to this portal; client users must be active in it (see List Contacts). Otherwise 404.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "task_id=3659" \
  --data-urlencode "assign_to=client_user" \
  --data-urlencode "email=jane@example.com" \
  'https://ahsuite.com/api/portal/task/assign'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'task_id' => 3659,
    'assign_to' => 'client_user',
    'email' => 'jane@example.com',
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/task/assign');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({ portal_id: 'vJW4', task_id: '3659', assign_to: 'client_user', email: 'jane@example.com' });

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/task/assign', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/task/assign',
    data={'portal_id': 'vJW4', 'task_id': 3659, 'assign_to': 'client_user', 'email': 'jane@example.com'},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Task has been assigned successfully.",
  "data": {
    "task_id": 3659,
    "assigned_to": "Jane"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "Not allowed to remove client from assignee"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "No active client user with this email in this portal."
}

Add Task Labels

Add one or more labels to a task — the same as Add Label on the task screen. A label with the same name and color already in the portal is reused, otherwise it is created. Labels already on the task are kept.

POST https://ahsuite.com/api/portal/task/label/add
Body parameters
portal_id required
string
Unique Portal ID. See example →
task_id required
integer
The task, from List Tasks or Task Detail.
labels[0][name] required
string
Label text. Max 20 characters. Up to 5 labels per request, same items as Create Task.
labels[0][color]
string
green, yellow, orange, red, purple, blue, sky, lime, pink or black. Omit for no color.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "task_id=3659" \
  --data-urlencode "labels[0][name]=Reporting" \
  --data-urlencode "labels[0][color]=blue" \
  --data-urlencode "labels[1][name]=Urgent" \
  --data-urlencode "labels[1][color]=red" \
  'https://ahsuite.com/api/portal/task/label/add'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'task_id' => 3659,
    'labels' => [
        ['name' => 'Reporting', 'color' => 'blue'],
        ['name' => 'Urgent', 'color' => 'red'],
    ],
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/task/label/add');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({
  portal_id: 'vJW4',
  task_id: '3659',
  'labels[0][name]': 'Reporting',
  'labels[0][color]': 'blue',
  'labels[1][name]': 'Urgent',
  'labels[1][color]': 'red',
});

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/task/label/add', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/task/label/add',
    data={
        'portal_id': 'vJW4',
        'task_id': 3659,
        'labels[0][name]': 'Reporting', 'labels[0][color]': 'blue',
        'labels[1][name]': 'Urgent', 'labels[1][color]': 'red',
    },
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Labels have been added successfully.",
  "data": {
    "task_id": 3659
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "These labels are already on the task — nothing to change."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Task record not found."
}

Add Checklist Items

Add one or more items to a task's checklist — the same as Add item on the task screen. Existing items are kept; each addition is written to the task history. Returns the new items with their item_id.

POST https://ahsuite.com/api/portal/task/checklist/add
Body parameters
portal_id required
string
Unique Portal ID. See example →
task_id required
integer
The task, from List Tasks or Task Detail.
checklist[] required
array
Item texts to add, one per checklist[] entry, same as Create Task. Each max 2000 characters, up to 20 per request. Blank entries are skipped.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "task_id=3659" \
  --data-urlencode "checklist[]=Export analytics" \
  --data-urlencode "checklist[]=Write summary" \
  'https://ahsuite.com/api/portal/task/checklist/add'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'task_id' => 3659,
    'checklist' => ['Export analytics', 'Write summary'],
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/task/checklist/add');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams();
body.append('portal_id', 'vJW4');
body.append('task_id', '3659');
body.append('checklist[]', 'Export analytics');
body.append('checklist[]', 'Write summary');

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/task/checklist/add', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/task/checklist/add',
    data={'portal_id': 'vJW4', 'task_id': 3659, 'checklist[]': ['Export analytics', 'Write summary']},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Checklist items have been added successfully.",
  "data": {
    "task_id": 3659,
    "checklist": [
      { "name": "Export analytics", "completed": 0, "item_id": 8101 },
      { "name": "Write summary", "completed": 0, "item_id": 8102 }
    ]
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "Item name is blank."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Task record not found."
}

Update a Checklist Item

Rename a checklist item and/or mark it completed or not completed — the same as editing or ticking an item on the task screen. This is a partial update: only the parameters you send are changed. Completing or un-checking an item is written to the task history.

POST https://ahsuite.com/api/portal/task/checklist/update
Body parameters
portal_id required
string
Unique Portal ID. See example →
task_id required
integer
The task the item belongs to.
item_id required
integer
The checklist item, from Task Detail checklist[].item_id or Add Checklist Items. An item of another task or portal returns 404.
name
string
New item text. Max 2000 characters. Cannot be blank.
completed
integer
1 marks the item completed, 0 marks it not completed.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "task_id=3659" \
  --data-urlencode "item_id=8101" \
  --data-urlencode "completed=1" \
  'https://ahsuite.com/api/portal/task/checklist/update'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'task_id' => 3659,
    'item_id' => 8101,
    'completed' => 1,
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/task/checklist/update');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({ portal_id: 'vJW4', task_id: '3659', item_id: '8101', completed: '1' });

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/task/checklist/update', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/task/checklist/update',
    data={'portal_id': 'vJW4', 'task_id': 3659, 'item_id': 8101, 'completed': 1},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Checklist item has been updated successfully.",
  "data": {
    "task_id": 3659,
    "item_id": 8101,
    "name": "Export analytics",
    "completed": 1
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "No settings were sent to update."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Checklist item not found on this task."
}

Add a Task Attachment

Upload one file to a task — the same as Add attachment on the task screen. Send the request as multipart/form-data, one file per request. Existing attachments are kept; the upload is written to the task history. A task can hold at most 70 attachments. Not available through the MCP connector (binary upload).

POST https://ahsuite.com/api/portal/task/attachment/add
Body parameters
portal_id required
string
Unique Portal ID. See example →
task_id required
integer
The task, from List Tasks or Task Detail.
attachment required
file
The file to attach. Same allowed types as the app (documents, images, video/audio, design files); max 9 MB. One file per request.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  -F "portal_id=vJW4" \
  -F "task_id=3659" \
  -F "attachment=@/path/to/brief.pdf" \
  'https://ahsuite.com/api/portal/task/attachment/add'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'task_id' => 3659,
    'attachment' => new CURLFile('/path/to/brief.pdf'),
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/task/attachment/add');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';
import { FormData, File } from 'node-fetch';
import { readFileSync } from 'fs';

const form = new FormData();
form.append('portal_id', 'vJW4');
form.append('task_id', '3659');
form.append('attachment', new File([readFileSync('/path/to/brief.pdf')], 'brief.pdf'));

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/task/attachment/add', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body: form,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/task/attachment/add',
    data={'portal_id': 'vJW4', 'task_id': 3659},
    files={'attachment': open('/path/to/brief.pdf', 'rb')},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Attachment has been added successfully.",
  "data": {
    "task_id": 3659,
    "name": "brief.pdf",
    "attachment_id": 512
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "Total task attachments adding limit is 70."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Task record not found."
}

Add a Task Comment

Post a comment on a task — exactly like commenting on the task screen: the comment is written to the task history and the people involved are notified (client users are notified when the task is shared with them). Basic HTML is allowed.

POST https://ahsuite.com/api/portal/task/comment/add
Body parameters
portal_id required
string
Unique Portal ID. See example →
task_id required
integer
The task, from List Tasks or Task Detail.
comment required
string
The comment text. Max 2000 characters. HTML allowed: p, br, strong, em, ul, ol, li, a; other tags are stripped.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "task_id=3659" \
  --data-urlencode "comment=Draft is ready for review." \
  'https://ahsuite.com/api/portal/task/comment/add'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'task_id' => 3659,
    'comment' => 'Draft is ready for review.',
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/task/comment/add');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({ portal_id: 'vJW4', task_id: '3659', comment: 'Draft is ready for review.' });

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/task/comment/add', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/task/comment/add',
    data={'portal_id': 'vJW4', 'task_id': 3659, 'comment': 'Draft is ready for review.'},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Comment has been added successfully.",
  "data": {
    "task_id": 3659,
    "comment_id": 2201
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"comment\":[\"The comment field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Task record not found."
}

Create a File

Add an item to the portal's Files section — upload a file, add a link, request a file from the client user, or create a folder. Send exactly ONE item type per request. Returns the new file_id on success (for folders, pass it as folder_id in later requests to add files inside).

POST https://ahsuite.com/api/portal/file/create
Body parameters
portal_id required
string
Unique Portal ID. See example →
share_with_client
integer
Set 1 to make the file visible to the client user. Default 0 (internal only).
share_with_team
integer
Set 1 to share the file with your internal team. Default 0 (only you).
folder_id
string
A folder's file_id — places the item inside that folder. Not allowed when creating a folder (folders are root-level only). Optional.
upload Item type 1 — upload a file
upload[file]
file
File sent as multipart/form-data. Allowed types: doc, pdf, docx, zip, txt, xls, xlsx, csv, jpg, png, jpeg, webp, gif, html, svg, mp4, mp3, wav, mov and more. Max 50MB.
upload[require_client_approval]
integer
1 = the client user is asked to approve the file. Default 0.
— OR —
link Item type 2 — add a link
link[title]
string
Display title of the link. Max 100 characters.
link[url]
string
The URL to add.
— OR —
request Item type 3 — request a file from the client user
request[title]
string
Title of the file you are asking the client user to upload. Max 100 characters.
request[description]
string
Short description shown with the file request. Optional.
— OR —
folder Item type 4 — create a folder (root level only)
folder[title]
string
Folder name. Max 100 characters. The returned file_id can be used as folder_id to add files inside it.

Other unsuccessful response codes: 403, 429, 500.

Request
# upload a file (send ONE item type per request)
curl --request POST \
  -u "EMAIL:TOKEN" \
  -F 'portal_id=PORTAL_ID' \
  -F 'upload[file]=@/path/to/contract.pdf' \
  -F 'upload[require_client_approval]=1' \
  'https://ahsuite.com/api/portal/file/create'

# add a link
curl --request POST \
  -u "EMAIL:TOKEN" \
  -F 'portal_id=PORTAL_ID' \
  -F 'link[title]=Brand guidelines' \
  -F 'link[url]=https://docs.google.com/document/d/XXXX' \
  'https://ahsuite.com/api/portal/file/create'
$ch = curl_init();
$data = [
    'portal_id'                        => 'PORTAL_ID',
    'upload[file]'                     => new CURLFile('/path/to/contract.pdf'),
    'upload[require_client_approval]'  => 1,
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/file/create');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data); // array = multipart/form-data (required for upload[file])
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';
import FormData from 'form-data';
import fs from 'fs';

const form = new FormData();
form.append('portal_id', 'PORTAL_ID');
form.append('upload[file]', fs.createReadStream('/path/to/contract.pdf'));
form.append('upload[require_client_approval]', '1');

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/file/create', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body: form,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/file/create',
    auth=('EMAIL', 'TOKEN'),
    data={
        'portal_id':                       'PORTAL_ID',
        'upload[require_client_approval]': 1,
    },
    files={'upload[file]': open('/path/to/contract.pdf', 'rb')},
)
print(res.json())
Response
{
  "success": true,
  "message": "File has been added successfully.",
  "data": {
    "file_id": "vJW4"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "Send exactly one of: file, link[url], request[title] or folder[title]."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

List Files

Return the files of a portal — uploads, links, file requests and folders — in the same order the app shows them (folders first). By default the root level is returned; pass folder_id to list inside a folder. Archived files are excluded, and visibility matches what the account owner sees in the app. Results are paginated.

GET https://ahsuite.com/api/portal/file/list
Query parameters
portal_id required
string
Unique Portal ID. See example →
folder_id
string
List the contents of this folder — the file_id of a row with type folder. When omitted, the root level is returned.
search
string
Filter by file title — returns files whose title contains this text. Max 100 characters.
page
integer
Page number to return. Defaults to 1.
limit
integer
Rows per page. Defaults to 20, maximum 100.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request GET \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/file/list?portal_id=vJW4&page=1&limit=20'
$params = http_build_query([
    'portal_id' => 'vJW4',
    'page' => 1,
    'limit' => 20,
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/file/list?'.$params);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const params = new URLSearchParams({ portal_id: 'vJW4', page: 1, limit: 20 });
const res = await fetch(`https://ahsuite.com/api/portal/file/list?${params}`, {
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.get(
    'https://ahsuite.com/api/portal/file/list',
    params={'portal_id': 'vJW4', 'page': 1, 'limit': 20},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "",
  "data": {
    "files": [
      {
        "title": "Brand Assets",
        "type": "folder",
        "status": "",
        "share_with_client": 1,
        "created_date": "2026-05-20T08:15:40-07:00",
        "file_id": "6mNs"
      },
      {
        "title": "logo-final.png",
        "type": "upload",
        "status": "client_approved",
        "share_with_client": 1,
        "created_date": "2026-06-01T12:41:08-07:00",
        "file_id": "7pQw"
      },
      {
        "title": "Signed contract",
        "type": "request",
        "status": "request_file",
        "share_with_client": 1,
        "created_date": "2026-06-03T09:05:22-07:00",
        "file_id": "8rTx"
      }
    ],
    "pagination": {
      "total": 9,
      "page": 1,
      "limit": 20,
      "total_pages": 1
    }
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"portal_id\":[\"The portal id field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Folder record not found."
}

File Detail

Return a single file, folder or file request in full — title, description, status, sharing, its versions (newest first), comments and labels, as shown on the file screen in the app. Field names match List Files and Create File. For a folder only its own details are returned (is_folder = 1, sharing, parent folder_id, items_count); to see the files inside it call List Files with folder_id. Archived files are not returned.

GET https://ahsuite.com/api/portal/file/detail
Query parameters
portal_id required
string
Unique Portal ID. See example →
file_id required
string
The file to return, as given by List Files or returned by Create File. A file of another portal, or one you are not assigned to, returns 404.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request GET \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/file/detail?portal_id=vJW4&file_id=7pQ1'
$params = http_build_query([
    'portal_id' => 'vJW4',
    'file_id' => '7pQ1',
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/file/detail?'.$params);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const params = new URLSearchParams({ portal_id: 'vJW4', file_id: '7pQ1' });
const res = await fetch(`https://ahsuite.com/api/portal/file/detail?${params}`, {
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.get(
    'https://ahsuite.com/api/portal/file/detail',
    params={'portal_id': 'vJW4', 'file_id': '7pQ1'},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "",
  "data": {
    "title": "Signed contract",
    "description": "",
    "is_folder": 0,
    "is_request": 0,
    "status": "client_approved",
    "require_client_approval": 1,
    "share_with_client": 1,
    "share_with_team": 1,
    "folder_id": "6kFz",
    "created_date": "2026-06-02T14:22:05-07:00",
    "file_id": "7pQ1",
    "versions": [
      { "file_name": "contract-v2.pdf", "type": "file", "url": "https://s3.amazonaws.com/.../tool/API-1718000000-contract-v2.pdf?X-Amz-Expires=7200&...", "approved_by": "Sam Lee", "approved_date": "2026-06-10T09:15:02-07:00", "created_date": "2026-06-09T17:40:11-07:00", "version_id": 9012 },
      { "file_name": "contract.pdf", "type": "file", "url": "https://s3.amazonaws.com/.../tool/API-1717000000-contract.pdf?X-Amz-Expires=7200&...", "approved_by": "", "approved_date": null, "created_date": "2026-06-02T14:22:05-07:00", "version_id": 8990 }
    ],
    "comments": [
      { "comment": "Please sign page 3 as well.", "by": "Jane Cooper", "from": "agent", "created_date": "2026-06-08T11:02:40-07:00", "comment_id": 3301 }
    ],
    "labels": [
      { "name": "Legal", "color": "purple" }
    ]
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"file_id\":[\"The file id field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "File record not found."
}

Update a File

Update a file, file request or folder. This is a partial update: only the parameters you send are changed. Each change behaves exactly like editing the field on the file screen — turning require_client_approval on shares the file with the client (and its folder, if any), sets it to "approval required" and notifies them by email (the "Action required" email, or "File required" for a file request); it cannot be changed on a file the client already approved. Folders accept title and description only.

POST https://ahsuite.com/api/portal/file/update
Body parameters
portal_id required
string
Unique Portal ID. See example →
file_id required
string
The file to update, from List Files or File Detail. A file of another portal, or one you are not assigned to, returns 404.
title
string
New title. Max 100 characters. Cannot be blank.
description
string
Description shown under the file. HTML allowed: p, br, strong, em, ul, ol, li, a. Max 5000 characters. Empty string clears it.
require_client_approval
integer
1 asks the client to review and approve the file (shares it with them), 0 removes the approval requirement. On a file request this is the "Request File" toggle: 1 asks the client to upload the file. Not accepted for folders; returns 400 once the client has approved / submitted.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "file_id=7pQ1" \
  --data-urlencode "title=Signed contract (final)" \
  --data-urlencode "require_client_approval=1" \
  'https://ahsuite.com/api/portal/file/update'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'file_id' => '7pQ1',
    'title' => 'Signed contract (final)',
    'require_client_approval' => 1,
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/file/update');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({ portal_id: 'vJW4', file_id: '7pQ1', title: 'Signed contract (final)', require_client_approval: '1' });

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/file/update', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/file/update',
    data={'portal_id': 'vJW4', 'file_id': '7pQ1', 'title': 'Signed contract (final)', 'require_client_approval': 1},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "File has been updated successfully.",
  "data": {
    "file_id": "7pQ1"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "Oop's this document is already approved!!"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "File record not found."
}

Add a File Version

Add a new version to an existing file — the same as Add a file on the file screen. Send either one uploaded file (multipart/form-data) or one url per request. Older versions are kept; if the client had already approved the file it goes back to "approval required". The addition is written to the file history. A file can hold at most 70 versions.

POST https://ahsuite.com/api/portal/file/version/add
Body parameters
portal_id required
string
Unique Portal ID. See example →
file_id required
string
The file, from List Files or File Detail. Folders return 400.
attachment_file
file
The file to upload as the newest version (multipart/form-data). Same allowed types as Create File; max 50 MB. Not available through the MCP connector. Send either attachment_file or url, not both.
url
string
A link (Google Drive, Dropbox, any https URL) to add as the newest version. Max 1000 characters.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  -F "portal_id=vJW4" \
  -F "file_id=7pQ1" \
  -F "attachment_file=@/path/to/contract-v3.pdf" \
  'https://ahsuite.com/api/portal/file/version/add'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'file_id' => '7pQ1',
    'attachment_file' => new CURLFile('/path/to/contract-v3.pdf'),
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/file/version/add');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

// link version — for an upload use FormData with an 'attachment_file' file instead
const body = new URLSearchParams({ portal_id: 'vJW4', file_id: '7pQ1', url: 'https://drive.google.com/file/d/1AbC/view' });

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/file/version/add', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/file/version/add',
    data={'portal_id': 'vJW4', 'file_id': '7pQ1'},
    files={'attachment_file': open('/path/to/contract-v3.pdf', 'rb')},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Version has been added successfully.",
  "data": {
    "file_id": "7pQ1",
    "version_id": 9013
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "Send exactly one of: attachment_file (file) or url."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "File record not found."
}

Add a File Comment

Post a comment on a file or file request — exactly like commenting on the file screen: the comment is written to the file history and the people involved are notified (client users are notified when the file is shared with them). Basic HTML is allowed. Folders cannot be commented on.

POST https://ahsuite.com/api/portal/file/comment/add
Body parameters
portal_id required
string
Unique Portal ID. See example →
file_id required
string
The file, from List Files or File Detail. Folders return 400.
comment required
string
The comment text. Max 2000 characters. HTML allowed: p, br, strong, em, ul, ol, li, a; other tags are stripped.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "file_id=7pQ1" \
  --data-urlencode "comment=Please sign page 3 as well." \
  'https://ahsuite.com/api/portal/file/comment/add'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'file_id' => '7pQ1',
    'comment' => 'Please sign page 3 as well.',
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/file/comment/add');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({ portal_id: 'vJW4', file_id: '7pQ1', comment: 'Please sign page 3 as well.' });

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/file/comment/add', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/file/comment/add',
    data={'portal_id': 'vJW4', 'file_id': '7pQ1', 'comment': 'Please sign page 3 as well.'},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Comment has been added successfully.",
  "data": {
    "file_id": "7pQ1",
    "comment_id": 3302
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "Comments cannot be added to a folder."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "File record not found."
}

Move a File

Move a file into a folder of the same portal, or back to the top level — the same as moving it in the app. The file takes over the sharing of the folder it lands in (folder members and client users). Folders themselves cannot be moved.

POST https://ahsuite.com/api/portal/file/move
Body parameters
portal_id required
string
Unique Portal ID. See example →
file_id required
string
The file to move, from List Files or File Detail. Folders return 400.
folder_id required
string
The target folder's file_id from List Files (a row with type = folder). Send an empty string to move the file to the root. A folder of another portal, or one you are not assigned to, returns 404.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "file_id=7pQ1" \
  --data-urlencode "folder_id=6kFz" \
  'https://ahsuite.com/api/portal/file/move'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'file_id' => '7pQ1',
    'folder_id' => '6kFz',
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/file/move');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({ portal_id: 'vJW4', file_id: '7pQ1', folder_id: '6kFz' });

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/file/move', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/file/move',
    data={'portal_id': 'vJW4', 'file_id': '7pQ1', 'folder_id': '6kFz'},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "File has been moved successfully.",
  "data": {
    "file_id": "7pQ1",
    "folder_id": "6kFz"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "Can be moved in a folder only."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Folder record not found."
}

Add File Labels

Add one or more labels to a file or file request — the same as Add Label on the file screen. A label with the same name and color already in the portal is reused, otherwise it is created. Labels already on the file are kept. Folders cannot be labelled.

POST https://ahsuite.com/api/portal/file/label/add
Body parameters
portal_id required
string
Unique Portal ID. See example →
file_id required
string
The file, from List Files or File Detail. Folders return 400.
labels[0][name] required
string
Label text. Max 20 characters. Up to 5 labels per request.
labels[0][color]
string
green, yellow, orange, red, purple, blue, sky, lime, pink or black. Omit for no color.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "file_id=7pQ1" \
  --data-urlencode "labels[0][name]=Legal" \
  --data-urlencode "labels[0][color]=purple" \
  'https://ahsuite.com/api/portal/file/label/add'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'file_id' => '7pQ1',
    'labels' => [
        ['name' => 'Legal', 'color' => 'purple'],
    ],
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/file/label/add');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({
  portal_id: 'vJW4',
  file_id: '7pQ1',
  'labels[0][name]': 'Legal',
  'labels[0][color]': 'purple',
});

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/file/label/add', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/file/label/add',
    data={'portal_id': 'vJW4', 'file_id': '7pQ1', 'labels[0][name]': 'Legal', 'labels[0][color]': 'purple'},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Labels have been added successfully.",
  "data": {
    "file_id": "7pQ1"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "These labels are already on the file — nothing to change."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "File record not found."
}

Create a Journal

Create a new work journal inside an existing portal. Requires a valid portal_id. Returns the new journal_id on success.

POST https://ahsuite.com/api/portal/journal/create
Query parameters
portal_id required
string
Unique Portal ID. See example →
name required
string
The journal name. Max 255 characters.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/journal/create?portal_id=PORTAL_ID&name=Journal+Name'
$ch = curl_init();
$data = [ 'portal_id' => 'PORTAL_ID', 'name' => 'Journal Name' ];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/journal/create');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const url = new URL('https://ahsuite.com/api/portal/journal/create');
url.searchParams.set('portal_id', 'PORTAL_ID');
url.searchParams.set('name', 'Journal Name');

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch(url, {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/journal/create',
    auth=('EMAIL', 'TOKEN'),
    params={'portal_id': 'PORTAL_ID', 'name': 'Journal Name'},
)
print(res.json())
Response
{
  "success": true,
  "message": "Journal has been added successfully.",
  "data": {
    "journal_id": "vJW4"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"name\":[\"The name field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

List Journals

Return the work journals of a portal, newest first. Archived journals are excluded. Results are paginated.

GET https://ahsuite.com/api/portal/journal/list
Query parameters
portal_id required
string
Unique Portal ID. See example →
search
string
Filter by journal name — returns journals whose name contains this text. Max 100 characters.
page
integer
Page number to return. Defaults to 1.
limit
integer
Rows per page. Defaults to 20, maximum 100.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request GET \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/journal/list?portal_id=vJW4&page=1&limit=20'
$params = http_build_query([
    'portal_id' => 'vJW4',
    'page' => 1,
    'limit' => 20,
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/journal/list?'.$params);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const params = new URLSearchParams({ portal_id: 'vJW4', page: 1, limit: 20 });
const res = await fetch(`https://ahsuite.com/api/portal/journal/list?${params}`, {
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.get(
    'https://ahsuite.com/api/portal/journal/list',
    params={'portal_id': 'vJW4', 'page': 1, 'limit': 20},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "",
  "data": {
    "journals": [
      {
        "name": "June development log",
        "created_date": "2026-06-01T08:00:12-07:00",
        "journal_id": 54
      },
      {
        "name": "Weekly SEO notes",
        "created_date": "2026-05-12T10:30:45-07:00",
        "journal_id": 47
      }
    ],
    "pagination": {
      "total": 5,
      "page": 1,
      "limit": 20,
      "total_pages": 1
    }
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"portal_id\":[\"The portal id field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

Journal Detail

Return a single journal in full — its name, created date and the newest 25 log entries, newest first, as shown in the journal popup in the app. Log entries are HTML; images pasted into a log come back with a temporary link that is valid for 15 minutes. Archived journals are not returned. Get a journal_id from List Journals.

GET https://ahsuite.com/api/portal/journal/detail
Query parameters
portal_id required
string
Unique Portal ID. See example →
journal_id required
integer
The journal to return, as given by List Journals or returned by Create Journal. A journal of another portal returns 404.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request GET \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/journal/detail?portal_id=vJW4&journal_id=60'
$params = http_build_query([
    'portal_id' => 'vJW4',
    'journal_id' => 60,
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/journal/detail?'.$params);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const params = new URLSearchParams({ portal_id: 'vJW4', journal_id: '60' });
const res = await fetch(`https://ahsuite.com/api/portal/journal/detail?${params}`, {
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.get(
    'https://ahsuite.com/api/portal/journal/detail',
    params={'portal_id': 'vJW4', 'journal_id': 60},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "",
  "data": {
    "name": "Website redesign",
    "created_date": "2026-06-02T14:20:11-07:00",
    "journal_id": 60,
    "logs": [
      { "log": "<p>Homepage wireframe approved by the client.</p>", "by": "Jane Cooper", "created_date": "2026-06-10T09:15:02-07:00", "log_id": 412 },
      { "log": "<p>Kickoff call done, notes shared.</p>", "by": "Jane Cooper", "created_date": "2026-06-03T17:40:11-07:00", "log_id": 401 }
    ]
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"journal_id\":[\"The journal id field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Journal record not found."
}

Update a Journal

Rename a journal. The name is the only field of a journal that can be edited in the app, so it is the only field here; log entries are added with Add a Journal Log. Get a journal_id from List Journals.

POST https://ahsuite.com/api/portal/journal/update
Body parameters
portal_id required
string
Unique Portal ID. See example →
journal_id required
integer
The journal to rename, from List Journals. A journal of another portal or an archived one returns 404.
name required
string
The new journal name, max 255 characters.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "journal_id=60" \
  --data-urlencode "name=Website redesign v2" \
  'https://ahsuite.com/api/portal/journal/update'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'journal_id' => 60,
    'name' => 'Website redesign v2',
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/journal/update');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams();
body.append('portal_id', 'vJW4');
body.append('journal_id', '60');
body.append('name', 'Website redesign v2');

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/journal/update', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/journal/update',
    data={'portal_id': 'vJW4', 'journal_id': 60, 'name': 'Website redesign v2'},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Journal has been updated.",
  "data": {
    "journal_id": 60,
    "name": "Website redesign v2"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"name\":[\"The name field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Journal record not found."
}

Add a Journal Log

Add a new log entry to a journal — the same as Save Log in the journal popup. The API owner is recorded as the author. Mention a team member with @name (their username, as shown in the app) and they are notified, as in the app. Text only: pasted images are not accepted here. A journal holds up to 200 logs. Get a journal_id from List Journals.

POST https://ahsuite.com/api/portal/journal/log/add
Body parameters
portal_id required
string
Unique Portal ID. See example →
journal_id required
integer
The journal to add the log to, from List Journals. A journal of another portal or an archived one returns 404.
log required
string
The log text, max 2000 characters. Plain text or simple HTML (p, br, strong, em, ul, ol, li, a); other tags are removed.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "journal_id=60" \
  --data-urlencode "log=Kickoff call done, notes shared with @jane." \
  'https://ahsuite.com/api/portal/journal/log/add'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'journal_id' => 60,
    'log' => 'Kickoff call done, notes shared with @jane.',
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/journal/log/add');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams();
body.append('portal_id', 'vJW4');
body.append('journal_id', '60');
body.append('log', 'Kickoff call done, notes shared with @jane.');

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/journal/log/add', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/journal/log/add',
    data={'portal_id': 'vJW4', 'journal_id': 60, 'log': 'Kickoff call done, notes shared with @jane.'},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Log has been added to the journal.",
  "data": {
    "journal_id": 60,
    "log_id": 413
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"log\":[\"The log field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Journal record not found."
}

Save a Login

Save a login's name, address and username in a portal's Password Manager. This endpoint never takes a password — any field outside the ones below (including password) saves nothing and returns 400. Without options the login is saved as Password needed and the response carries add_password_url, the page in Ahsuite where the password is added. With request, Ahsuite emails that client user to add the password in their portal. With generate, Ahsuite creates a strong 20-character password that can be revealed in Ahsuite (it is never returned here).

POST https://ahsuite.com/api/portal/password/create
Query parameters
portal_id required
string
Unique Portal ID. See example →
name required
string
The login name, e.g. WordPress admin. Max 255 characters.
url
string
The login address. Max 250 characters.
username
string
The login username. Max 250 characters.
notes
string
Notes for the login. Max 5000 characters. Passwords don't belong here either.
share_with_client
integer
Set 1 to make the login visible to the client. Default 0.
share_with_team
integer
Set 1 to share the login with your internal team. Default 0 (only you).
request[client_user_email]
string
Ask this client user to add the password, like a file request. Must be an active client user of the portal (see List Contacts). The login is shared with that user, who gets an email and a notification. One open request per login, max 20 requests per portal per day. Cannot be combined with generate.
request[message]
string
Plain-text note included in the request email. Max 500 characters.
generate
boolean
Set 1 and Ahsuite generates a strong password for this new login (letters, digits and symbols). The value is stored encrypted and never returned by the API. Cannot be combined with request.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/password/create' \
  --data-urlencode 'portal_id=PORTAL_ID' \
  --data-urlencode 'name=Hosting cPanel' \
  --data-urlencode 'url=https://example.com/login' \
  --data-urlencode 'username=admin' \
  --data-urlencode 'share_with_client=0' \
  --data-urlencode 'request[client_user_email]=sam@client.com' \
  --data-urlencode 'request[message]=Please add the cPanel password here.'
$ch = curl_init();
$data = [
    'portal_id' => 'PORTAL_ID',
    'name'      => 'Hosting cPanel',
    'url'       => 'https://example.com/login',
    'username'  => 'admin',
    'share_with_client' => 0,
    'request'   => [
        'client_user_email' => 'sam@client.com',
        'message'           => 'Please add the cPanel password here.',
    ],
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/password/create');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({
  portal_id: 'PORTAL_ID',
  name: 'Hosting cPanel',
  url: 'https://example.com/login',
  username: 'admin',
  share_with_client: '0',
  'request[client_user_email]': 'sam@client.com',
  'request[message]': 'Please add the cPanel password here.',
});

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/password/create', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/password/create',
    auth=('EMAIL', 'TOKEN'),
    data={
        'portal_id': 'PORTAL_ID',
        'name':      'Hosting cPanel',
        'url':       'https://example.com/login',
        'username':  'admin',
        'share_with_client': 0,
        'request[client_user_email]': 'sam@client.com',
        'request[message]': 'Please add the cPanel password here.',
    },
)
print(res.json())
Response
// without options — password still needed
{
  "success": true,
  "message": "Login saved without a password. The user adds the password in Ahsuite at add_password_url.",
  "data": {
    "password_id": "vJW4",
    "password_status": "needed",
    "add_password_url": "https://ahsuite.com/agent/client/tool/account-access/edit/NdLR/vJW4"
  }
}

// with request
{
  "success": true,
  "message": "Login saved. Sam has been asked to add the password in Ahsuite.",
  "data": {
    "password_id": "vJW4",
    "password_status": "requested",
    "request": {
      "to": "Sam",
      "to_email": "sam@client.com",
      "sent_at": "2026-10-13T09:15:30-07:00"
    }
  }
}

// with generate
{
  "success": true,
  "message": "Login saved with a strong generated password. The user can reveal it in Ahsuite at login_url.",
  "data": {
    "password_id": "vJW4",
    "password_status": "set",
    "login_url": "https://ahsuite.com/agent/client/tool/account-access/edit/NdLR/vJW4"
  }
}
// a password was sent — nothing is saved
{
  "success": false,
  "message": "Validation Error",
  "data": "Passwords can't go through the assistant, so nothing was saved. Save the login without its password; the user then adds the password in Ahsuite. If the user typed a password in this chat, tell them it is now in the chat history."
}

// other refusals
{ "success": false, "message": "Validation Error", "data": "That person isn't a client user of this portal." }
{ "success": false, "message": "Validation Error", "data": "This portal has sent 20 password requests today. Try again tomorrow." }
{ "success": false, "message": "Validation Error", "data": "Send either request or generate, not both." }
{ "success": false, "message": "Validation Error", "data": "{\"name\":[\"The name field is required.\"]}" }
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

List Logins

Return the saved logins of a portal, newest first — name, address, username, sharing, password_status (needed, requested or set), when the password last changed and by whom, the open request when there is one, and add_password_url while the password is still needed. All dates are ISO 8601 in the portal's timezone. No field ever contains a password or any part of one; passwords are only viewed in the Ahsuite app. Archived logins are excluded. Results are paginated.

GET https://ahsuite.com/api/portal/password/list
Query parameters
portal_id required
string
Unique Portal ID. See example →
search
string
Filter by record name — returns records whose name contains this text. Max 100 characters.
page
integer
Page number to return. Defaults to 1.
limit
integer
Rows per page. Defaults to 20, maximum 100.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request GET \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/password/list?portal_id=vJW4&page=1&limit=20'
$params = http_build_query([
    'portal_id' => 'vJW4',
    'page' => 1,
    'limit' => 20,
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/password/list?'.$params);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const params = new URLSearchParams({ portal_id: 'vJW4', page: 1, limit: 20 });
const res = await fetch(`https://ahsuite.com/api/portal/password/list?${params}`, {
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.get(
    'https://ahsuite.com/api/portal/password/list',
    params={'portal_id': 'vJW4', 'page': 1, 'limit': 20},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "",
  "data": {
    "passwords": [
      {
        "password_id": "vJW4",
        "name": "WordPress admin",
        "url": "https://clientsite.com/wp-admin",
        "username": "admin",
        "share_with_client": 1,
        "share_with_team": 0,
        "password_status": "requested",
        "password_changed_at": null,
        "password_changed_by": "",
        "created_date": "2026-10-13T09:15:30-07:00",
        "request": {
          "to": "Sam",
          "requested_by": "Kevin Frei",
          "sent_at": "2026-10-13T09:15:30-07:00"
        }
      },
      {
        "password_id": "eRq2",
        "name": "Hosting cPanel",
        "url": "https://host.com:2083",
        "username": "client",
        "share_with_client": 0,
        "share_with_team": 1,
        "password_status": "set",
        "password_changed_at": "2026-10-09T14:22:05-07:00",
        "password_changed_by": "Kevin Frei",
        "created_date": "2026-05-02T14:22:05-07:00"
      },
      {
        "password_id": "kL9m",
        "name": "Analytics",
        "url": "https://analytics.example.com",
        "username": "",
        "share_with_client": 0,
        "share_with_team": 0,
        "password_status": "needed",
        "password_changed_at": null,
        "password_changed_by": "",
        "created_date": "2026-10-12T16:40:00-07:00",
        "add_password_url": "https://ahsuite.com/agent/client/tool/account-access/edit/NdLR/kL9m"
      }
    ],
    "pagination": {
      "total": 8,
      "page": 1,
      "limit": 20,
      "total_pages": 1
    }
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"portal_id\":[\"The portal id field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

Create an Invoice

Create a new invoice inside an existing portal with one or more line items, each with optional add/subtract sub-items (e.g. taxes, fees, discounts). The invoice total is computed automatically from the items — you do not send a total. Client users who have email notifications enabled are notified by email. Returns the new invoice_id and invoice_number on success.

POST https://ahsuite.com/api/portal/invoice/create
Query parameters
portal_id required
string
Unique Portal ID. See example →
invoice_date required
string
The invoice date, format YYYY-MM-DD.
due_date required
string
The due date, format YYYY-MM-DD.
invoice_number
integer
A positive invoice number, unique per portal. When omitted, the next available number is used automatically.
currency
string
USD (default) or GBP.
notes
string
Notes shown on the invoice. Max 500 characters.
Line items — items[] required (max 50)
items[0][name] required
string
The line item name. Max 250 characters.
items[0][quantity] required
integer
Quantity, minimum 1.
items[0][price] required
number
Unit price.
items[0][description]
string
Line item description. Max 400 characters.
items[0][sub_items][0][type]
string
add (e.g. tax, fee) or subtract (e.g. discount). Required for each sub-item. Max 20 sub-items per item.
items[0][sub_items][0][amount]
number
The sub-item amount. Required for each sub-item.
items[0][sub_items][0][description]
string
Sub-item description (e.g. "VAT 20%"). Max 500 characters.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/invoice/create' \
  --data-urlencode 'portal_id=PORTAL_ID' \
  --data-urlencode 'invoice_date=2026-07-08' \
  --data-urlencode 'due_date=2026-07-22' \
  --data-urlencode 'items[0][name]=Website design' \
  --data-urlencode 'items[0][quantity]=1' \
  --data-urlencode 'items[0][price]=500' \
  --data-urlencode 'items[0][sub_items][0][type]=add' \
  --data-urlencode 'items[0][sub_items][0][description]=VAT 20%' \
  --data-urlencode 'items[0][sub_items][0][amount]=100'
$ch = curl_init();
$data = [
    'portal_id'    => 'PORTAL_ID',
    'invoice_date' => '2026-07-08',
    'due_date'     => '2026-07-22',
    'items' => [
        [
            'name'     => 'Website design',
            'quantity' => 1,
            'price'    => 500,
            'sub_items' => [
                [ 'type' => 'add', 'description' => 'VAT 20%', 'amount' => 100 ],
            ],
        ],
    ],
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/invoice/create');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({
  portal_id: 'PORTAL_ID',
  invoice_date: '2026-07-08',
  due_date: '2026-07-22',
  'items[0][name]': 'Website design',
  'items[0][quantity]': '1',
  'items[0][price]': '500',
  'items[0][sub_items][0][type]': 'add',
  'items[0][sub_items][0][description]': 'VAT 20%',
  'items[0][sub_items][0][amount]': '100',
});

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/invoice/create', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/invoice/create',
    auth=('EMAIL', 'TOKEN'),
    data={
        'portal_id':    'PORTAL_ID',
        'invoice_date': '2026-07-08',
        'due_date':     '2026-07-22',
        'items[0][name]':     'Website design',
        'items[0][quantity]': 1,
        'items[0][price]':    500,
        'items[0][sub_items][0][type]':        'add',
        'items[0][sub_items][0][description]': 'VAT 20%',
        'items[0][sub_items][0][amount]':      100,
    },
)
print(res.json())
Response
{
  "success": true,
  "message": "Invoice has been added successfully.",
  "data": {
    "invoice_id": 55,
    "invoice_number": "00006"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "Invoice number 00006 already exists for this portal. Please use a different number."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

List Invoices

Return the invoices of a portal, newest first — number, dates, amount, currency and payment status. Archived invoices are excluded. Results are paginated.

GET https://ahsuite.com/api/portal/invoice/list
Query parameters
portal_id required
string
Unique Portal ID. See example →
page
integer
Page number to return. Defaults to 1.
limit
integer
Rows per page. Defaults to 20, maximum 100.

Invoice status is one of: due, paid, payment_processing, cancelled, refunded. Other unsuccessful response codes: 403, 429, 500.

Request
curl --request GET \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/invoice/list?portal_id=vJW4&page=1&limit=20'
$params = http_build_query([
    'portal_id' => 'vJW4',
    'page' => 1,
    'limit' => 20,
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/invoice/list?'.$params);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const params = new URLSearchParams({ portal_id: 'vJW4', page: 1, limit: 20 });
const res = await fetch(`https://ahsuite.com/api/portal/invoice/list?${params}`, {
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.get(
    'https://ahsuite.com/api/portal/invoice/list',
    params={'portal_id': 'vJW4', 'page': 1, 'limit': 20},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "",
  "data": {
    "invoices": [
      {
        "invoice_number": "00012",
        "invoice_date": "2026-08-01",
        "due_date": "2026-08-15",
        "total_amount": "450.00",
        "currency": "USD",
        "status": "due",
        "created_date": "2026-08-01T10:12:44-07:00",
        "invoice_id": 55
      },
      {
        "invoice_number": "00011",
        "invoice_date": "2026-07-01",
        "due_date": "2026-07-15",
        "total_amount": "450.00",
        "currency": "USD",
        "status": "paid",
        "created_date": "2026-07-01T09:03:21-07:00",
        "invoice_id": 54
      }
    ],
    "pagination": {
      "total": 12,
      "page": 1,
      "limit": 20,
      "total_pages": 1
    }
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"portal_id\":[\"The portal id field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

Invoice Detail

Return a single invoice in full — number, dates, currency, status, total, notes and every line item with its add / subtract sub-items, as shown on the invoice screen in the app. Field names match Create Invoice. Archived invoices are not returned. Get an invoice_id from List Invoices.

GET https://ahsuite.com/api/portal/invoice/detail
Query parameters
portal_id required
string
Unique Portal ID. See example →
invoice_id required
integer
The invoice to return, as given by List Invoices or returned by Create Invoice. An invoice of another portal returns 404.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request GET \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/invoice/detail?portal_id=vJW4&invoice_id=55'
$params = http_build_query([
    'portal_id' => 'vJW4',
    'invoice_id' => 55,
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/invoice/detail?'.$params);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const params = new URLSearchParams({ portal_id: 'vJW4', invoice_id: '55' });
const res = await fetch(`https://ahsuite.com/api/portal/invoice/detail?${params}`, {
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.get(
    'https://ahsuite.com/api/portal/invoice/detail',
    params={'portal_id': 'vJW4', 'invoice_id': 55},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "",
  "data": {
    "invoice_number": "00011",
    "invoice_date": "2026-06-01",
    "due_date": "2026-06-15",
    "currency": "USD",
    "status": "due",
    "total_amount": "1150.00",
    "notes": "Payable within 14 days.",
    "created_date": "2026-06-01T10:02:44-07:00",
    "invoice_id": 55,
    "items": [
      {
        "name": "Website design",
        "description": "Homepage and 3 inner pages",
        "quantity": 1,
        "price": "1000.00",
        "line_total": "1150.00",
        "item_id": 912,
        "sub_items": [
          { "type": "add", "description": "Rush delivery", "amount": "200.00", "sub_item_id": 3300 },
          { "type": "subtract", "description": "Loyalty discount", "amount": "50.00", "sub_item_id": 3301 }
        ]
      }
    ]
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"invoice_id\":[\"The invoice id field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Invoice record not found."
}

Update an Invoice

Edit the header of an unpaid invoice — number, dates, currency and notes. Paid, processing, cancelled and refunded invoices cannot be edited. This is a partial update: only the parameters you send are changed. Line items are not changed here; use Add an Invoice Item and Update an Invoice Item. No email is sent to the client. Get an invoice_id from List Invoices.

POST https://ahsuite.com/api/portal/invoice/update
Body parameters
portal_id required
string
Unique Portal ID. See example →
invoice_id required
integer
The invoice to edit, from List Invoices. An invoice of another portal returns 404; an invoice that is not unpaid returns 400.
invoice_number
integer
New invoice number. Must be unique within the portal.
invoice_date
string
Format YYYY-MM-DD.
due_date
string
Format YYYY-MM-DD.
currency
string
USD or GBP.
notes
string
Max 500 characters. Send an empty value to clear the notes.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "invoice_id=55" \
  --data-urlencode "due_date=2026-10-30" \
  --data-urlencode "notes=Payable within 30 days." \
  'https://ahsuite.com/api/portal/invoice/update'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'invoice_id' => 55,
    'due_date' => '2026-10-30',
    'notes' => 'Payable within 30 days.',
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/invoice/update');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams();
body.append('portal_id', 'vJW4');
body.append('invoice_id', '55');
body.append('due_date', '2026-10-30');
body.append('notes', 'Payable within 30 days.');

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/invoice/update', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/invoice/update',
    data={'portal_id': 'vJW4', 'invoice_id': 55, 'due_date': '2026-10-30', 'notes': 'Payable within 30 days.'},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Invoice has been updated.",
  "data": {
    "invoice_id": 55,
    "invoice_number": "00011",
    "total_amount": "1150.00"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "Only unpaid invoices can be edited."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Invoice record not found."
}

Add an Invoice Item

Add one line item to an unpaid invoice — the same as + Add Another Item on the edit form. The item can carry add / subtract adjustments (modifications). Existing items are left untouched and the invoice total is recomputed. An invoice holds up to 50 items. Get an invoice_id from List Invoices.

POST https://ahsuite.com/api/portal/invoice/item/add
Body parameters
portal_id required
string
Unique Portal ID. See example →
invoice_id required
integer
The invoice, from List Invoices. An invoice that is not unpaid returns 400.
name required
string
Product / service name, max 250 characters.
quantity required
integer
Minimum 1.
price required
number
Price per unit, 0 or more.
description
string
Max 400 characters.
sub_items[]
array
Adjustments on this item, up to 20: sub_items[0][type] (add / subtract), sub_items[0][amount], optional sub_items[0][description].

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "invoice_id=55" \
  --data-urlencode "name=Logo design" \
  --data-urlencode "quantity=1" \
  --data-urlencode "price=300" \
  --data-urlencode "sub_items[0][type]=subtract" \
  --data-urlencode "sub_items[0][description]=Loyalty discount" \
  --data-urlencode "sub_items[0][amount]=50" \
  'https://ahsuite.com/api/portal/invoice/item/add'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'invoice_id' => 55,
    'name' => 'Logo design',
    'quantity' => 1,
    'price' => 300,
    'sub_items' => [['type' => 'subtract', 'description' => 'Loyalty discount', 'amount' => 50]],
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/invoice/item/add');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams();
body.append('portal_id', 'vJW4');
body.append('invoice_id', '55');
body.append('name', 'Logo design');
body.append('quantity', '1');
body.append('price', '300');
body.append('sub_items[0][type]', 'subtract');
body.append('sub_items[0][description]', 'Loyalty discount');
body.append('sub_items[0][amount]', '50');

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/invoice/item/add', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/invoice/item/add',
    data={
        'portal_id': 'vJW4', 'invoice_id': 55, 'name': 'Logo design', 'quantity': 1, 'price': 300,
        'sub_items[0][type]': 'subtract', 'sub_items[0][description]': 'Loyalty discount', 'sub_items[0][amount]': 50,
    },
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Item has been added to the invoice.",
  "data": {
    "invoice_id": 55,
    "item_id": 912,
    "total_amount": "1400.00"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "Only unpaid invoices can be edited."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Invoice record not found."
}

Update an Invoice Item

Edit one line item of an unpaid invoice. This is a partial update: only the fields you send are changed. Adjustments (sub_items[]) can be edited by passing their sub_item_id, or appended by leaving it out. To delete an item use Remove an Invoice Item; to delete an adjustment use Remove an Invoice Adjustment. The invoice total is recomputed. Get item_id and sub_item_id from Invoice Detail.

POST https://ahsuite.com/api/portal/invoice/item/update
Body parameters
portal_id required
string
Unique Portal ID. See example →
invoice_id required
integer
The invoice, from List Invoices. An invoice that is not unpaid returns 400.
item_id required
integer
The item to edit, from Invoice Detail items[].item_id. An item of another invoice returns 404.
name
string
Max 250 characters.
description
string
Max 400 characters. Send an empty value to clear it.
quantity
integer
Minimum 1.
price
number
Price per unit, 0 or more.
sub_items[]
array
Up to 20 adjustments. With sub_items[0][sub_item_id] the existing adjustment is edited (send only the fields to change). Without it a new adjustment is appended and [type] (add / subtract) plus [amount] are required; [description] is optional.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "invoice_id=55" \
  --data-urlencode "item_id=912" \
  --data-urlencode "quantity=2" \
  --data-urlencode "sub_items[0][sub_item_id]=3301" \
  --data-urlencode "sub_items[0][amount]=75" \
  --data-urlencode "sub_items[1][type]=add" \
  --data-urlencode "sub_items[1][description]=Rush delivery" \
  --data-urlencode "sub_items[1][amount]=100" \
  'https://ahsuite.com/api/portal/invoice/item/update'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'invoice_id' => 55,
    'item_id' => 912,
    'quantity' => 2,
    'sub_items' => [
        ['sub_item_id' => 3301, 'amount' => 75],
        ['type' => 'add', 'description' => 'Rush delivery', 'amount' => 100],
    ],
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/invoice/item/update');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams();
body.append('portal_id', 'vJW4');
body.append('invoice_id', '55');
body.append('item_id', '912');
body.append('quantity', '2');
body.append('sub_items[0][sub_item_id]', '3301');
body.append('sub_items[0][amount]', '75');
body.append('sub_items[1][type]', 'add');
body.append('sub_items[1][description]', 'Rush delivery');
body.append('sub_items[1][amount]', '100');

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/invoice/item/update', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/invoice/item/update',
    data={
        'portal_id': 'vJW4', 'invoice_id': 55, 'item_id': 912, 'quantity': 2,
        'sub_items[0][sub_item_id]': 3301, 'sub_items[0][amount]': 75,
        'sub_items[1][type]': 'add', 'sub_items[1][description]': 'Rush delivery', 'sub_items[1][amount]': 100,
    },
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Invoice item has been updated.",
  "data": {
    "invoice_id": 55,
    "item_id": 912,
    "total_amount": "2225.00"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "Nothing to update. Send at least one of: name, description, quantity, price, sub_items."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Item not found on this invoice."
}

Remove an Invoice Item

Remove one line item from an unpaid invoice together with all its adjustments — the same as Remove Item on the edit form. To remove a single adjustment and keep the item, use Remove an Invoice Adjustment. This cannot be undone. The invoice total is recomputed. Get item_id from Invoice Detail.

POST https://ahsuite.com/api/portal/invoice/item/remove
Body parameters
portal_id required
string
Unique Portal ID. See example →
invoice_id required
integer
The invoice, from List Invoices. An invoice that is not unpaid returns 400.
item_id required
integer
The item, from Invoice Detail items[].item_id. An item of another invoice returns 404.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "invoice_id=55" \
  --data-urlencode "item_id=912" \
  'https://ahsuite.com/api/portal/invoice/item/remove'
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/invoice/item/remove');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query(['portal_id' => 'vJW4', 'invoice_id' => 55, 'item_id' => 912]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams();
body.append('portal_id', 'vJW4');
body.append('invoice_id', '55');
body.append('item_id', '912');

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/invoice/item/remove', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/invoice/item/remove',
    data={'portal_id': 'vJW4', 'invoice_id': 55, 'item_id': 912},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Item has been removed from the invoice.",
  "data": {
    "invoice_id": 55,
    "item_id": 912,
    "total_amount": "0.00"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "Only unpaid invoices can be edited."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Item not found on this invoice."
}

Remove an Invoice Adjustment

Remove one add / subtract adjustment (modification) from a line item of an unpaid invoice — the trash icon next to a modification on the edit form. The item itself stays. This cannot be undone. The invoice total is recomputed. Get item_id and sub_item_id from Invoice Detail.

POST https://ahsuite.com/api/portal/invoice/item/adjustment/remove
Body parameters
portal_id required
string
Unique Portal ID. See example →
invoice_id required
integer
The invoice, from List Invoices. An invoice that is not unpaid returns 400.
item_id required
integer
The item the adjustment belongs to, from Invoice Detail items[].item_id.
sub_item_id required
integer
The adjustment to remove, from items[].sub_items[].sub_item_id. An adjustment of another item returns 404.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "invoice_id=55" \
  --data-urlencode "item_id=912" \
  --data-urlencode "sub_item_id=3301" \
  'https://ahsuite.com/api/portal/invoice/item/adjustment/remove'
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/invoice/item/adjustment/remove');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query(['portal_id' => 'vJW4', 'invoice_id' => 55, 'item_id' => 912, 'sub_item_id' => 3301]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams();
body.append('portal_id', 'vJW4');
body.append('invoice_id', '55');
body.append('item_id', '912');
body.append('sub_item_id', '3301');

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/invoice/item/adjustment/remove', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/invoice/item/adjustment/remove',
    data={'portal_id': 'vJW4', 'invoice_id': 55, 'item_id': 912, 'sub_item_id': 3301},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Adjustment has been removed from the item.",
  "data": {
    "invoice_id": 55,
    "item_id": 912,
    "sub_item_id": 3301,
    "total_amount": "1200.00"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "Only unpaid invoices can be edited."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Adjustment not found on this item."
}

Mark an Invoice Paid

Mark an unpaid invoice as paid (manual payment), the same as the Mark Paid button on the invoice screen. Only unpaid invoices can be marked paid, and this cannot be undone through the API. No email is sent. Get an invoice_id from List Invoices.

POST https://ahsuite.com/api/portal/invoice/mark-paid
Body parameters
portal_id required
string
Unique Portal ID. See example →
invoice_id required
integer
The invoice to mark paid, from List Invoices. An already-paid invoice returns 400.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "invoice_id=55" \
  'https://ahsuite.com/api/portal/invoice/mark-paid'
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/invoice/mark-paid');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query(['portal_id' => 'vJW4', 'invoice_id' => 55]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams();
body.append('portal_id', 'vJW4');
body.append('invoice_id', '55');

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/invoice/mark-paid', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/invoice/mark-paid',
    data={'portal_id': 'vJW4', 'invoice_id': 55},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Invoice has been marked as paid.",
  "data": {
    "invoice_id": 55,
    "invoice_number": "00011",
    "status": "paid"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "This invoice is already paid."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Invoice record not found."
}

Create a Project

Create a new project board inside an existing portal. Requires a valid portal_id. Returns the new project_id on success.

POST https://ahsuite.com/api/portal/project/create
Query parameters
portal_id required
string
Unique Portal ID. See example →
name required
string
The project name. Max 100 characters.
description
string
The project description. Max 5000 characters.
share_with_client
integer
Set 1 to make the project visible to the client. Default 0.
share_with_team
integer
Set 1 to share the project with your internal team. Default 0 (only you).

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/project/create' \
  --data-urlencode 'portal_id=PORTAL_ID' \
  --data-urlencode 'name=Website Redesign' \
  --data-urlencode 'description=Full redesign of the marketing site' \
  --data-urlencode 'share_with_client=1'
$ch = curl_init();
$data = [
    'portal_id'   => 'PORTAL_ID',
    'name'        => 'Website Redesign',
    'description' => 'Full redesign of the marketing site',
    'share_with_client' => 1,
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/project/create');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({
  portal_id: 'PORTAL_ID',
  name: 'Website Redesign',
  description: 'Full redesign of the marketing site',
  share_with_client: '1',
});

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/project/create', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/project/create',
    auth=('EMAIL', 'TOKEN'),
    data={
        'portal_id':   'PORTAL_ID',
        'name':        'Website Redesign',
        'description': 'Full redesign of the marketing site',
        'share_with_client': 1,
    },
)
print(res.json())
Response
{
  "success": true,
  "message": "Project has been added successfully.",
  "data": {
    "project_id": "vJW4"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"name\":[\"The name field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

List Projects

Return the projects of a portal in the same order as the app (manual position, then newest first). Archived projects are excluded. Results are paginated.

GET https://ahsuite.com/api/portal/project/list
Query parameters
portal_id required
string
Unique Portal ID. See example →
search
string
Filter by project name — returns projects whose name contains this text. Max 100 characters.
page
integer
Page number to return. Defaults to 1.
limit
integer
Rows per page. Defaults to 20, maximum 100.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request GET \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/project/list?portal_id=vJW4&page=1&limit=20'
$params = http_build_query([
    'portal_id' => 'vJW4',
    'page' => 1,
    'limit' => 20,
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/project/list?'.$params);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const params = new URLSearchParams({ portal_id: 'vJW4', page: 1, limit: 20 });
const res = await fetch(`https://ahsuite.com/api/portal/project/list?${params}`, {
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.get(
    'https://ahsuite.com/api/portal/project/list',
    params={'portal_id': 'vJW4', 'page': 1, 'limit': 20},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "",
  "data": {
    "projects": [
      {
        "name": "Website redesign",
        "share_with_client": 1,
        "created_date": "2026-07-14T11:20:10-07:00",
        "project_id": 82
      },
      {
        "name": "SEO retainer",
        "share_with_client": 0,
        "created_date": "2026-06-02T15:44:37-07:00",
        "project_id": 75
      }
    ],
    "pagination": {
      "total": 6,
      "page": 1,
      "limit": 20,
      "total_pages": 1
    }
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"portal_id\":[\"The portal id field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

Project Detail

Return a single project in full — name, description, sharing, its tasks and phases in the order the project screen shows them, and its resources (linked pages, files, conversations and passwords). Task rows use the same fields as List Tasks; resource ids match the other list endpoints. Archived projects are not returned.

GET https://ahsuite.com/api/portal/project/detail
Query parameters
portal_id required
string
Unique Portal ID. See example →
project_id required
integer
The project to return, as given by List Projects or returned by Create Project. A project of another portal, or one you are not assigned to, returns 404.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request GET \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/project/detail?portal_id=vJW4&project_id=328'
$params = http_build_query([
    'portal_id' => 'vJW4',
    'project_id' => 328,
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/project/detail?'.$params);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const params = new URLSearchParams({ portal_id: 'vJW4', project_id: '328' });
const res = await fetch(`https://ahsuite.com/api/portal/project/detail?${params}`, {
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.get(
    'https://ahsuite.com/api/portal/project/detail',
    params={'portal_id': 'vJW4', 'project_id': 328},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "",
  "data": {
    "name": "Website redesign",
    "description": "",
    "share_with_client": 1,
    "share_with_team": 1,
    "created_date": "2026-06-02T14:20:11-07:00",
    "project_id": 328,
    "tasks": [
      { "name": "Send the June report", "status": "ready_for_work", "assigned_to": "contact 1 (Client)", "due_date": "2026-12-10", "task_id": 3659 }
    ],
    "phases": [
      { "name": "Phase 1", "phase_id": 91, "tasks": [] },
      { "name": "Phase 2", "phase_id": 92, "tasks": [
        { "name": "child 1", "status": "ready_for_work", "assigned_to": "Steve 1", "due_date": null, "task_id": 3702 }
      ] }
    ],
    "resources": [
      { "type": "page", "name": "Onboarding", "page_id": "9dRt" },
      { "type": "file", "name": "Signed contract", "file_id": "7pQ1" },
      { "type": "conversation", "name": "Q3 planning", "conversation_id": 276 },
      { "type": "password", "name": "Hosting login", "password_id": "4hLq" }
    ]
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"project_id\":[\"The project id field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Project record not found."
}

Update a Project

Rename a project and/or change its description — the same as editing the name and description on the project screen. This is a partial update: only the parameters you send are changed.

POST https://ahsuite.com/api/portal/project/update
Body parameters
portal_id required
string
Unique Portal ID. See example →
project_id required
integer
The project to update, from List Projects or Project Detail. A project of another portal, or one you are not assigned to, returns 404.
name
string
New project name. Max 100 characters. Cannot be blank.
description
string
Project description. HTML allowed: p, br, strong, em, ul, ol, li, a. Max 5000 characters. Empty string clears it.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "project_id=328" \
  --data-urlencode "name=Website redesign (phase 2)" \
  --data-urlencode "description=

Homepage, pricing and blog.

" \ 'https://ahsuite.com/api/portal/project/update'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'project_id' => 328,
    'name' => 'Website redesign (phase 2)',
    'description' => '

Homepage, pricing and blog.

', ]; curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/project/update'); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); curl_setopt($ch, CURLOPT_USERPWD, "$username:$password"); $result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({ portal_id: 'vJW4', project_id: '328', name: 'Website redesign (phase 2)', description: '

Homepage, pricing and blog.

' }); const auth = Buffer.from('EMAIL:TOKEN').toString('base64'); const res = await fetch('https://ahsuite.com/api/portal/project/update', { method: 'POST', headers: { Authorization: `Basic ${auth}` }, body, }); console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/project/update',
    data={'portal_id': 'vJW4', 'project_id': 328, 'name': 'Website redesign (phase 2)', 'description': '

Homepage, pricing and blog.

'}, auth=('EMAIL', 'TOKEN'), ) print(res.json())
Response
{
  "success": true,
  "message": "Project has been updated successfully.",
  "data": {
    "project_id": 328
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "No settings were sent to update."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Project record not found."
}

Add a Project Phase

Add a phase to a project — the same as Add Phase on the project screen. Phases group the project's tasks; a new phase is added at the end. Returns the new phase_id.

POST https://ahsuite.com/api/portal/project/phase/add
Body parameters
portal_id required
string
Unique Portal ID. See example →
project_id required
integer
The project, from List Projects or Project Detail.
name
string
Phase name. Max 200 characters. Defaults to Phase N (next number), as in the app.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "project_id=328" \
  --data-urlencode "name=Discovery" \
  'https://ahsuite.com/api/portal/project/phase/add'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'project_id' => 328,
    'name' => 'Discovery',
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/project/phase/add');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({ portal_id: 'vJW4', project_id: '328', name: 'Discovery' });

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/project/phase/add', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/project/phase/add',
    data={'portal_id': 'vJW4', 'project_id': 328, 'name': 'Discovery'},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Phase has been added successfully.",
  "data": {
    "project_id": 328,
    "phase_id": 94,
    "name": "Discovery"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"name\":[\"The name may not be greater than 200 characters.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Project record not found."
}

Update a Project Phase

Rename a phase of a project — the same as clicking the pencil next to a phase name on the project screen.

POST https://ahsuite.com/api/portal/project/phase/update
Body parameters
portal_id required
string
Unique Portal ID. See example →
project_id required
integer
The project, from List Projects or Project Detail.
phase_id required
integer
The phase to rename, from Project Detail phases[].phase_id. A phase of another project returns 404.
name required
string
New phase name. Max 200 characters. Cannot be blank.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "project_id=328" \
  --data-urlencode "phase_id=94" \
  --data-urlencode "name=Discovery & research" \
  'https://ahsuite.com/api/portal/project/phase/update'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'project_id' => 328,
    'phase_id' => 94,
    'name' => 'Discovery & research',
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/project/phase/update');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({ portal_id: 'vJW4', project_id: '328', phase_id: '94', name: 'Discovery & research' });

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/project/phase/update', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/project/phase/update',
    data={'portal_id': 'vJW4', 'project_id': 328, 'phase_id': 94, 'name': 'Discovery & research'},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Phase has been updated successfully.",
  "data": {
    "project_id": 328,
    "phase_id": 94,
    "name": "Discovery & research"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "Phase name is blank."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Phase record not found on this project."
}

Add Tasks to a Project

Put existing tasks of the portal into a project — the same as Select tasks on the project screen — optionally into a specific phase. Also use it to move a task that is already in the project to another phase. To create a brand-new task inside a project, create the task first and then add it here.

POST https://ahsuite.com/api/portal/project/task/add
Body parameters
portal_id required
string
Unique Portal ID. See example →
project_id required
integer
The project, from List Projects or Project Detail.
task_ids[] required
array
Task IDs to add, one per task_ids[] entry (from List Tasks). Up to 20 per request. A task of another portal, or one that is archived or canceled, returns 404 and nothing is changed.
phase_id
integer
The phase to place the tasks in, from Project Detail phases[].phase_id. Omit to use the project's last phase (as the app does); send 0 for no phase (top of the project).

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "project_id=328" \
  --data-urlencode "task_ids[]=3659" \
  --data-urlencode "task_ids[]=3702" \
  --data-urlencode "phase_id=92" \
  'https://ahsuite.com/api/portal/project/task/add'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'project_id' => 328,
    'task_ids' => [3659, 3702],
    'phase_id' => 92,
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/project/task/add');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams();
body.append('portal_id', 'vJW4');
body.append('project_id', '328');
body.append('task_ids[]', '3659');
body.append('task_ids[]', '3702');
body.append('phase_id', '92');

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/project/task/add', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/project/task/add',
    data={'portal_id': 'vJW4', 'project_id': 328, 'task_ids[]': [3659, 3702], 'phase_id': 92},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Tasks have been added to the project.",
  "data": {
    "project_id": 328,
    "phase_id": 92,
    "task_ids": [3659, 3702]
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"task_ids\":[\"The task ids field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Task record not found: 3999"
}

Add Resources to a Project

Link existing pages, files, conversations or passwords of the portal to a project — the same as Add Resources on the project screen. Resources that are already on the project are skipped. Every resource is checked against the portal first; if any one is not found, nothing is linked.

POST https://ahsuite.com/api/portal/project/resource/add
Body parameters
portal_id required
string
Unique Portal ID. See example →
project_id required
integer
The project, from List Projects or Project Detail.
resources[] required
array
The resources to link, up to 20 per request. Each entry has two keys, sent as resources[0][type] and resources[0][id].
resources[][type] required
string
One of page, file, conversation, password.
resources[][id] required
string
The ID exactly as its list endpoint returns it: page_id from List Pages, file_id from List Files, conversation_id from List Conversations, password_id from List Passwords. A resource of another portal or an archived one returns 404.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=vJW4" \
  --data-urlencode "project_id=328" \
  --data-urlencode "resources[0][type]=page" \
  --data-urlencode "resources[0][id]=l78w" \
  --data-urlencode "resources[1][type]=file" \
  --data-urlencode "resources[1][id]=ZrV1" \
  --data-urlencode "resources[2][type]=conversation" \
  --data-urlencode "resources[2][id]=276" \
  'https://ahsuite.com/api/portal/project/resource/add'
$ch = curl_init();
$data = [
    'portal_id' => 'vJW4',
    'project_id' => 328,
    'resources' => [
        ['type' => 'page', 'id' => 'l78w'],
        ['type' => 'file', 'id' => 'ZrV1'],
        ['type' => 'conversation', 'id' => '276'],
    ],
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/project/resource/add');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams();
body.append('portal_id', 'vJW4');
body.append('project_id', '328');
body.append('resources[0][type]', 'page');
body.append('resources[0][id]', 'l78w');
body.append('resources[1][type]', 'file');
body.append('resources[1][id]', 'ZrV1');
body.append('resources[2][type]', 'conversation');
body.append('resources[2][id]', '276');

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/project/resource/add', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/project/resource/add',
    data={
        'portal_id': 'vJW4', 'project_id': 328,
        'resources[0][type]': 'page', 'resources[0][id]': 'l78w',
        'resources[1][type]': 'file', 'resources[1][id]': 'ZrV1',
        'resources[2][type]': 'conversation', 'resources[2][id]': '276',
    },
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Resources have been added to the project.",
  "data": {
    "project_id": 328,
    "added": 3
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "These resources are already linked to the project — nothing to change."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "resources[1]: file ZrV9 not found in this portal."
}

Create a Contact

Add a client user (contact) to an existing portal. Requires a valid portal_id and email. The contact is created in a pending state until they accept the invitation. Returns the new contact_id on success.

POST https://ahsuite.com/api/portal/contact/create
Query parameters
portal_id required
string
Unique Portal ID. See example →
email required
string
The contact's email address. Max 100 characters.
name
string
The contact's name. Max 50 characters.
send_client_email_notify
integer
Set 1 to send the contact an invitation email with a link to set their password. Default 0.
default_permissions Set default permissions
default_permissions[allow_add_portal_users]
integer
Set 1 to enable this user to add or remove other client users to the portal. Optional, default 0.
default_permissions[email_receive]
integer
Set 1 to enable this user to receive notifications and updates by email. Optional, default 0.
default_permissions[allow_page_add]
integer
Set 1 to enable this user to manage portal content. Optional, default 0.

These permissions apply to this contact only. To set the defaults every new client user on the portal starts with, see Update Client User Settings.

A portal can hold a maximum of 50 contacts.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=PORTAL_ID" \
  --data-urlencode "email=contact@example.com" \
  --data-urlencode "name=Contact Name" \
  --data-urlencode "default_permissions[email_receive]=1" \
  --data-urlencode "default_permissions[allow_page_add]=1" \
  --data-urlencode "send_client_email_notify=1" \
  'https://ahsuite.com/api/portal/contact/create'
$ch = curl_init();
$data = [
    'portal_id' => 'PORTAL_ID',
    'email' => 'contact@example.com',
    'name' => 'Contact Name',
    'default_permissions[email_receive]' => 1,
    'default_permissions[allow_page_add]' => 1,
    'send_client_email_notify' => 1,
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/contact/create');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({
  portal_id: 'PORTAL_ID',
  email: 'contact@example.com',
  name: 'Contact Name',
  'default_permissions[email_receive]': '1',
  'default_permissions[allow_page_add]': '1',
  send_client_email_notify: '1',
});

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/contact/create', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/contact/create',
    auth=('EMAIL', 'TOKEN'),
    data={
        'portal_id': 'PORTAL_ID',
        'email': 'contact@example.com',
        'name': 'Contact Name',
        'default_permissions[email_receive]': 1,
        'default_permissions[allow_page_add]': 1,
        'send_client_email_notify': 1,
    },
)
print(res.json())
Response
{
  "success": true,
  "message": "Contact has been added successfully.",
  "data": {
    "contact_id": "vJW4"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"email\":[\"The email field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

List Contacts

Return the client users (contacts) of a portal — the same list as the Client Users block in the app. The primary contact comes first. Each row includes the invite status (invited, active or declined), the contact's permissions. Invited and declined contacts return only their email and status (blank name, permissions = null), like the app. Results are paginated.

GET https://ahsuite.com/api/portal/contact/list
Query parameters
portal_id required
string
Unique Portal ID. See example →
search
string
Filter by contact name — returns contacts whose name contains this text. Max 100 characters.
page
integer
Page number to return. Defaults to 1.
limit
integer
Rows per page. Defaults to 20, maximum 100.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request GET \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/portal/contact/list?portal_id=vJW4&page=1&limit=20'
$params = http_build_query([
    'portal_id' => 'vJW4',
    'page' => 1,
    'limit' => 20,
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/contact/list?'.$params);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const params = new URLSearchParams({ portal_id: 'vJW4', page: 1, limit: 20 });
const res = await fetch(`https://ahsuite.com/api/portal/contact/list?${params}`, {
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.get(
    'https://ahsuite.com/api/portal/contact/list',
    params={'portal_id': 'vJW4', 'page': 1, 'limit': 20},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "",
  "data": {
    "contacts": [
      {
        "name": "Jane Cooper",
        "email": "jane@example.com",
        "primary": 1,
        "status": "active",
        "permissions": {
          "allow_add_portal_users": 1,
          "email_receive": 1,
          "allow_page_add": 0
        },
        "added_date": "2026-05-02T10:12:44-07:00",
        "contact_id": "3Kp9"
      },
      {
        "name": "",
        "email": "sam@example.com",
        "primary": 0,
        "status": "invited",
        "permissions": null,
        "added_date": "2026-07-19T16:03:21-07:00",
        "contact_id": "8Qw2"
      }
    ],
    "pagination": {
      "total": 2,
      "page": 1,
      "limit": 20,
      "total_pages": 1
    }
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"portal_id\":[\"The portal id field is required.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

Update Portal Preferences

Update the Preferences settings of an existing portal — the same options you see at the top of the portal settings screen. This is a partial update: only the parameters you send are changed, every other portal setting is left untouched.

POST https://ahsuite.com/api/portal/settings/preferences/update
Query parameters
portal_id required
string
Unique Portal ID. See example →
portal_name
string
The portal name. Max 50 characters.
phone
string
Client organisation phone. Max 20 characters. Send an empty value to clear it.
timezone
string
The portal timezone — for example America/Phoenix, Asia/Kolkata or Europe/London. Fetch the full list →
language
string
The portal language. One of en, es, de, pt, ja, fr, ar, cs, nl, et, fi, el, hi, hu, id, it, ko, no, pl, ro, ru, zh-Hans, sv, th, zh-Hant, tr, vi.
content_align
string
How portal content is aligned. Either align_left or align_center.
two_step_auth
integer
Set 1 to require 2 step authentication for this portal. Ignored on HIPAA portals, where 2 step authentication always stays on.
portal_search
integer
Set 1 to enable portal search, 0 to disable it.
files_no_login
integer
Set 1 to allow file access without login, 0 to require login.
files_hide_preview
integer
Set 1 to hide the large file preview, 0 to show it.

Send at least one setting besides portal_id, otherwise the request is rejected.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=PORTAL_ID" \
  --data-urlencode "timezone=America/Phoenix" \
  --data-urlencode "language=en" \
  --data-urlencode "portal_search=1" \
  'https://ahsuite.com/api/portal/settings/preferences/update'
$ch = curl_init();
$data = [
    'portal_id' => 'PORTAL_ID',
    'timezone' => 'America/Phoenix',
    'language' => 'en',
    'portal_search' => 1,
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/settings/preferences/update');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({
  portal_id: 'PORTAL_ID',
  timezone: 'America/Phoenix',
  language: 'en',
  portal_search: '1',
});

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/settings/preferences/update', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/settings/preferences/update',
    auth=('EMAIL', 'TOKEN'),
    data={
        'portal_id': 'PORTAL_ID',
        'timezone': 'America/Phoenix',
        'language': 'en',
        'portal_search': 1,
    },
)
print(res.json())
Response
{
  "success": true,
  "message": "Portal preferences have been updated successfully.",
  "data": {
    "portal_id": "vJW4"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"timezone\":[\"The timezone must be a valid zone.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

Update Sidebar Menu

Control which tools appear in a portal's sidebar, what they are called, the order they sit in, and any custom tabs. This is a partial update: only the parameters you send are changed, every other portal setting is left untouched.

POST https://ahsuite.com/api/portal/settings/sidebar/update
Query parameters
portal_id required
string
Unique Portal ID. See example →
show_* Show tabs
show_pages
integer
Set 1 to show the Pages tab, 0 to hide it.
show_files
integer
Set 1 to show the File Manager tab, 0 to hide it.
show_password
integer
Set 1 to show the Password Manager tab, 0 to hide it.
show_tasks
integer
Set 1 to show the To Do tab, 0 to hide it.
show_conversations
integer
Set 1 to show the Conversations tab, 0 to hide it.
show_projects
integer
Set 1 to show the Projects tab, 0 to hide it.
label_* Rename tabs
label_files
string
Custom name for the File Manager tab. Max 50 characters.
label_password
string
Custom name for the Password Manager tab. Max 50 characters.
label_tasks
string
Custom name for the To Do tab. Max 50 characters.
label_conversations
string
Custom name for the Conversations tab. Max 50 characters.
label_projects
string
Custom name for the Projects tab. Max 50 characters.
links Custom tabs
links[0][name]
string
The custom tab label. Max 25 characters. Required when links[0][url] is sent. Sent tabs are added to the existing list; a tab with the same name is updated instead of duplicated. Existing tabs are never removed by the API (remove them from the portal settings page). Max 20 tabs per portal.
links[0][url]
string
The URL the custom tab opens. Required when links[0][name] is sent.
links[0][same]
integer
Set 1 to open the link in the same tab. Default 0, which opens a new tab.
order[]
array
Sidebar tool order, first item on top. Values: pages, tasks, conversations, files, password, projects. Tools you leave out keep their current position after the ones you send. The portal opens on the first visible tool.

Unlike the other parameters, sending links replaces every custom tab on the portal — send the full set you want to keep. Leave links out entirely and the existing custom tabs are untouched. A maximum of 20 custom tabs is allowed.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=PORTAL_ID" \
  --data-urlencode "show_pages=1" \
  --data-urlencode "show_password=0" \
  --data-urlencode "label_tasks=Action Items" \
  --data-urlencode "links[0][name]=Our Website" \
  --data-urlencode "links[0][url]=https://example.com" \
  'https://ahsuite.com/api/portal/settings/sidebar/update'
$ch = curl_init();
$data = [
    'portal_id' => 'PORTAL_ID',
    'show_pages' => 1,
    'show_password' => 0,
    'label_tasks' => 'Action Items',
    'links' => [
        ['name' => 'Our Website', 'url' => 'https://example.com', 'same' => 0],
    ],
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/settings/sidebar/update');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({
  portal_id: 'PORTAL_ID',
  show_pages: '1',
  show_password: '0',
  label_tasks: 'Action Items',
  'links[0][name]': 'Our Website',
  'links[0][url]': 'https://example.com',
});

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/settings/sidebar/update', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/settings/sidebar/update',
    auth=('EMAIL', 'TOKEN'),
    data={
        'portal_id': 'PORTAL_ID',
        'show_pages': 1,
        'show_password': 0,
        'label_tasks': 'Action Items',
        'links[0][name]': 'Our Website',
        'links[0][url]': 'https://example.com',
    },
)
print(res.json())
Response
{
  "success": true,
  "message": "Portal sidebar menu has been updated successfully.",
  "data": {
    "portal_id": "vJW4"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"show_pages\":[\"The selected show pages is invalid.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

Update Account Manager Details

Control the account manager card shown on the portal dashboard — which details appear, what they say, and the action buttons beneath them. This is a partial update: only the parameters you send are changed, every other portal setting is left untouched.

POST https://ahsuite.com/api/portal/settings/account-manager/update
Query parameters
portal_id required
string
Unique Portal ID. See example →
avatar_source
string
What to show as the avatar. Either agency_logo or profile_picture.
show_* Show details
show_card
integer
Set 1 to show the account manager card at all, 0 to hide it. When hidden, the settings below have no visible effect.
show_avatar
integer
Set 1 to show the avatar, 0 to hide it.
show_name
integer
Set 1 to show the name, 0 to hide it.
show_title
integer
Set 1 to show the job title, 0 to hide it.
show_email
integer
Set 1 to show the email address, 0 to hide it.
show_phone
integer
Set 1 to show the phone number, 0 to hide it.
text Override the text
display_name
string
Name shown on the card. Max 100 characters.
title
string
Job title shown on the card. Max 100 characters.
email
string
Email address shown on the card. Max 100 characters.
phone
string
Phone number shown on the card. Max 100 characters.

Every parameter in this group may be sent empty to clear it. A cleared value falls back to the account manager's own details.

action_buttons Action buttons
Request button
action_buttons[request][enabled]
integer
Set 1 to enable the request button, 0 to disable it.
action_buttons[request][text]
string
Request button text. Max 100 characters.
Schedule button
action_buttons[schedule][enabled]
integer
Set 1 to enable the schedule button, 0 to disable it.
action_buttons[schedule][text]
string
Schedule button text. Max 100 characters.
action_buttons[schedule][calendar_link]
string
Your calendar booking link — Calendly, Acuity, or any other scheduling tool. Clients who click the schedule button are taken here to book a meeting. Max 250 characters.
action_buttons[schedule][open_in_iframe]
integer
Set 1 to open the calendar link inside the portal, 0 to open it in a new tab.
Send a message button
action_buttons[message][enabled]
integer
Set 1 to enable the send a message button, 0 to disable it. This button needs the Conversations tab to be present in the portal sidebar.
action_buttons[message][text]
string
Send a message button text. Max 100 characters.
Primary button
action_buttons[primary]
string
Which action to emphasize — the primary button is highlighted in blue and appears first. One of request, schedule or message.

Send at least one setting besides portal_id, otherwise the request is rejected.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=PORTAL_ID" \
  --data-urlencode "show_card=1" \
  --data-urlencode "show_phone=0" \
  --data-urlencode "display_name=Priya Sharma" \
  --data-urlencode "title=Account Executive" \
  --data-urlencode "action_buttons[schedule][enabled]=1" \
  --data-urlencode "action_buttons[schedule][text]=Schedule a Call" \
  --data-urlencode "action_buttons[schedule][calendar_link]=https://calendly.com/priya" \
  --data-urlencode "action_buttons[primary]=schedule" \
  'https://ahsuite.com/api/portal/settings/account-manager/update'
$ch = curl_init();
$data = [
    'portal_id' => 'PORTAL_ID',
    'show_card' => 1,
    'show_phone' => 0,
    'display_name' => 'Priya Sharma',
    'title' => 'Account Executive',
    'action_buttons' => [
        'schedule' => [
            'enabled' => 1,
            'text' => 'Schedule a Call',
            'calendar_link' => 'https://calendly.com/priya',
        ],
        'primary' => 'schedule',
    ],
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/settings/account-manager/update');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({
  portal_id: 'PORTAL_ID',
  show_card: '1',
  show_phone: '0',
  display_name: 'Priya Sharma',
  title: 'Account Executive',
  'action_buttons[schedule][enabled]': '1',
  'action_buttons[schedule][text]': 'Schedule a Call',
  'action_buttons[schedule][calendar_link]': 'https://calendly.com/priya',
  'action_buttons[primary]': 'schedule',
});

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/settings/account-manager/update', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/settings/account-manager/update',
    auth=('EMAIL', 'TOKEN'),
    data={
        'portal_id': 'PORTAL_ID',
        'show_card': 1,
        'show_phone': 0,
        'display_name': 'Priya Sharma',
        'title': 'Account Executive',
        'action_buttons[schedule][enabled]': 1,
        'action_buttons[schedule][text]': 'Schedule a Call',
        'action_buttons[schedule][calendar_link]': 'https://calendly.com/priya',
        'action_buttons[primary]': 'schedule',
    },
)
print(res.json())
Response
{
  "success": true,
  "message": "Portal account manager details have been updated successfully.",
  "data": {
    "portal_id": "vJW4"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"action_buttons.primary\":[\"The selected action buttons.primary is invalid.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

Update Client User Settings

Set the default access and notification preferences that new client users get when they are added to this portal. Existing client users are not changed — to set permissions for one contact, use Create Contact. This is a partial update: only the parameters you send are changed.

POST https://ahsuite.com/api/portal/settings/client-user/update
Query parameters
portal_id required
string
Unique Portal ID. See example →
allow_add_portal_users
integer
Set 1 to enable client users to add or remove other client users to the portal, 0 to disable it.
email_receive
integer
Set 1 to enable client users to receive notifications and updates by email, 0 to disable it.
allow_page_add
integer
Set 1 to enable client users to manage portal content, 0 to disable it.

Send at least one setting besides portal_id, otherwise the request is rejected.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=PORTAL_ID" \
  --data-urlencode "email_receive=1" \
  --data-urlencode "allow_page_add=0" \
  'https://ahsuite.com/api/portal/settings/client-user/update'
$ch = curl_init();
$data = [
    'portal_id' => 'PORTAL_ID',
    'email_receive' => 1,
    'allow_page_add' => 0,
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/settings/client-user/update');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({
  portal_id: 'PORTAL_ID',
  email_receive: '1',
  allow_page_add: '0',
});

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/settings/client-user/update', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/settings/client-user/update',
    auth=('EMAIL', 'TOKEN'),
    data={
        'portal_id': 'PORTAL_ID',
        'email_receive': 1,
        'allow_page_add': 0,
    },
)
print(res.json())
Response
{
  "success": true,
  "message": "Portal client user settings have been updated successfully.",
  "data": {
    "portal_id": "vJW4"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "No settings were sent to update."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

Update Portal Branding

Update the portal's branding — logo, custom CSS and website. This is a partial update: only the parameters you send are changed, everything else keeps its current value.

POST https://ahsuite.com/api/portal/settings/branding/update
Query parameters
portal_id required
string
Unique Portal ID. See example →
logo
file
Portal logo image, sent as a multipart file upload. Replaces the current logo. Allowed types: jpg, png, jpeg, gif, svg, webp. Max size 5 MB.
custom_css
string
Custom CSS applied to the client portal. Send the rules only, without <style> tags. Send an empty value to clear it. Max 50000 characters.
website
string
The client's website address. Send an empty value to clear it. Max 50 characters.

Send at least one setting besides portal_id, otherwise the request is rejected.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  -F "portal_id=PORTAL_ID" \
  -F "logo=@/path/to/logo.png" \
  -F "custom_css=.portal-header { background: #1a1a2e; }" \
  -F "website=https://client-website.com" \
  'https://ahsuite.com/api/portal/settings/branding/update'
$ch = curl_init();
$data = [
    'portal_id' => 'PORTAL_ID',
    'logo' => new CURLFile('/path/to/logo.png'),
    'custom_css' => '.portal-header { background: #1a1a2e; }',
    'website' => 'https://client-website.com',
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/settings/branding/update');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';
import FormData from 'form-data';
import fs from 'fs';

const form = new FormData();
form.append('portal_id', 'PORTAL_ID');
form.append('logo', fs.createReadStream('/path/to/logo.png'));
form.append('custom_css', '.portal-header { background: #1a1a2e; }');
form.append('website', 'https://client-website.com');

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/settings/branding/update', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body: form,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/settings/branding/update',
    auth=('EMAIL', 'TOKEN'),
    data={
        'portal_id': 'PORTAL_ID',
        'custom_css': '.portal-header { background: #1a1a2e; }',
        'website': 'https://client-website.com',
    },
    files={'logo': open('/path/to/logo.png', 'rb')},
)
print(res.json())
Response
{
  "success": true,
  "message": "Portal branding has been updated successfully.",
  "data": {
    "portal_id": "vJW4"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "No settings were sent to update."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

Update Email Notifications

Set the summary email frequency and customize the emails the portal sends to client users. This is a partial update: only the parameters you send are changed. Sending an empty value clears the custom text, and the default template is used again.

POST https://ahsuite.com/api/portal/settings/email-notifications/update
Query parameters
portal_id required
string
Unique Portal ID. See example →
summary_email_frequency
string
How often client users receive the summary email. One of off, daily, weekly (each Monday) or monthly (on the 1st).
emails[invitation] Invitation email
emails[invitation][subject]
string
Email subject. Max 250 characters.
emails[invitation][body]
string
Email body text. Max 2000 characters.
emails[task_completed] Task completed email
emails[task_completed][subject]
string
Email subject. Max 250 characters.
emails[task_completed][body]
string
Email body text. Max 2000 characters.
emails[task_completed][button_text]
string
Text on the email's action button. Max 50 characters.
emails[action_required] Action required email
emails[action_required][subject]
string
Email subject. Max 250 characters.
emails[action_required][body]
string
Email body text. Max 2000 characters.
emails[action_required][button_text]
string
Text on the email's action button. Max 50 characters.
emails[file_requested] File requested email
emails[file_requested][subject]
string
Email subject. Max 250 characters.
emails[file_requested][body]
string
Email body text. Max 2000 characters.
emails[file_requested][button_text]
string
Text on the email's action button. Max 50 characters.
emails[needs_attention] Needs attention (summary) email
emails[needs_attention][subject]
string
Email subject. Max 250 characters.
emails[needs_attention][body][main_heading]
string
Text shown at the top of the summary email. Max 400 characters.
emails[needs_attention][body][task_heading]
string
Heading shown above the open tasks list. Max 400 characters.
emails[needs_attention][body][notification_heading]
string
Heading shown above the notifications list. Max 400 characters.
emails[needs_attention][body][last_heading]
string
Text shown at the bottom of the summary email. Max 400 characters.
emails[needs_attention][button_text]
string
Text on the email's action button. Max 50 characters.

Send at least one setting besides portal_id, otherwise the request is rejected.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=PORTAL_ID" \
  --data-urlencode "summary_email_frequency=weekly" \
  --data-urlencode "emails[invitation][subject]=You are invited to your client portal" \
  --data-urlencode "emails[invitation][body]=Hi, your portal is ready. Click below to log in." \
  --data-urlencode "emails[needs_attention][body][task_heading]=Tasks waiting for you" \
  'https://ahsuite.com/api/portal/settings/email-notifications/update'
$ch = curl_init();
$data = [
    'portal_id' => 'PORTAL_ID',
    'summary_email_frequency' => 'weekly',
    'emails' => [
        'invitation' => [
            'subject' => 'You are invited to your client portal',
            'body' => 'Hi, your portal is ready. Click below to log in.',
        ],
        'needs_attention' => [
            'body' => ['task_heading' => 'Tasks waiting for you'],
        ],
    ],
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/settings/email-notifications/update');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({
  portal_id: 'PORTAL_ID',
  summary_email_frequency: 'weekly',
  'emails[invitation][subject]': 'You are invited to your client portal',
  'emails[invitation][body]': 'Hi, your portal is ready. Click below to log in.',
  'emails[needs_attention][body][task_heading]': 'Tasks waiting for you',
});

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/settings/email-notifications/update', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/settings/email-notifications/update',
    auth=('EMAIL', 'TOKEN'),
    data={
        'portal_id': 'PORTAL_ID',
        'summary_email_frequency': 'weekly',
        'emails[invitation][subject]': 'You are invited to your client portal',
        'emails[invitation][body]': 'Hi, your portal is ready. Click below to log in.',
        'emails[needs_attention][body][task_heading]': 'Tasks waiting for you',
    },
)
print(res.json())
Response
{
  "success": true,
  "message": "Portal email notification settings have been updated successfully.",
  "data": {
    "portal_id": "vJW4"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "No settings were sent to update."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

Update Custom Variables

Add or update the portal's custom variables. A variable is identified by its name and scope together — sending an existing combination updates that variable's value, a new combination creates it. Variables can also be created when a portal is created (Add Portal) or through page questions (Create Page).

POST https://ahsuite.com/api/portal/settings/variables/update
Query parameters
portal_id required
string
Unique Portal ID. See example →
variables Add or update variables (max 20 per request)
variables[0][name] required
string
Variable name, e.g. address. Together with scope it identifies the variable. Max 255 characters.
variables[0][scope]
string
One of portal (default) or user. A portal variable holds one value for the whole portal; a user variable can hold a different value per client user.
variables[0][value]
string
The variable's value. Only for portal scope — a user variable holds its values per client user, so sending a value with user scope is rejected. Send an empty value to clear it. Max 500 characters.

Send at least one variable besides portal_id, otherwise the request is rejected.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "portal_id=PORTAL_ID" \
  --data-urlencode "variables[0][name]=address" \
  --data-urlencode "variables[0][scope]=portal" \
  --data-urlencode "variables[0][value]=221B Baker Street" \
  --data-urlencode "variables[1][name]=phone" \
  --data-urlencode "variables[1][scope]=user" \
  'https://ahsuite.com/api/portal/settings/variables/update'
$ch = curl_init();
$data = [
    'portal_id' => 'PORTAL_ID',
    'variables' => [
        ['name' => 'address', 'scope' => 'portal', 'value' => '221B Baker Street'],
        ['name' => 'phone', 'scope' => 'user'],
    ],
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/portal/settings/variables/update');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams({
  portal_id: 'PORTAL_ID',
  'variables[0][name]': 'address',
  'variables[0][scope]': 'portal',
  'variables[0][value]': '221B Baker Street',
  'variables[1][name]': 'phone',
  'variables[1][scope]': 'user',
});

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/portal/settings/variables/update', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/portal/settings/variables/update',
    auth=('EMAIL', 'TOKEN'),
    data={
        'portal_id': 'PORTAL_ID',
        'variables[0][name]': 'address',
        'variables[0][scope]': 'portal',
        'variables[0][value]': '221B Baker Street',
        'variables[1][name]': 'phone',
        'variables[1][scope]': 'user',
    },
)
print(res.json())
Response
{
  "success": true,
  "message": "Portal variables have been updated successfully.",
  "data": {
    "portal_id": "vJW4"
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "No settings were sent to update."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Portal record not found."
}

List Notifications

Return your own notifications, newest first — the same list as the Notifications page in the app (task assigned or done, mentions, file approved, page answered, invoice paid and so on). Notifications belong to the API user, not to one portal, so this endpoint takes no portal_id. Send unread_only=1 to get only the unread ones. When a notification belongs to a portal you own, the row also carries the portal_id and the ID of the related task, file, page, conversation or journal, so you can open it with the detail endpoints.

GET https://ahsuite.com/api/notifications
Query parameters
unread_only
integer
1 returns only unread notifications. 0 or omitted returns all, read and unread.
page
integer
Page number to return. Defaults to 1. Each page holds 20 notifications.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request GET \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/notifications?unread_only=1&page=1'
$params = http_build_query([
    'unread_only' => 1,
    'page' => 1,
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/notifications?'.$params);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const params = new URLSearchParams({ unread_only: '1', page: '1' });
const res = await fetch(`https://ahsuite.com/api/notifications?${params}`, {
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.get(
    'https://ahsuite.com/api/notifications',
    params={'unread_only': 1, 'page': 1},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "",
  "data": {
    "notifications": [
      {
        "text": "You were assigned Send the June report",
        "is_read": 0,
        "portal": "Acme Inc",
        "portal_id": "vJW4",
        "created_date": "2026-06-10T09:15:02-07:00",
        "notification_id": 130,
        "task_id": 3659
      },
      {
        "text": "Signed contract was approved by Sam Lee (Client)",
        "is_read": 0,
        "portal": "Acme Inc",
        "portal_id": "vJW4",
        "created_date": "2026-06-09T17:40:11-07:00",
        "notification_id": 129,
        "file_id": "ZrV1"
      }
    ],
    "unread_count": 12,
    "pagination": {
      "total": 12,
      "page": 1,
      "limit": 20,
      "total_pages": 1
    }
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "{\"page\":[\"The page must be at least 1.\"]}"
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}

Mark Notifications Read

Mark your notifications as read, the same as opening a notification or using Mark all read in the app. Send either notification_ids[] for specific notifications or all=1 for every unread one, not both. Only your own notifications can be changed. The response returns how many were marked and your new unread count.

POST https://ahsuite.com/api/notifications/read
Body parameters
notification_ids[]
array
The notifications to mark read, one notification_id per entry (from List Notifications), up to 50. An ID that is not yours returns 404 and nothing is changed.
all
integer
1 marks every unread notification as read. Do not send it together with notification_ids[].

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request POST \
  -u "EMAIL:TOKEN" \
  --data-urlencode "notification_ids[]=130" \
  --data-urlencode "notification_ids[]=129" \
  'https://ahsuite.com/api/notifications/read'
$ch = curl_init();
$data = [
    'notification_ids' => [130, 129],
];
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/notifications/read');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const body = new URLSearchParams();
body.append('notification_ids[]', '130');
body.append('notification_ids[]', '129');

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/notifications/read', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}` },
  body,
});
console.log(await res.json());
import requests

res = requests.post(
    'https://ahsuite.com/api/notifications/read',
    data={'notification_ids[]': [130, 129]},
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "Notifications have been marked as read.",
  "data": {
    "marked_read": 2,
    "unread_count": 10
  }
}
{
  "success": false,
  "message": "Validation Error",
  "data": "Send exactly one of: notification_ids or all=1."
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}
{
  "success": false,
  "message": "Not Found",
  "data": "Notification record not found: 999"
}

List Timezones

Return every timezone identifier accepted by the timezone parameter. Use this to build a picker or to validate a value before saving it — anything in this list is guaranteed to be accepted.

GET https://ahsuite.com/api/timezones
Query parameters

None. This endpoint takes no parameters.

Other unsuccessful response codes: 403, 429, 500.

Request
curl --request GET \
  -u "EMAIL:TOKEN" \
  'https://ahsuite.com/api/timezones'
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://ahsuite.com/api/timezones');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");

$result = curl_exec($ch);
import fetch from 'node-fetch';

const auth = Buffer.from('EMAIL:TOKEN').toString('base64');
const res = await fetch('https://ahsuite.com/api/timezones', {
  headers: { Authorization: `Basic ${auth}` },
});
console.log(await res.json());
import requests

res = requests.get(
    'https://ahsuite.com/api/timezones',
    auth=('EMAIL', 'TOKEN'),
)
print(res.json())
Response
{
  "success": true,
  "message": "",
  "data": [
    "Africa/Cairo",
    "America/Phoenix",
    "Asia/Kolkata",
    "Europe/London",
    "..."
  ]
}
{
  "success": false,
  "message": "Invalid owner email and authorization_token."
}

MCP tools

The 71 tools the Ahsuite MCP server (https://ahsuite.com/api/mcp) offers to a connected AI app, exactly as the server describes them. read-only tools only read data; write tools create or change data; confirm tools overwrite, remove or revoke something, so the AI app asks you first; may email client tools can also email or notify your client users. No tool ever accepts or returns a password.

List portals list_portals read-only

List the client portals of the authenticated Ahsuite account, newest first. Call this first to get a portal_id for the other tools. Supports name search and pagination.

search
string
Filter by portal name (contains match)
page
integer
Page number, default 1
limit
integer
Rows per page, default 20, max 100
Get portal settings get_portal read-only

Get the full details of a single portal — name, contact info, language, timezone, preferences, plus nested sidebar (tools, labels, order, custom links), client_user defaults, account_manager card, branding, email_notifications and variables. Field names match the update_portal_* tools, so read here and send changed values back.

portal_id required
string
Portal ID from list_portals
Reply in a conversation reply_to_conversation may email client

Post a reply in an existing conversation, exactly like replying in the app: the team is notified and, when the conversation is shared with the client, the client is notified and emailed (unless emails are turned off for that conversation). Call get_conversation first to read the thread. Text only here; attachments are added in the Ahsuite app or with the Ahsuite API.

portal_id required
string
Portal ID from list_portals
conversation_id required
integer
Conversation ID from list_conversations / get_conversation
message required
string
The reply text, max 4100 characters (basic HTML allowed: p, br, strong, em, ul, ol, li, a)
Update a conversation update_conversation confirm

Rename a conversation and/or turn its email notifications on or off — merge-only. email_not_send = 1 stops emailing the client about this conversation (they still see it in the portal); on HIPAA portals emails are always off and cannot be turned on.

portal_id required
string
Portal ID from list_portals
conversation_id required
integer
Conversation ID from list_conversations
name
string
New conversation name, max 200 chars
email_not_send
integer
1 = do not email the client about this conversation, 0 = email them One of: 0, 1.
List tasks list_tasks read-only

List the tasks of a portal, in the same order the Ahsuite app shows them (tasks being worked on first). Use this when the user asks about tasks, to-dos, pending work or deadlines in a portal. By default every status except canceled is returned. When showing tasks to the user, display statuses in a friendly form (ready_for_work = "Ready for Work", in_progress = "In Progress", on_hold = "On Hold", done = "Done").

portal_id required
string
Portal ID from list_portals
status
string
Return only tasks with this status One of: ready_for_work, pending, in_progress, on_hold, done, canceled.
search
string
Filter by task name (contains match)
page
integer
Page number, default 1
limit
integer
Rows per page, default 20, max 100
List conversations list_conversations read-only

List the conversations (message threads with the client) of a portal, most recently updated first. new_message=1 means it has unread replies. Use get_conversation to read the messages inside one.

portal_id required
string
Portal ID from list_portals
search
string
Filter by conversation name (contains match)
page
integer
Page number, default 1
limit
integer
Rows per page, default 20, max 100
Read a conversation get_conversation read-only

Read a single conversation — its share settings and the last 25 messages of the thread (oldest of those first). Each message says whether it came from the team or the client.

portal_id required
string
Portal ID from list_portals
conversation_id required
integer
Conversation ID from list_conversations
List pages list_pages read-only

List the pages (content/dashboard pages built for the client) of a portal, grouped by category in the same order as the Ahsuite app. draft=1 means the page is not visible to the client yet.

portal_id required
string
Portal ID from list_portals
search
string
Filter by page title (contains match)
page
integer
Page number, default 1
limit
integer
Rows per page, default 20, max 100
Get page content get_page read-only

Get one page of a portal with its content — title, category, draft flag and its tabs. Each tab returns its text block (text.content is HTML) embed block (embed.url = embedded url or embed code, embed.height) image block (image.url = temporary link valid 20 minutes, image.file_name) and files block (files.items[] = the files/file requests placed on the tab: type = file|url, file = uploaded file, file_name = its display name, url = the link or a 20-minute temporary download link, title + description for file requests, is_request, file_id) video block (video.url = YouTube url), code block (code.content = raw HTML/CSS/JS) and questions block (questions.items[] = the form questions: question_type, question_name, description, required, options, variable_name/variable_scope, question_id; section/repeat_section containers nest their child questions in questions[]), each with optional heading/description, or null when the tab has no such block. Use this to read a page before describing or editing it. page_id comes from list_pages.

portal_id required
string
Portal ID from list_portals
page_id required
string
Page ID from list_pages
Update a page update_page confirm

Use this to change an existing page in a client portal. Call get_page first to get the page's tab_id values and current content. Only the fields you send change. At page level you can change the title, the category, and whether the page is a draft (hidden from the client); publishing a draft makes it visible to the client. With a tab_id you can rename the tab and edit or add its blocks: text, code, embed, video, image heading and description, file requests or existing files, and new form questions. Text and code content replace what is there; file items and form questions are added, never removed. To add a new tab, use add_tab. Image and file uploads are done in the Ahsuite app.

portal_id required
string
Portal ID from list_portals
page_id required
string
Page ID from list_pages / get_page
tab_id
string
Tab to edit, from get_page tabs[].tab_id — required when sending a content block
tab_title
string
Rename the tab given by tab_id, max 100 chars
title
string
New page title, max 50 chars
draft
integer
1 = draft (hidden from the client), 0 = published One of: 0, 1.
category
string
Category name, max 20 chars. Created in the portal if it does not exist yet; empty string removes the page from its category
text
object
Text block of the tab (partial) Fields: enabled, content, heading, description.
code
object
Code block of the tab (partial) Fields: enabled, content, heading, description.
video
object
Video block of the tab (partial) Fields: enabled, url, heading, description.
image
object
Image block of the tab (partial) — text fields only; the image file is uploaded/replaced in the Ahsuite app or with the Ahsuite API Fields: enabled, heading, description.
files
object
Files block of the tab (partial). items[] APPENDS to the tab — existing files are kept Fields: enabled, heading, description, items.
questions
object
Form block of the tab (partial). items[] APPENDS new questions — existing questions are kept as they are (editing/deleting is not available yet) Fields: enabled, heading, description, items.
embed
object
Embed block of the tab (partial) Fields: enabled, url, height, heading, description.
Add a page tab add_tab write

Add a new (empty) tab to a page, like "+ Add Tab" in the page editor. Returns the tab_id — then fill it with update_page (text, embed, video, files, questions...). Max 20 tabs per page. Use this when the user wants content on a tab that does not exist yet.

portal_id required
string
Portal ID from list_portals
page_id required
string
Page ID from list_pages / get_page
title
string
Tab name shown to the client, max 100 chars. Default "Tab N"
Edit a form question update_question confirm

Edit one form question of a page — merge-only: only the fields you send change. Only the question text, its description and whether it is required can be changed (as in the app); the type, options and variable are fixed once created — to change those, add a new question with update_page and delete the old one. Call get_page first to get the question_id.

portal_id required
string
Portal ID from list_portals
page_id required
string
Page ID the question belongs to
question_id required
string
Question ID from get_page (tabs[].questions.items[].question_id)
question_name
string
New question text, max 500
description
string
Help text under the question, max 500; empty string clears it
required
integer
1 = answer required One of: 0, 1.
Move a form question move_question write

Move an existing question of a page into a section / repeat_section on the same tab (like "Add Question" inside a section in the app), or out of its section back to the top level (section_id = empty string, like "Move Out"). Sections themselves and questions with conditional navigation cannot be moved into a section. Call get_page first for the ids. To create a NEW question directly inside a section use update_page with section_id instead.

portal_id required
string
Portal ID from list_portals
page_id required
string
Page ID the question belongs to
question_id required
string
The question to move, from get_page
section_id required
string
question_id of the target section / repeat_section on the same tab; empty string moves the question out of its section
Delete a form question delete_question confirm

Delete one form question from a page. DESTRUCTIVE and cannot be undone — ALWAYS call get_page first, show the user the exact question text you are about to delete, and ask for explicit confirmation before calling this tool. Never delete more than the user named. Deleting a section or repeat_section also deletes the questions inside it. Answers the client already gave to the question stay available in the form responses.

portal_id required
string
Portal ID from list_portals
page_id required
string
Page ID the question belongs to (from list_pages / get_page)
question_id required
string
Question ID from get_page (tabs[].questions.items[].question_id) — must be on that page
Get task details get_task read-only

Get one task of a portal in full — name, description (HTML), status, due date, assignee, project, checklist items, labels, attachments (temporary links valid 15 minutes) and comments (newest first). Use this to read a task before describing or editing it. task_id comes from list_tasks.

portal_id required
string
Portal ID from list_portals
task_id required
integer
Task ID from list_tasks
Update a task update_task may email client

Update an existing task — merge-only: only the fields you send change. Call get_task first. Fields: name, description (HTML), due_date, status, dependency_task_id (this task waits on another task — it becomes "pending" until that task is done; 0 removes the dependency) and project_id (from list_projects; 0 takes the task out of its project). To change who the task is assigned to use assign_task; to add labels use add_task_labels. Sending an empty string clears description / due_date. Setting status to done notifies the team (and the client when they created the task) and releases tasks that were waiting on this one.

portal_id required
string
Portal ID from list_portals
task_id required
integer
Task ID from list_tasks / get_task
name
string
New task name, max 200 chars
description
string
Task description (HTML allowed: p, br, strong, em, ul, ol, li, a), max 5000. Replaces the current description — read it with get_task first if you only want to change part of it
due_date
string
Due date YYYY-MM-DD; empty string removes the due date
status
string
New status (same values list_tasks returns) One of: ready_for_work, pending, in_progress, on_hold, done, canceled.
dependency_task_id
integer
task_id of another open task in this portal that must be done first; 0 removes the dependency
project_id
integer
project_id from list_projects to place the task in; 0 removes it from its project
Assign a task assign_task may email client

Assign a task to a team member, to the client (any client user) or to one specific client user. Assigning to the client / a client user shares the task with them and sends the "action required" email; assigning to a team member notifies them. Behaves exactly like the assignee dropdown in the app. A task created by the client always stays shared with all client users — team_member adds a team member to it, while client / client_user are rejected for such a task. Get client user emails from list_contacts.

portal_id required
string
Portal ID from list_portals
task_id required
integer
Task ID from list_tasks / get_task
assign_to required
string
team_member = a member of the portal team (email required); client = any client user of the portal; client_user = one specific client user (email required) One of: team_member, client, client_user.
email
string
Email of the team member or client user (not needed for assign_to = client)
Label a task add_task_labels write

Add one or more labels to a task (like "Add Label" in the app). A label with the same name and color already in the portal is reused, otherwise it is created. Existing labels on the task are kept. Max 5 per call.

portal_id required
string
Portal ID from list_portals
task_id required
integer
Task ID from list_tasks / get_task
labels required
array of object
Add checklist items add_checklist_items write

Add checklist items to a task (like "Add item" on the task screen). Existing items are kept. Returns the new items with their item_id. Max 20 per call.

portal_id required
string
Portal ID from list_portals
task_id required
integer
Task ID from list_tasks / get_task
checklist required
array of string
Item texts to add, each max 2000 characters
Update a checklist item update_checklist_item confirm

Rename a checklist item and/or mark it completed / not completed — merge-only, only the fields you send change. item_id comes from get_task (checklist[].item_id) or add_checklist_items.

portal_id required
string
Portal ID from list_portals
task_id required
integer
Task ID the item belongs to
item_id required
integer
Checklist item ID from get_task
name
string
New item text, max 2000 characters
completed
integer
1 = mark completed, 0 = mark not completed One of: 0, 1.
Comment on a task add_task_comment may email client

Post a comment on a task, exactly like commenting on the task screen: the comment is written to the task history and the people involved are notified (a client user is notified when the task is shared with them). Plain text; basic HTML (p, br, strong, em, ul, ol, li, a) is allowed.

portal_id required
string
Portal ID from list_portals
task_id required
integer
Task ID from list_tasks / get_task
comment required
string
The comment text, max 2000 characters
Get file details get_file read-only

Get one file (or folder / file request) of a portal in full — title, description, status, sharing (is_folder / is_request flags), its versions (newest first, each with a link valid 2 hours), comments and labels. For a folder only its own details and items_count are returned — use list_files with folder_id to see what is inside. file_id comes from list_files.

portal_id required
string
Portal ID from list_portals
file_id required
string
File ID from list_files
Update a file update_file may email client

Update a file, file request or folder — merge-only: only the fields you send change. title, description and require_client_approval (files only). Turning require_client_approval on shares the file with the client (and its folder, if any) and EMAILS them asking to review it (or to upload it, for a file request), exactly like the app; it cannot be changed on a file the client already approved. Call get_file first.

portal_id required
string
Portal ID from list_portals
file_id required
string
File ID from list_files / get_file
title
string
New title, max 100 chars
description
string
Description shown under the file (basic HTML allowed); empty string clears it
require_client_approval
integer
1 = the client must review/approve this file (shares it with them), 0 = no approval needed. On a file request this is the "Request File" toggle (1 = ask the client to upload it) One of: 0, 1.
Add a file version add_file_version write

Add a new version to a file as a link (url) — like "Add a file" on the file screen. Older versions are kept; if the client had already approved the file it goes back to "approval required". Uploading a binary file as a version is done in the Ahsuite app or with the Ahsuite API: https://ahsuite.com/api-docs. Max 70 versions per file.

portal_id required
string
Portal ID from list_portals
file_id required
string
File ID from list_files / get_file (not a folder)
url required
string
The link to add as the newest version (Google Drive, Dropbox, any https url), max 1000 chars
Comment on a file add_file_comment may email client

Post a comment on a file or file request, exactly like commenting on the file screen: the comment is written to the file history and the people involved are notified (client users are notified when the file is shared with them). Plain text; basic HTML (p, br, strong, em, ul, ol, li, a) is allowed.

portal_id required
string
Portal ID from list_portals
file_id required
string
File ID from list_files / get_file (not a folder)
comment required
string
The comment text, max 2000 characters
Move a file move_file confirm

Move a file (not a folder) into a folder of the same portal, or back to the top level with an empty folder_id — like drag-and-drop in the app. The file takes over the sharing of the folder it lands in.

portal_id required
string
Portal ID from list_portals
file_id required
string
File ID from list_files / get_file
folder_id required
string
Target folder ID from list_files (is_folder = 1); empty string moves the file to the root
Label a file add_file_labels write

Add one or more labels to a file or file request (like "Add Label" on the file screen). A label with the same name and color already in the portal is reused, otherwise it is created. Existing labels on the file are kept. Max 5 per call.

portal_id required
string
Portal ID from list_portals
file_id required
string
File ID from list_files / get_file (not a folder)
labels required
array of object
List files list_files read-only

List the files of a portal — uploads, links, file requests and folders (folders first). Root level by default; pass folder_id (the file_id of a folder row) to list inside a folder. status shows client approval state, share_with_client whether the client can see it. When showing files to the user, display statuses in a friendly form (client_approval_required = "Client Approval Required", request_file = "Requested", file_submitted = "Submitted").

portal_id required
string
Portal ID from list_portals
folder_id
string
file_id of a folder row — lists that folder's contents
search
string
Filter by file title (contains match)
page
integer
Page number, default 1
limit
integer
Rows per page, default 20, max 100
List journals list_journals read-only

List the work journals of a portal, newest first. Journals are internal work logs the team keeps per client.

portal_id required
string
Portal ID from list_portals
search
string
Filter by journal name (contains match)
page
integer
Page number, default 1
limit
integer
Rows per page, default 20, max 100
Read a journal get_journal read-only

Get one work journal: name, created date and its newest 25 log entries (newest first, HTML) with author and date. Use list_journals to find the journal_id.

portal_id required
string
Portal ID from list_portals
journal_id required
integer
Journal ID from list_journals
Rename a journal update_journal confirm

Rename a work journal. The name is the only editable field of a journal; log entries are added with add_journal_log.

portal_id required
string
Portal ID from list_portals
journal_id required
integer
Journal ID from list_journals
name required
string
New journal name, max 255 characters
Write a journal entry add_journal_log write

Add a log entry to a work journal (like "Save Log" in the journal popup). Text only; @name mentions of team members notify them. The api owner is recorded as the author.

portal_id required
string
Portal ID from list_portals
journal_id required
integer
Journal ID from list_journals
log required
string
The log text, max 2000 characters
Create a task create_task may email client

Create ONE new task in a portal. Call once per task — a successful call means the task exists, do not retry it. By default the task is private to the team; set assign_to_client=1 to assign it to the client (the client is then notified by email).

portal_id required
string
Portal ID from list_portals
name required
string
Task name, max 200 characters
description
string
Optional task description, max 5000 characters
due_date
string
Optional due date, format YYYY-MM-DD
assign_to_client
integer
1 = assign the task to the client (notifies them), 0 = internal team task (default) One of: 0, 1.
checklist
array of string
Optional checklist items to add inside the task, max 20
Start a conversation create_conversation write

Start ONE new conversation (message thread) in a portal. Call once per conversation — a successful call means it exists, do not retry it. By default it is private to the owner; set share_with_client=1 so the client can see it, share_with_team=1 for the whole team.

portal_id required
string
Portal ID from list_portals
name required
string
Conversation name/subject
share_with_client
integer
1 = client can see and reply, 0 = hidden from client (default) One of: 0, 1.
share_with_team
integer
1 = whole team can see, 0 = only the owner (default) One of: 0, 1.
email_not_send
integer
1 = do not create an email thread for this conversation One of: 0, 1.
Create a portal create_portal write

Create ONE new client portal (a client workspace). Call once — a successful call means it exists, do not retry it. The contact email becomes the primary client user of the portal. If the user has not said whether to use their default portal template (blueprint), ask them first — apply it (default) or create a clean portal (apply_template 0).

portal_name required
string
Name of the portal / client company
contact_email required
string
Email of the primary client contact
contact_name
string
Optional name of the primary client contact
apply_template
integer
Set 0 to create a clean portal WITHOUT the agent's default portal template (blueprint tasks/files/projects). Omit or 1 = apply the default template, same as creating from the app. One of: 0, 1.
Create a page create_page write

Use this to create one new page in a client portal. A page can include a text block, a code block (HTML/CSS/JS), an embedded website, a video, form questions the client answers, and file requests. Call it once per page. If the user hasn't said what the page should contain, ask before creating it. Image and file uploads are added in the Ahsuite app. Returns the new page_id.

portal_id required
string
Portal ID from list_portals
title required
string
Page title, max 50 characters
text
object
Text block Fields: content, heading, description.
code
object
Code block Fields: content, heading, description.
embed
object
Embedded website block Fields: url, height, heading, description.
video
object
Video block (YouTube/Vimeo/Loom url) Fields: url, heading, description.
questions
array of object
Form questions block — the client user answers these on the page. Max 20 questions (questions inside sections count too).
files
array of object
File REQUESTS block — each item asks the client to upload a file (no binary is sent here). Max 10.
Add a link, request or folder create_file write

Add ONE item to the Files section of a portal: a link, a file request (asks the client to upload something), or a folder. Send exactly one of link/request/folder per call. If it is unclear which of the three the user wants, ask before calling. Direct file uploads can only be done from the Ahsuite app.

portal_id required
string
Portal ID from list_portals
link
object
A link item Fields: title, url.
request
object
A file request — the client is asked to upload Fields: title, description.
folder
object
A folder (root level only) Fields: title.
share_with_client
integer
1 = client can see it (file requests are always client-visible) One of: 0, 1.
share_with_team
integer
1 = whole team can see, 0 = only the owner (default) One of: 0, 1.
folder_id
string
Optional: put the item inside this folder (file_id of a folder row)
Create a journal create_journal write

Create ONE new work journal in a portal. Journals are internal work logs the team keeps per client.

portal_id required
string
Portal ID from list_portals
name required
string
Journal name, max 255 characters
Save a login create_password may email client

Saves a login's name, address and username in a portal's password manager. It never takes a password, and passwords don't belong in any field, note, task, comment or message. Without options, the login is marked Password needed and the result has add_password_url, where the user adds the password in Ahsuite. With request, Ahsuite emails that client user to add it in their portal. With generate, Ahsuite makes a strong password the user can reveal in Ahsuite.

portal_id required
string
Portal ID from list_portals
name required
string
Login name, e.g. "WordPress admin"
url
string
Login address
username
string
notes
string
Max 5000 characters
share_with_client
integer
One of: 0, 1.
share_with_team
integer
One of: 0, 1.
request
object
Ask a client user of this portal to add the password, like a file request Fields: client_user_email, message.
generate
boolean
Ahsuite makes a strong password for this new login
List logins list_passwords read-only

List the saved logins of a portal, newest first: name, address, username, sharing, password status (needed / requested / set), when the password last changed and by whom, the open request if any, and add_password_url when the password is still needed. No field ever contains a password or any part of one.

portal_id required
string
Portal ID from list_portals
search
string
Filter by record name (contains match)
page
integer
Page number, default 1
limit
integer
Rows per page, default 20, max 100
Create an invoice create_invoice may email client

Create ONE invoice in a portal. IMPORTANT: the client is always emailed an "action required" notice when the invoice is created — confirm with the user before calling. Never invent line items, prices or dates — if the user has not given them, ask. The total is computed server-side from the items.

portal_id required
string
Portal ID from list_portals
invoice_date required
string
Format YYYY-MM-DD
due_date required
string
Format YYYY-MM-DD
invoice_number
integer
Optional — auto-numbered when omitted
currency
string
Default USD One of: USD, GBP.
notes
string
Max 500 characters
items required
array of object
Invoice line items, max 50
List invoices list_invoices read-only

List the invoices of a portal, newest first — number, dates, amount, currency and status (due / paid / payment_processing / cancelled / refunded).

portal_id required
string
Portal ID from list_portals
page
integer
Page number, default 1
limit
integer
Rows per page, default 20, max 100
Get invoice details get_invoice read-only

Get one invoice in full: number, dates, currency, status, total, notes and every line item with its add / subtract sub-items. Use list_invoices to find the invoice_id.

portal_id required
string
Portal ID from list_portals
invoice_id required
integer
Invoice ID from list_invoices / create_invoice
Update an invoice update_invoice confirm

Edit the header of an UNPAID invoice — number, dates, currency, notes (merge-only). Paid / processing / cancelled / refunded invoices cannot be edited. Line items are NOT changed here: use add_invoice_item / update_invoice_item. No email is sent. Never invent dates — ask the user.

portal_id required
string
Portal ID from list_portals
invoice_id required
integer
Invoice ID from list_invoices / get_invoice
invoice_number
integer
New invoice number (must be unique in the portal)
invoice_date
string
Format YYYY-MM-DD
due_date
string
Format YYYY-MM-DD
currency
string
One of: USD, GBP.
notes
string
Max 500 characters; "" clears
Add an invoice line item add_invoice_item write

Add ONE line item (with optional add / subtract adjustments) to an UNPAID invoice. Existing items stay as they are; the invoice total is recomputed. Call once per item. Never invent prices — ask the user.

portal_id required
string
Portal ID from list_portals
invoice_id required
integer
Invoice ID from list_invoices / get_invoice
name required
string
Product / service name, max 250
quantity required
integer
Min 1
price required
number
Price per unit
description
string
Max 400
sub_items
array of object
Adjustments on this item, max 20
Update an invoice line item update_invoice_item confirm

Edit ONE line item of an UNPAID invoice — merge-only (only the fields you send change). sub_items: give sub_item_id (from get_invoice) to edit an existing adjustment, omit it to append a new one. Nothing is deleted here (use remove_invoice_item / remove_invoice_adjustment for that); the invoice total is recomputed. Get item_id / sub_item_id from get_invoice first.

portal_id required
string
Portal ID from list_portals
invoice_id required
integer
Invoice ID from list_invoices / get_invoice
item_id required
integer
Item ID from get_invoice items[].item_id
name
string
Max 250
description
string
Max 400; "" clears
quantity
integer
Min 1
price
number
Price per unit
sub_items
array of object
Adjustments to edit (with sub_item_id) or append (without), max 20
Remove an invoice line item remove_invoice_item confirm

Remove ONE line item (with all its adjustments) from an UNPAID invoice — "Remove Item" in the app. This cannot be undone: confirm with the user first. To remove only a single adjustment use remove_invoice_adjustment. The invoice total is recomputed.

portal_id required
string
Portal ID from list_portals
invoice_id required
integer
Invoice ID from list_invoices / get_invoice
item_id required
integer
Item ID from get_invoice items[].item_id
Remove an invoice adjustment remove_invoice_adjustment confirm

Remove ONE add / subtract adjustment from a line item of an UNPAID invoice; the item itself stays. This cannot be undone: confirm with the user first. The invoice total is recomputed.

portal_id required
string
Portal ID from list_portals
invoice_id required
integer
Invoice ID from list_invoices / get_invoice
item_id required
integer
Item ID from get_invoice items[].item_id
sub_item_id required
integer
Adjustment ID from get_invoice items[].sub_items[].sub_item_id
Mark an invoice paid mark_invoice_paid confirm

Mark an unpaid invoice as paid (manual payment), same as the "Mark Paid" button in the app. This cannot be undone through the API — confirm with the user before calling.

portal_id required
string
Portal ID from list_portals
invoice_id required
integer
Invoice ID from list_invoices / get_invoice
Create a project create_project write

Create ONE new project (service contract with time tracking) in a portal.

portal_id required
string
Portal ID from list_portals
name required
string
Project name, max 100 characters
description
string
Max 5000 characters
share_with_client
integer
1 = client can see it, 0 = hidden (default) One of: 0, 1.
share_with_team
integer
1 = whole team can see, 0 = only the owner (default) One of: 0, 1.
List projects list_projects read-only

List the projects of a portal (service contracts) — name, client sharing and created date.

portal_id required
string
Portal ID from list_portals
search
string
Filter by project name (contains match)
page
integer
Page number, default 1
limit
integer
Rows per page, default 20, max 100
Get project details get_project read-only

Get one project of a portal in full — name, description, sharing, its tasks (top-level tasks in tasks[], and phases[] each with the tasks inside it, in the order the project screen shows them) and its resources (linked pages, files, conversations, passwords). project_id comes from list_projects.

portal_id required
string
Portal ID from list_portals
project_id required
integer
Project ID from list_projects
Update a project update_project confirm

Rename a project and/or change its description — merge-only: only the fields you send change. Sending an empty description clears it. Call get_project first.

portal_id required
string
Portal ID from list_portals
project_id required
integer
Project ID from list_projects / get_project
name
string
New project name, max 100 chars
description
string
Project description (basic HTML allowed), max 5000; empty string clears it
Add a project phase add_project_phase write

Add a phase to a project (like "Add Phase" on the project screen). Phases group the project tasks; new phases go to the end. Returns the phase_id to use with update_project_phase / add_project_tasks.

portal_id required
string
Portal ID from list_portals
project_id required
integer
Project ID from list_projects / get_project
name
string
Phase name, max 200 chars. Default "Phase N"
Rename a project phase update_project_phase confirm

Rename a phase of a project. phase_id comes from get_project (phases[].phase_id).

portal_id required
string
Portal ID from list_portals
project_id required
integer
Project ID the phase belongs to
phase_id required
integer
Phase ID from get_project
name required
string
New phase name, max 200 chars
Move tasks into a project add_project_tasks write

Put existing tasks of the portal into a project (like "Select tasks" on the project screen), optionally into a specific phase; also use it to move a task that is already in the project to another phase. Tasks come from list_tasks; canceled or archived tasks are rejected. To create a brand-new task inside a project use create_task and then this tool.

portal_id required
string
Portal ID from list_portals
project_id required
integer
Project ID from list_projects / get_project
task_ids required
array of integer
Task IDs to add, max 20
phase_id
integer
Phase to place the tasks in (from get_project phases[].phase_id). Omit = the last phase; 0 = no phase (top of the project)
Link items to a project add_project_resources write

Link existing pages, files, conversations or passwords of the portal to a project (like "Add Resources" on the project screen). Already-linked resources are skipped. Ids come from list_pages / list_files / list_conversations / list_passwords.

portal_id required
string
Portal ID from list_portals
project_id required
integer
Project ID from list_projects / get_project
resources required
array of object
Max 20 per call
Add a client user create_contact may email client

Add ONE client user (contact) to a portal. No invite email is sent unless send_client_email_notify=1 — ask the user whether to send it.

portal_id required
string
Portal ID from list_portals
email required
string
Contact email, max 100
name
string
Optional contact name, max 50
default_permissions
object
Permissions for this client user Fields: allow_add_portal_users, email_receive, allow_page_add.
send_client_email_notify
integer
1 = send the invite email now, 0 = add silently (default) One of: 0, 1.
List client users list_contacts read-only

List the client users (contacts) of a portal — name, email, whether they are the primary contact, invite status (invited/active/declined), permissions. Primary contact comes first, like the Client Users list in the app. Invited and declined contacts return only their email and status (name is blank, permissions null), exactly as the app shows them.

portal_id required
string
Portal ID from list_portals
search
string
Filter by contact name (contains match)
page
integer
Page number, default 1
limit
integer
Rows per page, default 20, max 100
Update portal preferences update_portal_preferences confirm

Update the Preferences settings of a portal — name, phone, timezone, language, content alignment and security/visibility toggles. PARTIAL update: only the fields you send are changed. Use list_timezones for valid timezone values.

portal_id required
string
Portal ID from list_portals
portal_name
string
Max 50 characters
phone
string
Client organisation phone, max 20 chars (send empty string to clear)
timezone
string
IANA id, e.g. America/Phoenix
language
string
Portal language code, e.g. en, es, de, fr, hi
content_align
string
One of: align_left, align_center.
two_step_auth
integer
Require OTP login for this portal. HIPAA portals are locked to 1. SECURITY SETTING — only change when the user EXPLICITLY asks to enable/disable two-step login; never set it on your own initiative. One of: 0, 1.
portal_search
integer
One of: 0, 1.
files_no_login
integer
Allow file access WITHOUT login. SECURITY SETTING — only change when the user explicitly asks; never enable on your own initiative. One of: 0, 1.
files_hide_preview
integer
Hide large file previews One of: 0, 1.
Update the portal sidebar update_portal_sidebar confirm

Update the portal sidebar menu — show/hide tools, rename their labels, reorder them, and add custom link tabs. PARTIAL update: only the fields you send change. links are added, or updated when a tab with the same name already exists; existing custom links are always kept (removing a link is done on the portal settings page in the app).

portal_id required
string
Portal ID from list_portals
show_pages
integer
One of: 0, 1.
show_files
integer
One of: 0, 1.
show_password
integer
One of: 0, 1.
show_tasks
integer
One of: 0, 1.
show_conversations
integer
One of: 0, 1.
show_projects
integer
One of: 0, 1.
label_files
string
Rename the Files tab
label_password
string
label_tasks
string
label_conversations
string
label_projects
string
links
array of object
Custom link tabs to ADD. A tab with the same name is updated (url/same). Existing tabs are kept — tabs can only be removed from the portal settings page in the GUI. Max 20 per portal.
order
array of string
Sidebar tool order, first = top. Tools you leave out keep their current position after the ones you send. The portal opens on the first visible tool.
Set default client permissions update_client_user_defaults confirm

Update the portal-level DEFAULT permissions applied to newly added client users (existing users are not changed). PARTIAL update.

portal_id required
string
Portal ID from list_portals
allow_add_portal_users
integer
One of: 0, 1.
email_receive
integer
One of: 0, 1.
allow_page_add
integer
One of: 0, 1.
Update the account manager card update_account_manager confirm

Update the Account Manager card the client sees in a portal — avatar, displayed contact fields, text overrides and action buttons. PARTIAL update. Blank text overrides fall back to the real account manager details.

portal_id required
string
Portal ID from list_portals
avatar_source
string
One of: agency_logo, profile_picture.
show_card
integer
One of: 0, 1.
show_avatar
integer
One of: 0, 1.
show_name
integer
One of: 0, 1.
show_title
integer
One of: 0, 1.
show_email
integer
One of: 0, 1.
show_phone
integer
One of: 0, 1.
display_name
string
Override the displayed name
title
string
Override the displayed job title
email
string
Override the displayed email
phone
string
Override the displayed phone
action_buttons
object
The buttons on the card Fields: request, schedule, message, primary.
Update portal branding update_portal_branding confirm

Update the branding of a portal — custom CSS and the client website shown in the portal. PARTIAL update. The portal logo can only be changed from the Ahsuite app.

portal_id required
string
Portal ID from list_portals
custom_css
string
Custom CSS applied to the portal, max 50000 chars. Write rules against Ahsuite's own portal selectors, not generic ones like button/.btn: body, .cs-portal-spf-wrapper (portal wrapper/background), .cs-portal-spf-header (header), .cs-portal-spf-left-sidebar (sidebar background/width), .cs-portal-spf-wrapper .left-menu ul li a (sidebar menu links; "a span" = menu icons), .cs-portal-spf-wrapper .save_button (buttons background/text), .cs-portal-spf-left-sidebar .client-schedule-call a (schedule-call button), .cs-portal-spf-wrapper .left-mini-heading ("Need review" heading), .cs-portal-spf-wrapper .nclient-task-list .table-col-name (sidebar table text). Reference: https://docs.ahsuite.com/en/articles/9102664-how-to-customize-your-portals
website
string
Client website, max 50 chars
Edit client email settings update_email_notifications confirm

Update the email notification settings of a portal — the summary email frequency and the templates of the emails clients receive. PARTIAL update per email; sending a blank subject/body resets that field to the default template.

portal_id required
string
Portal ID from list_portals
summary_email_frequency
string
One of: off, daily, weekly, monthly.
emails
object
Per-email template overrides Fields: invitation, task_completed, action_required, file_requested, needs_attention.
Set portal variables update_portal_variables confirm

Create or update custom variables of a portal (upsert by name+scope, max 20 per call — variables cannot be deleted here). scope "portal" = one shared value; scope "user" = per-client-user values set in the app, so no value is accepted for user scope.

portal_id required
string
Portal ID from list_portals
variables required
array of object
List time zones list_timezones read-only

List every timezone identifier accepted by update_portal_preferences. No parameters.

List notifications list_notifications read-only

List the user's own notifications across all portals, newest first (task assigned / done / mention, file approved, conversation, invoice paid ...). Not per portal — no portal_id. Set unread_only 1 for just the unread ones (what the user usually means by "what's new"). Each row has the text, portal name and, when available, the portal_id and task_id / file_id / page_id / conversation_id / journal_id to follow up with the get_* tools.

unread_only
integer
1 = only unread notifications, 0 or omitted = all One of: 0, 1.
page
integer
Page number, default 1 (20 notifications per page)
Mark notifications read mark_notifications_read write

Mark the user's notifications as read. Send notification_ids (from list_notifications, max 50) to mark specific ones, or all 1 to mark every unread notification as read. Only mark notifications read when the user asks for it.

notification_ids
array of integer
notification_id values from list_notifications, max 50
all
integer
1 = mark ALL unread notifications as read (do not send notification_ids with it) One of: 0, 1.