{ }
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

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": {
    "client_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-12 10:04:33",
        "portal_id": "vJW4"
      },
      {
        "name": "Acme Digital",
        "created_date": "2026-04-02 08:11:20",
        "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-12 10:04:33",
    "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": []
}
{
  "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-10 09:22:45",
        "last_updated": "2026-06-14 16:03:12",
        "conversation_id": 271
      },
      {
        "name": "Onboarding questions",
        "new_message": 0,
        "created_date": "2026-05-28 11:40:03",
        "last_updated": "2026-06-01 10:15:57",
        "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-10 09:22:45",
    "last_updated": "2026-06-14 16:03:12",
    "logs": [
      {
        "message": "<p>Here is the first homepage draft.</p>",
        "from": "team",
        "created_date": "2026-06-14 15:58:02"
      },
      {
        "message": "<p>Looks great, approved!</p>",
        "from": "client",
        "created_date": "2026-06-14 16:03:12"
      }
    ],
    "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-02 14:20:11",
        "page_id": "9dRt"
      },
      {
        "title": "Onboarding Checklist",
        "category": "",
        "draft": 1,
        "created_date": "2026-05-18 09:31:47",
        "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-02 14:20:11",
    "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 00:00:00",
        "created_date": "2026-06-05 10:12:30",
        "task_id": 512
      },
      {
        "name": "Send onboarding documents",
        "status": "pending",
        "assign_to_client": 1,
        "assigned_to": "Client",
        "due_date": "2026-06-25 00:00:00",
        "created_date": "2026-06-08 13:45:19",
        "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 00:00:00",
    "assign_to_client": 0,
    "assigned_to": "Jane Cooper",
    "project": "Q2 Reporting",
    "created_date": "2026-06-02 14:20:11",
    "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-10 09:15:02", "comment_id": 2201 },
      { "comment": "Please include the ads data too.", "by": "Sam Lee (Client)", "from": "client", "created_date": "2026-06-09 17:40:11", "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-20 08:15:40",
        "file_id": "6mNs"
      },
      {
        "title": "logo-final.png",
        "type": "upload",
        "status": "client_approved",
        "share_with_client": 1,
        "created_date": "2026-06-01 12:41:08",
        "file_id": "7pQw"
      },
      {
        "title": "Signed contract",
        "type": "request",
        "status": "request_file",
        "share_with_client": 1,
        "created_date": "2026-06-03 09:05:22",
        "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-02 14:22:05",
    "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-10 09:15:02", "created_date": "2026-06-09 17:40:11", "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-02 14:22:05", "version_id": 8990 }
    ],
    "comments": [
      { "comment": "Please sign page 3 as well.", "by": "Jane Cooper", "from": "agent", "created_date": "2026-06-08 11:02:40", "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-01 08:00:12",
        "journal_id": 54
      },
      {
        "name": "Weekly SEO notes",
        "created_date": "2026-05-12 10:30:45",
        "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-02 14:20:11",
    "journal_id": 60,
    "logs": [
      { "log": "<p>Homepage wireframe approved by the client.</p>", "by": "Jane Cooper", "created_date": "2026-06-10 09:15:02", "log_id": 412 },
      { "log": "<p>Kickoff call done, notes shared.</p>", "by": "Jane Cooper", "created_date": "2026-06-03 17:40:11", "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-01 10:12:44",
        "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-01 09:03:21",
        "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-01 10:02:44",
    "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-14 11:20:10",
        "project_id": 82
      },
      {
        "name": "SEO retainer",
        "share_with_client": 0,
        "created_date": "2026-06-02 15:44:37",
        "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-02 14:20:11",
    "project_id": 328,
    "tasks": [
      { "name": "Send the June report", "status": "ready_for_work", "assigned_to": "contact 1 (Client)", "due_date": "2026-12-10 00:00:00", "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-02 10:12:44",
        "contact_id": "3Kp9"
      },
      {
        "name": "",
        "email": "sam@example.com",
        "primary": 0,
        "status": "invited",
        "permissions": null,
        "added_date": "2026-07-19 16:03:21",
        "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-10 09:15:02",
        "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-09 17:40:11",
        "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."
}