Authentication
Dates: every date-time in a response (created_date, last_updated, approved_date, generated_date, password_changed_at…) is ISO 8601 in the portal's timezone, with its offset, e.g. 2026-10-16T09:15:30-07:00. Date-only fields such as due_date stay YYYY-MM-DD.
There are two ways to authenticate with Ahsuite:
- API Token (Basic auth) — for your own code: server scripts, cron jobs, Zapier-style tools and direct REST API calls. Use the credentials below.
- OAuth — for AI apps & third-party apps (Claude, ChatGPT, Cursor...). No token to copy: the app opens Ahsuite in your browser, you log in and click Approve. See Connect from an AI app below.
API Token (Basic auth)
Pass your Owner Email as the username and one of your API tokens as the password in the Authorization header. Tokens are shown only once when generated — if you lose one, revoke it and generate a new one. Never expose a token in client-side code or public repos.
Connect from an AI app (MCP)
Ahsuite is an MCP server — connect it once and your AI app can list portals, create tasks, start conversations and more, just by chatting. Authentication is OAuth: the app opens Ahsuite in your browser, you log in and click Approve. No token needed.
- 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_urimust behttps://. - 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.
address.portal or user.delhi.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.
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()){
"success": true,
"message": "Portal has been added successfully.",
"data": {
"portal_id": "vJW4"
}
}{
"success": false,
"message": "Validation Error",
"data": "{\"portal_name\":[\"The portal name field is required.\"]}"
}{
"success": false,
"message": "Invalid owner email and authorization_token."
}List Portals
Return the portals owned by the authenticated account, newest first. Only active portals are returned. The portal_id in each row is the same ID accepted by every other endpoint on this page. Results are paginated — use page and limit to walk through the full list.
1.20, maximum 100.Other unsuccessful response codes: 403, 429, 500.
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()){
"success": true,
"message": "",
"data": {
"portals": [
{
"name": "Acme Corp",
"created_date": "2026-05-12T10:04:33-07:00",
"portal_id": "vJW4"
},
{
"name": "Acme Digital",
"created_date": "2026-04-02T08:11:20-07:00",
"portal_id": "vJW3"
}
],
"pagination": {
"total": 42,
"page": 1,
"limit": 20,
"total_pages": 3
}
}
}{
"success": false,
"message": "Validation Error",
"data": "{\"limit\":[\"The limit may not be greater than 100.\"]}"
}{
"success": false,
"message": "Invalid owner email and authorization_token."
}Portal Detail
Return the full details of a single portal — name, contact info and the preference fields accepted by Update Portal Preferences, plus nested sidebar, client_user, account_manager, branding, email_notifications and variables sections. Every field uses the same name its settings update endpoint accepts. Requires a valid portal_id (get one from List Portals).
Other unsuccessful response codes: 403, 429, 500.
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()){
"success": true,
"message": "",
"data": {
"name": "Acme Corp",
"phone": "+1 480 555 0117",
"website": "acmecorp.com",
"language": "en",
"timezone": "America/Phoenix",
"content_align": "align_left",
"two_step_auth": 0,
"portal_search": 1,
"files_no_login": 1,
"files_hide_preview": 0,
"created_date": "2026-05-12T10:04:33-07:00",
"portal_id": "vJW4",
"sidebar": {
"show_pages": 1, "show_files": 1, "show_password": 0, "show_tasks": 1, "show_conversations": 1, "show_projects": 1,
"label_files": "Files", "label_password": "", "label_tasks": "Tasks", "label_conversations": "", "label_projects": "",
"order": ["pages", "tasks", "conversations", "files", "password", "projects"],
"default_tab": "pages",
"links": [{ "name": "Our Website", "url": "https://example.com", "same": 0 }]
},
"client_user": { "allow_add_portal_users": 0, "email_receive": 1, "allow_page_add": 0 },
"account_manager": {
"show_card": 1, "show_avatar": 1, "show_name": 1, "show_title": 1, "show_email": 1, "show_phone": 0,
"display_name": "", "title": "Account Manager", "email": "", "phone": "",
"avatar_source": "profile_picture",
"action_buttons": {
"primary": "message",
"request": { "enabled": 1, "text": "" },
"schedule": { "enabled": 1, "text": "", "calendar_link": "", "open_in_iframe": 0 },
"message": { "enabled": 1, "text": "" }
}
},
"branding": { "logo_url": "https://d1ea3bsweroqag.cloudfront.net/assets/client/logo/cl-1234.png", "custom_css": "" },
"email_notifications": {
"summary_email_frequency": "daily",
"emails": {
"invitation": { "subject": "", "body": "" },
"task_completed": { "subject": "", "button_text": "", "body": "" },
"action_required": { "subject": "", "button_text": "", "body": "" },
"file_requested": { "subject": "", "button_text": "", "body": "" },
"needs_attention": { "subject": "", "button_text": "", "body": { "main_heading": "", "task_heading": "", "notification_heading": "", "last_heading": "" } }
}
},
"variables": [{ "name": "project_manager", "scope": "portal", "value": "Jane" }]
}
}{
"success": false,
"message": "Validation Error",
"data": "{\"portal_id\":[\"The portal id field is required.\"]}"
}{
"success": false,
"message": "Invalid owner email and authorization_token."
}{
"success": false,
"message": "Not Found",
"data": "Portal record not found."
}Create a Conversation
Create a new conversation inside an existing portal. Requires a valid portal_id.
1 to not create an email thread for this conversation. Default 0. Always treated as 1 on HIPAA-compliant portals.1 to make the conversation visible to the client. Default 0.1 to share the conversation with your internal team. Default 0.Other unsuccessful response codes: 403, 429, 500.
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()){
"success": true,
"message": "Conversation has been added successfully.",
"data": {
"conversation_id": 812,
"reply_email": "c-812-x9k2@reply.ahsuite.com"
}
}{
"success": false,
"message": "Validation Error",
"data": "{\"name\":[\"The name field is required.\"]}"
}{
"success": false,
"message": "Invalid owner email and authorization_token."
}{
"success": false,
"message": "Not Found",
"data": "Portal record not found."
}List Conversations
Return the conversations of a portal, most recently updated first. Archived conversations are excluded, and visibility matches what the account owner sees in the app — conversations shared with the team plus the owner's own, while other agents' private conversations stay hidden. Results are paginated.
1.20, maximum 100.Other unsuccessful response codes: 403, 429, 500.
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()){
"success": true,
"message": "",
"data": {
"conversations": [
{
"name": "Website revamp discussion",
"new_message": 1,
"created_date": "2026-06-10T09:22:45-07:00",
"last_updated": "2026-06-14T16:03:12-07:00",
"conversation_id": 271
},
{
"name": "Onboarding questions",
"new_message": 0,
"created_date": "2026-05-28T11:40:03-07:00",
"last_updated": "2026-06-01T10:15:57-07:00",
"conversation_id": 265
}
],
"pagination": {
"total": 12,
"page": 1,
"limit": 20,
"total_pages": 1
}
}
}{
"success": false,
"message": "Validation Error",
"data": "{\"portal_id\":[\"The portal id field is required.\"]}"
}{
"success": false,
"message": "Invalid owner email and authorization_token."
}{
"success": false,
"message": "Not Found",
"data": "Portal record not found."
}Conversation Detail
Return a single conversation with its last 25 messages (oldest of those first). The conversation_id comes from List Conversations. Share flags use the same names as Create Conversation.
Other unsuccessful response codes: 403, 429, 500.
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()){
"success": true,
"message": "",
"data": {
"name": "Website revamp discussion",
"share_with_client": 1,
"share_with_team": 1,
"email_not_send": 0,
"created_date": "2026-06-10T09:22:45-07:00",
"last_updated": "2026-06-14T16:03:12-07:00",
"logs": [
{
"message": "<p>Here is the first homepage draft.</p>",
"from": "team",
"created_date": "2026-06-14T15:58:02-07:00"
},
{
"message": "<p>Looks great, approved!</p>",
"from": "client",
"created_date": "2026-06-14T16:03:12-07:00"
}
],
"conversation_id": 271
}
}{
"success": false,
"message": "Validation Error",
"data": "{\"conversation_id\":[\"The conversation id field is required.\"]}"
}{
"success": false,
"message": "Invalid owner email and authorization_token."
}{
"success": false,
"message": "Not Found",
"data": "Conversation record not found."
}Reply to a Conversation
Post a reply in an existing conversation — exactly like replying in the app. The team is notified and, when the conversation is shared with the client, the client is notified and emailed (unless emails are turned off for that conversation). One optional attachment per request (multipart/form-data).
404.p, br, strong, em, ul, ol, li, a; other tags are stripped.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.
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()){
"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.
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.
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()){
"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.
text[content] is sent.code[content] is sent.embed[url] is sent.video[url] is sent.multipart/form-data. Allowed types: jpg, png, jpeg, gif, svg, webp. Max 5MB.image[file] is sent.files[N] index is ONE item: either a file upload or a file request, never both. Max 10 items per page.files[0]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[1] (its own index, no file field)questions[N] index is ONE question. Max 20 questions per page.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][...].paragraph (default), multiple_choice, checkbox, date, heading, file, signature.0, 1, 2…) to add multiple questions.1 to make the question required. Default 0.multiple_choice and checkbox types only. Max 20 options per question.portal (default) or user. Used only when variable_name is sent.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.Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
1.20, maximum 100.Other unsuccessful response codes: 403, 429, 500.
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()){
"success": true,
"message": "",
"data": {
"pages": [
{
"title": "Monthly SEO Report",
"category": "Reports",
"draft": 0,
"created_date": "2026-06-02T14:20:11-07:00",
"page_id": "9dRt"
},
{
"title": "Onboarding Checklist",
"category": "",
"draft": 1,
"created_date": "2026-05-18T09:31:47-07:00",
"page_id": "8kLm"
}
],
"pagination": {
"total": 8,
"page": 1,
"limit": 20,
"total_pages": 1
}
}
}{
"success": false,
"message": "Validation Error",
"data": "{\"portal_id\":[\"The portal id field is required.\"]}"
}{
"success": false,
"message": "Invalid owner email and authorization_token."
}{
"success": false,
"message": "Not Found",
"data": "Portal record not found."
}Page Detail
Return a single page with its content — title, category, draft flag and its tabs in the order the page editor shows them. Each tab returns its text block (HTML content) embed block (embedded URL or embed code, plus height) image block (file_name plus a temporary url valid for 20 minutes) and files block (items[] — the files and file requests placed on the tab, with the same file / file_name / title / description fields Create a Page accepts (file = the uploaded file, file_name = its display name; title and description belong to file requests), type (file or url), a url (the link itself for url items, a temporary download link valid for 20 minutes for uploaded files, empty for an open file request), is_request = 1 for a file request, and a file_id that matches List Files) video block (YouTube url), code block (raw HTML/CSS/JS content) and questions block (items[] — the form questions, with the same fields Create a Page accepts plus a question_id; section and repeat_section containers carry their child questions in a nested questions[]), each with the optional block heading and description, using the same field names Create a Page accepts; a block the tab does not have is returned as null. Archived pages are not returned. Get a page_id from List Pages.
Other unsuccessful response codes: 403, 429, 500.
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()){
"success": true,
"message": "",
"data": {
"title": "Monthly SEO Report",
"category": "Reports",
"draft": 0,
"created_date": "2026-06-02T14:20:11-07:00",
"page_id": "9dRt",
"tabs": [
{
"tab_id": "3xQa",
"title": "Tab 1",
"text": {
"content": "<h2>Summary</h2><p>Organic traffic grew 18% this month.</p>",
"heading": "June overview",
"description": ""
},
"embed": null,
"image": {
"file_name": "traffic-chart.png",
"url": "https://s3.amazonaws.com/.../view/API-1718000000-traffic-chart.png?X-Amz-Expires=1200&...",
"heading": "",
"description": ""
},
"files": null,
"video": null,
"code": {
"content": "<div class=\"cta\"><a href=\"https://example.com/book\">Book a call</a></div>\n<style>.cta a{background:#2f80ed;color:#fff;padding:10px 16px;border-radius:6px}</style>",
"heading": "",
"description": ""
},
"questions": null
},
{
"tab_id": "3xQb",
"title": "Tab 2",
"text": null,
"embed": {
"url": "https://docs.google.com/spreadsheets/d/1AbC/edit",
"height": 800,
"heading": "Live report",
"description": "Updated every Monday."
},
"image": null,
"files": {
"heading": "Documents",
"description": "",
"items": [
{ "type": "file", "file": "Contract.pdf", "url": "https://s3.amazonaws.com/.../tool/API-1718000000-Contract.pdf?X-Amz-Expires=1200&...", "file_name": "Signed contract", "title": "", "description": "", "is_request": 0, "file_id": "7pQ1" },
{ "type": "file", "file": "", "url": "", "file_name": "", "title": "Please upload your logo", "description": "PNG or SVG, transparent background.", "is_request": 1, "file_id": "7pQ2" }
]
},
"video": {
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"heading": "Walkthrough",
"description": ""
},
"code": null,
"questions": {
"heading": "Kick-off form",
"description": "Takes 2 minutes.",
"items": [
{ "question_type": "paragraph", "question_name": "What is your main goal this quarter?", "description": "", "required": 1, "options": [], "variable_name": "main_goal", "variable_scope": "portal", "question_id": "5wA1" },
{ "question_type": "multiple_choice", "question_name": "Preferred contact method", "description": "", "required": 0, "options": ["Email", "Phone", "Slack"], "variable_name": "", "variable_scope": "", "question_id": "5wA2" },
{ "question_type": "repeat_section", "question_name": "Team members", "description": "", "required": 0, "options": [], "variable_name": "", "variable_scope": "", "question_id": "5wA3",
"questions": [
{ "question_type": "paragraph", "question_name": "Full name", "description": "", "required": 1, "options": [], "variable_name": "", "variable_scope": "", "question_id": "5wA4" }
]
}
]
}
}
]
}
}{
"success": false,
"message": "Validation Error",
"data": "{\"page_id\":[\"The page id field is required.\"]}"
}{
"success": false,
"message": "Invalid owner email and authorization_token."
}{
"success": false,
"message": "Not Found",
"data": "Page record not found."
}Update a Page
Update an existing page. This is a partial update: only the parameters you send are changed, every other field and block on the page is left untouched. Sending a parameter as an empty string clears that field. Page-level fields (title, draft, category) need only the page_id; content blocks are edited per tab and need the tab_id from Page Detail. All content blocks are supported: text, code, video, image, files, questions and embed. Send the request as multipart/form-data when uploading an image or files.
1 marks the page as a draft (hidden from the client), 0 publishes it.tabs[].tab_id. Required when any block parameter or tab_title is sent. New tabs are created with Add a Tab.tab_id. Max 100 characters.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.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.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.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.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.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.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.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.title without a file to add a file request — a card asking the client to upload something. Max 100 characters.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[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.1 makes the question mandatory. Default 0.multiple_choice or checkbox question, one per options[] entry. Required for those types. Max 20, each max 500 characters.portal (default) or user — scope of variable_name.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][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.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.Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
Tab N. Can be changed later with tab_title on Update a Page.Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
question_id from Page Detail. A question of another page or portal returns 404.1 makes the question mandatory, 0 optional.Other unsuccessful response codes: 403, 429, 500.
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()){
"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].
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.
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()){
"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.
question_id from Page Detail. A question of another page or portal returns 404.Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
p, br, strong, em, ul, ol, li, a). Max 5000 characters. Optional.YYYY-MM-DD format. Optional.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.0, 1, 2…) to add multiple labels. An existing label with the same name and color is reused. Optional.green, yellow, orange, red, purple, blue, sky, lime, pink, black. Optional, default = no color.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.
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()){
"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.
ready_for_work, pending, in_progress, on_hold, done, canceled. When omitted, every status except canceled is returned.1.20, maximum 100.Other unsuccessful response codes: 403, 429, 500.
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()){
"success": true,
"message": "",
"data": {
"tasks": [
{
"name": "Design homepage mockup",
"status": "in_progress",
"assign_to_client": 0,
"assigned_to": "John Smith",
"due_date": "2026-06-20",
"created_date": "2026-06-05T10:12:30-07:00",
"task_id": 512
},
{
"name": "Send onboarding documents",
"status": "pending",
"assign_to_client": 1,
"assigned_to": "Client",
"due_date": "2026-06-25",
"created_date": "2026-06-08T13:45:19-07:00",
"task_id": 518
}
],
"pagination": {
"total": 14,
"page": 1,
"limit": 20,
"total_pages": 1
}
}
}{
"success": false,
"message": "Validation Error",
"data": "{\"status\":[\"The selected status is invalid.\"]}"
}{
"success": false,
"message": "Invalid owner email and authorization_token."
}{
"success": false,
"message": "Not Found",
"data": "Portal record not found."
}Task Detail
Return a single task in full — name, description, status, dates, assignee, project, checklist, labels, attachments and comments, as shown on the task detail screen in the app. Field names match Create Task. Hidden and archived tasks are not returned. Get a task_id from List Tasks.
404.Other unsuccessful response codes: 403, 429, 500.
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()){
"success": true,
"message": "",
"data": {
"name": "Send the June report",
"description": "<p>Export the analytics and share the PDF.</p>",
"status": "in_progress",
"due_date": "2026-06-30",
"assign_to_client": 0,
"assigned_to": "Jane Cooper",
"project": "Q2 Reporting",
"created_date": "2026-06-02T14:20:11-07:00",
"task_id": 3659,
"checklist": [
{ "name": "Export analytics", "completed": 1, "item_id": 8101 },
{ "name": "Write summary", "completed": 0, "item_id": 8102 }
],
"labels": [
{ "name": "Reporting", "color": "blue" }
],
"attachments": [
{ "name": "may-report.pdf", "url": "https://s3.amazonaws.com/.../task/T-1718000000-may-report.pdf?X-Amz-Expires=900&...", "attachment_id": 512 }
],
"comments": [
{ "comment": "Draft is ready for review.", "by": "Jane Cooper", "from": "agent", "created_date": "2026-06-10T09:15:02-07:00", "comment_id": 2201 },
{ "comment": "Please include the ads data too.", "by": "Sam Lee (Client)", "from": "client", "created_date": "2026-06-09T17:40:11-07:00", "comment_id": 2198 }
]
}
}{
"success": false,
"message": "Validation Error",
"data": "{\"task_id\":[\"The task id field is required.\"]}"
}{
"success": false,
"message": "Invalid owner email and authorization_token."
}{
"success": false,
"message": "Not Found",
"data": "Task record not found."
}Update a Task
Update an existing task. This is a partial update: only the parameters you send are changed, everything else on the task is left untouched. Each change is written to the task's history, as in the app. Supported: name, description, due_date, status, dependency_task_id and project_id. Assignee, labels, checklist items, attachments and comments have their own endpoints below. Get a task_id from List Tasks.
p, br, strong, em, ul, ol, li, a), other tags are stripped. Max 5000 characters. Replaces the current description. Empty string clears it.YYYY-MM-DD format. Empty string removes the due date.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.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 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.
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()){
"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.
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).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.
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()){
"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.
green, yellow, orange, red, purple, blue, sky, lime, pink or black. Omit for no color.Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
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.
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()){
"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.
checklist[].item_id or Add Checklist Items. An item of another task or portal returns 404.1 marks the item completed, 0 marks it not completed.Other unsuccessful response codes: 403, 429, 500.
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()){
"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).
Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
p, br, strong, em, ul, ol, li, a; other tags are stripped.Other unsuccessful response codes: 403, 429, 500.
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()){
"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).
1 to make the file visible to the client user. Default 0 (internal only).1 to share the file with your internal team. Default 0 (only you).file_id — places the item inside that folder. Not allowed when creating a folder (folders are root-level only). Optional.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.1 = the client user is asked to approve the file. Default 0.file_id can be used as folder_id to add files inside it.Other unsuccessful response codes: 403, 429, 500.
# 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()){
"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.
file_id of a row with type folder. When omitted, the root level is returned.1.20, maximum 100.Other unsuccessful response codes: 403, 429, 500.
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()){
"success": true,
"message": "",
"data": {
"files": [
{
"title": "Brand Assets",
"type": "folder",
"status": "",
"share_with_client": 1,
"created_date": "2026-05-20T08:15:40-07:00",
"file_id": "6mNs"
},
{
"title": "logo-final.png",
"type": "upload",
"status": "client_approved",
"share_with_client": 1,
"created_date": "2026-06-01T12:41:08-07:00",
"file_id": "7pQw"
},
{
"title": "Signed contract",
"type": "request",
"status": "request_file",
"share_with_client": 1,
"created_date": "2026-06-03T09:05:22-07:00",
"file_id": "8rTx"
}
],
"pagination": {
"total": 9,
"page": 1,
"limit": 20,
"total_pages": 1
}
}
}{
"success": false,
"message": "Validation Error",
"data": "{\"portal_id\":[\"The portal id field is required.\"]}"
}{
"success": false,
"message": "Invalid owner email and authorization_token."
}{
"success": false,
"message": "Not Found",
"data": "Folder record not found."
}File Detail
Return a single file, folder or file request in full — title, description, status, sharing, its versions (newest first), comments and labels, as shown on the file screen in the app. Field names match List Files and Create File. For a folder only its own details are returned (is_folder = 1, sharing, parent folder_id, items_count); to see the files inside it call List Files with folder_id. Archived files are not returned.
404.Other unsuccessful response codes: 403, 429, 500.
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()){
"success": true,
"message": "",
"data": {
"title": "Signed contract",
"description": "",
"is_folder": 0,
"is_request": 0,
"status": "client_approved",
"require_client_approval": 1,
"share_with_client": 1,
"share_with_team": 1,
"folder_id": "6kFz",
"created_date": "2026-06-02T14:22:05-07:00",
"file_id": "7pQ1",
"versions": [
{ "file_name": "contract-v2.pdf", "type": "file", "url": "https://s3.amazonaws.com/.../tool/API-1718000000-contract-v2.pdf?X-Amz-Expires=7200&...", "approved_by": "Sam Lee", "approved_date": "2026-06-10T09:15:02-07:00", "created_date": "2026-06-09T17:40:11-07:00", "version_id": 9012 },
{ "file_name": "contract.pdf", "type": "file", "url": "https://s3.amazonaws.com/.../tool/API-1717000000-contract.pdf?X-Amz-Expires=7200&...", "approved_by": "", "approved_date": null, "created_date": "2026-06-02T14:22:05-07:00", "version_id": 8990 }
],
"comments": [
{ "comment": "Please sign page 3 as well.", "by": "Jane Cooper", "from": "agent", "created_date": "2026-06-08T11:02:40-07:00", "comment_id": 3301 }
],
"labels": [
{ "name": "Legal", "color": "purple" }
]
}
}{
"success": false,
"message": "Validation Error",
"data": "{\"file_id\":[\"The file id field is required.\"]}"
}{
"success": false,
"message": "Invalid owner email and authorization_token."
}{
"success": false,
"message": "Not Found",
"data": "File record not found."
}Update a File
Update a file, file request or folder. This is a partial update: only the parameters you send are changed. Each change behaves exactly like editing the field on the file screen — turning require_client_approval on shares the file with the client (and its folder, if any), sets it to "approval required" and notifies them by email (the "Action required" email, or "File required" for a file request); it cannot be changed on a file the client already approved. Folders accept title and description only.
404.p, br, strong, em, ul, ol, li, a. Max 5000 characters. Empty string clears it.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.
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()){
"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.
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.Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
p, br, strong, em, ul, ol, li, a; other tags are stripped.Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
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.
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()){
"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.
green, yellow, orange, red, purple, blue, sky, lime, pink or black. Omit for no color.Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
1.20, maximum 100.Other unsuccessful response codes: 403, 429, 500.
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()){
"success": true,
"message": "",
"data": {
"journals": [
{
"name": "June development log",
"created_date": "2026-06-01T08:00:12-07:00",
"journal_id": 54
},
{
"name": "Weekly SEO notes",
"created_date": "2026-05-12T10:30:45-07:00",
"journal_id": 47
}
],
"pagination": {
"total": 5,
"page": 1,
"limit": 20,
"total_pages": 1
}
}
}{
"success": false,
"message": "Validation Error",
"data": "{\"portal_id\":[\"The portal id field is required.\"]}"
}{
"success": false,
"message": "Invalid owner email and authorization_token."
}{
"success": false,
"message": "Not Found",
"data": "Portal record not found."
}Journal Detail
Return a single journal in full — its name, created date and the newest 25 log entries, newest first, as shown in the journal popup in the app. Log entries are HTML; images pasted into a log come back with a temporary link that is valid for 15 minutes. Archived journals are not returned. Get a journal_id from List Journals.
404.Other unsuccessful response codes: 403, 429, 500.
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()){
"success": true,
"message": "",
"data": {
"name": "Website redesign",
"created_date": "2026-06-02T14:20:11-07:00",
"journal_id": 60,
"logs": [
{ "log": "<p>Homepage wireframe approved by the client.</p>", "by": "Jane Cooper", "created_date": "2026-06-10T09:15:02-07:00", "log_id": 412 },
{ "log": "<p>Kickoff call done, notes shared.</p>", "by": "Jane Cooper", "created_date": "2026-06-03T17:40:11-07:00", "log_id": 401 }
]
}
}{
"success": false,
"message": "Validation Error",
"data": "{\"journal_id\":[\"The journal id field is required.\"]}"
}{
"success": false,
"message": "Invalid owner email and authorization_token."
}{
"success": false,
"message": "Not Found",
"data": "Journal record not found."
}Update a Journal
Rename a journal. The name is the only field of a journal that can be edited in the app, so it is the only field here; log entries are added with Add a Journal Log. Get a journal_id from List Journals.
404.Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
404.p, br, strong, em, ul, ol, li, a); other tags are removed.Other unsuccessful response codes: 403, 429, 500.
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()){
"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).
WordPress admin. Max 255 characters.1 to make the login visible to the client. Default 0.1 to share the login with your internal team. Default 0 (only you).generate.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.
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())// 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.
1.20, maximum 100.Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
YYYY-MM-DD.YYYY-MM-DD.USD (default) or GBP.items[] required (max 50)add (e.g. tax, fee) or subtract (e.g. discount). Required for each sub-item. Max 20 sub-items per item.Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
1.20, maximum 100.Invoice status is one of: due, paid, payment_processing, cancelled, refunded. Other unsuccessful response codes: 403, 429, 500.
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()){
"success": true,
"message": "",
"data": {
"invoices": [
{
"invoice_number": "00012",
"invoice_date": "2026-08-01",
"due_date": "2026-08-15",
"total_amount": "450.00",
"currency": "USD",
"status": "due",
"created_date": "2026-08-01T10:12:44-07:00",
"invoice_id": 55
},
{
"invoice_number": "00011",
"invoice_date": "2026-07-01",
"due_date": "2026-07-15",
"total_amount": "450.00",
"currency": "USD",
"status": "paid",
"created_date": "2026-07-01T09:03:21-07:00",
"invoice_id": 54
}
],
"pagination": {
"total": 12,
"page": 1,
"limit": 20,
"total_pages": 1
}
}
}{
"success": false,
"message": "Validation Error",
"data": "{\"portal_id\":[\"The portal id field is required.\"]}"
}{
"success": false,
"message": "Invalid owner email and authorization_token."
}{
"success": false,
"message": "Not Found",
"data": "Portal record not found."
}Invoice Detail
Return a single invoice in full — number, dates, currency, status, total, notes and every line item with its add / subtract sub-items, as shown on the invoice screen in the app. Field names match Create Invoice. Archived invoices are not returned. Get an invoice_id from List Invoices.
404.Other unsuccessful response codes: 403, 429, 500.
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()){
"success": true,
"message": "",
"data": {
"invoice_number": "00011",
"invoice_date": "2026-06-01",
"due_date": "2026-06-15",
"currency": "USD",
"status": "due",
"total_amount": "1150.00",
"notes": "Payable within 14 days.",
"created_date": "2026-06-01T10:02:44-07:00",
"invoice_id": 55,
"items": [
{
"name": "Website design",
"description": "Homepage and 3 inner pages",
"quantity": 1,
"price": "1000.00",
"line_total": "1150.00",
"item_id": 912,
"sub_items": [
{ "type": "add", "description": "Rush delivery", "amount": "200.00", "sub_item_id": 3300 },
{ "type": "subtract", "description": "Loyalty discount", "amount": "50.00", "sub_item_id": 3301 }
]
}
]
}
}{
"success": false,
"message": "Validation Error",
"data": "{\"invoice_id\":[\"The invoice id field is required.\"]}"
}{
"success": false,
"message": "Invalid owner email and authorization_token."
}{
"success": false,
"message": "Not Found",
"data": "Invoice record not found."
}Update an Invoice
Edit the header of an unpaid invoice — number, dates, currency and notes. Paid, processing, cancelled and refunded invoices cannot be edited. This is a partial update: only the parameters you send are changed. Line items are not changed here; use Add an Invoice Item and Update an Invoice Item. No email is sent to the client. Get an invoice_id from List Invoices.
404; an invoice that is not unpaid returns 400.YYYY-MM-DD.YYYY-MM-DD.USD or GBP.Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
400.sub_items[0][type] (add / subtract), sub_items[0][amount], optional sub_items[0][description].Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
400.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.
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()){
"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.
400.Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
400.items[].sub_items[].sub_item_id. An adjustment of another item returns 404.Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
400.Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
1 to make the project visible to the client. Default 0.1 to share the project with your internal team. Default 0 (only you).Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
1.20, maximum 100.Other unsuccessful response codes: 403, 429, 500.
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()){
"success": true,
"message": "",
"data": {
"projects": [
{
"name": "Website redesign",
"share_with_client": 1,
"created_date": "2026-07-14T11:20:10-07:00",
"project_id": 82
},
{
"name": "SEO retainer",
"share_with_client": 0,
"created_date": "2026-06-02T15:44:37-07:00",
"project_id": 75
}
],
"pagination": {
"total": 6,
"page": 1,
"limit": 20,
"total_pages": 1
}
}
}{
"success": false,
"message": "Validation Error",
"data": "{\"portal_id\":[\"The portal id field is required.\"]}"
}{
"success": false,
"message": "Invalid owner email and authorization_token."
}{
"success": false,
"message": "Not Found",
"data": "Portal record not found."
}Project Detail
Return a single project in full — name, description, sharing, its tasks and phases in the order the project screen shows them, and its resources (linked pages, files, conversations and passwords). Task rows use the same fields as List Tasks; resource ids match the other list endpoints. Archived projects are not returned.
404.Other unsuccessful response codes: 403, 429, 500.
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()){
"success": true,
"message": "",
"data": {
"name": "Website redesign",
"description": "",
"share_with_client": 1,
"share_with_team": 1,
"created_date": "2026-06-02T14:20:11-07:00",
"project_id": 328,
"tasks": [
{ "name": "Send the June report", "status": "ready_for_work", "assigned_to": "contact 1 (Client)", "due_date": "2026-12-10", "task_id": 3659 }
],
"phases": [
{ "name": "Phase 1", "phase_id": 91, "tasks": [] },
{ "name": "Phase 2", "phase_id": 92, "tasks": [
{ "name": "child 1", "status": "ready_for_work", "assigned_to": "Steve 1", "due_date": null, "task_id": 3702 }
] }
],
"resources": [
{ "type": "page", "name": "Onboarding", "page_id": "9dRt" },
{ "type": "file", "name": "Signed contract", "file_id": "7pQ1" },
{ "type": "conversation", "name": "Q3 planning", "conversation_id": 276 },
{ "type": "password", "name": "Hosting login", "password_id": "4hLq" }
]
}
}{
"success": false,
"message": "Validation Error",
"data": "{\"project_id\":[\"The project id field is required.\"]}"
}{
"success": false,
"message": "Invalid owner email and authorization_token."
}{
"success": false,
"message": "Not Found",
"data": "Project record not found."
}Update a Project
Rename a project and/or change its description — the same as editing the name and description on the project screen. This is a partial update: only the parameters you send are changed.
404.p, br, strong, em, ul, ol, li, a. Max 5000 characters. Empty string clears it.Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
Phase N (next number), as in the app.Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
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.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.
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()){
"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.
resources[0][type] and resources[0][id].page, file, conversation, password.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.
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()){
"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.
1 to send the contact an invitation email with a link to set their password. Default 0.1 to enable this user to add or remove other client users to the portal. Optional, default 0.1 to enable this user to receive notifications and updates by email. Optional, default 0.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.
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()){
"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.
1.20, maximum 100.Other unsuccessful response codes: 403, 429, 500.
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()){
"success": true,
"message": "",
"data": {
"contacts": [
{
"name": "Jane Cooper",
"email": "jane@example.com",
"primary": 1,
"status": "active",
"permissions": {
"allow_add_portal_users": 1,
"email_receive": 1,
"allow_page_add": 0
},
"added_date": "2026-05-02T10:12:44-07:00",
"contact_id": "3Kp9"
},
{
"name": "",
"email": "sam@example.com",
"primary": 0,
"status": "invited",
"permissions": null,
"added_date": "2026-07-19T16:03:21-07:00",
"contact_id": "8Qw2"
}
],
"pagination": {
"total": 2,
"page": 1,
"limit": 20,
"total_pages": 1
}
}
}{
"success": false,
"message": "Validation Error",
"data": "{\"portal_id\":[\"The portal id field is required.\"]}"
}{
"success": false,
"message": "Invalid owner email and authorization_token."
}{
"success": false,
"message": "Not Found",
"data": "Portal record not found."
}Update Portal Preferences
Update the Preferences settings of an existing portal — the same options you see at the top of the portal settings screen. This is a partial update: only the parameters you send are changed, every other portal setting is left untouched.
America/Phoenix, Asia/Kolkata or Europe/London. Fetch the full list →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.align_left or align_center.1 to require 2 step authentication for this portal. Ignored on HIPAA portals, where 2 step authentication always stays on.1 to enable portal search, 0 to disable it.1 to allow file access without login, 0 to require login.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.
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()){
"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.
1 to show the Pages tab, 0 to hide it.1 to show the File Manager tab, 0 to hide it.1 to show the Password Manager tab, 0 to hide it.1 to show the To Do tab, 0 to hide it.1 to show the Conversations tab, 0 to hide it.1 to show the Projects tab, 0 to hide it.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][name] is sent.1 to open the link in the same tab. Default 0, which opens a new tab.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.
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()){
"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.
agency_logo or profile_picture.1 to show the account manager card at all, 0 to hide it. When hidden, the settings below have no visible effect.1 to show the avatar, 0 to hide it.1 to show the name, 0 to hide it.1 to show the job title, 0 to hide it.1 to show the email address, 0 to hide it.1 to show the phone number, 0 to hide it.Every parameter in this group may be sent empty to clear it. A cleared value falls back to the account manager's own details.
1 to enable the request button, 0 to disable it.1 to enable the schedule button, 0 to disable it.1 to open the calendar link inside the portal, 0 to open it in a new tab.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.request, schedule or message.Send at least one setting besides portal_id, otherwise the request is rejected.
Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
1 to enable client users to add or remove other client users to the portal, 0 to disable it.1 to enable client users to receive notifications and updates by email, 0 to disable it.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.
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()){
"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.
jpg, png, jpeg, gif, svg, webp. Max size 5 MB.<style> tags. Send an empty value to clear it. Max 50000 characters.Send at least one setting besides portal_id, otherwise the request is rejected.
Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
off, daily, weekly (each Monday) or monthly (on the 1st).Send at least one setting besides portal_id, otherwise the request is rejected.
Other unsuccessful response codes: 403, 429, 500.
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()){
"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).
address. Together with scope it identifies the variable. Max 255 characters.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.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.
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()){
"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.
1 returns only unread notifications. 0 or omitted returns all, read and unread.1. Each page holds 20 notifications.Other unsuccessful response codes: 403, 429, 500.
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()){
"success": true,
"message": "",
"data": {
"notifications": [
{
"text": "You were assigned Send the June report",
"is_read": 0,
"portal": "Acme Inc",
"portal_id": "vJW4",
"created_date": "2026-06-10T09:15:02-07:00",
"notification_id": 130,
"task_id": 3659
},
{
"text": "Signed contract was approved by Sam Lee (Client)",
"is_read": 0,
"portal": "Acme Inc",
"portal_id": "vJW4",
"created_date": "2026-06-09T17:40:11-07:00",
"notification_id": 129,
"file_id": "ZrV1"
}
],
"unread_count": 12,
"pagination": {
"total": 12,
"page": 1,
"limit": 20,
"total_pages": 1
}
}
}{
"success": false,
"message": "Validation Error",
"data": "{\"page\":[\"The page must be at least 1.\"]}"
}{
"success": false,
"message": "Invalid owner email and authorization_token."
}Mark Notifications Read
Mark your notifications as read, the same as opening a notification or using Mark all read in the app. Send either notification_ids[] for specific notifications or all=1 for every unread one, not both. Only your own notifications can be changed. The response returns how many were marked and your new unread count.
notification_id per entry (from List Notifications), up to 50. An ID that is not yours returns 404 and nothing is changed.1 marks every unread notification as read. Do not send it together with notification_ids[].Other unsuccessful response codes: 403, 429, 500.
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()){
"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.
None. This endpoint takes no parameters.
Other unsuccessful response codes: 403, 429, 500.
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()){
"success": true,
"message": "",
"data": [
"Africa/Cairo",
"America/Phoenix",
"Asia/Kolkata",
"Europe/London",
"..."
]
}{
"success": false,
"message": "Invalid owner email and authorization_token."
}MCP tools
The 71 tools the Ahsuite MCP server (https://ahsuite.com/api/mcp) offers to a connected AI app, exactly as the server describes them. read-only tools only read data; write tools create or change data; confirm tools overwrite, remove or revoke something, so the AI app asks you first; may email client tools can also email or notify your client users. No tool ever accepts or returns a password.
list_portals
read-only
List the client portals of the authenticated Ahsuite account, newest first. Call this first to get a portal_id for the other tools. Supports name search and pagination.
get_portal
read-only
Get the full details of a single portal — name, contact info, language, timezone, preferences, plus nested sidebar (tools, labels, order, custom links), client_user defaults, account_manager card, branding, email_notifications and variables. Field names match the update_portal_* tools, so read here and send changed values back.
reply_to_conversation
may email client
Post a reply in an existing conversation, exactly like replying in the app: the team is notified and, when the conversation is shared with the client, the client is notified and emailed (unless emails are turned off for that conversation). Call get_conversation first to read the thread. Text only here; attachments are added in the Ahsuite app or with the Ahsuite API.
update_conversation
confirm
Rename a conversation and/or turn its email notifications on or off — merge-only. email_not_send = 1 stops emailing the client about this conversation (they still see it in the portal); on HIPAA portals emails are always off and cannot be turned on.
list_tasks
read-only
List the tasks of a portal, in the same order the Ahsuite app shows them (tasks being worked on first). Use this when the user asks about tasks, to-dos, pending work or deadlines in a portal. By default every status except canceled is returned. When showing tasks to the user, display statuses in a friendly form (ready_for_work = "Ready for Work", in_progress = "In Progress", on_hold = "On Hold", done = "Done").
list_conversations
read-only
List the conversations (message threads with the client) of a portal, most recently updated first. new_message=1 means it has unread replies. Use get_conversation to read the messages inside one.
get_conversation
read-only
Read a single conversation — its share settings and the last 25 messages of the thread (oldest of those first). Each message says whether it came from the team or the client.
list_pages
read-only
List the pages (content/dashboard pages built for the client) of a portal, grouped by category in the same order as the Ahsuite app. draft=1 means the page is not visible to the client yet.
get_page
read-only
Get one page of a portal with its content — title, category, draft flag and its tabs. Each tab returns its text block (text.content is HTML) embed block (embed.url = embedded url or embed code, embed.height) image block (image.url = temporary link valid 20 minutes, image.file_name) and files block (files.items[] = the files/file requests placed on the tab: type = file|url, file = uploaded file, file_name = its display name, url = the link or a 20-minute temporary download link, title + description for file requests, is_request, file_id) video block (video.url = YouTube url), code block (code.content = raw HTML/CSS/JS) and questions block (questions.items[] = the form questions: question_type, question_name, description, required, options, variable_name/variable_scope, question_id; section/repeat_section containers nest their child questions in questions[]), each with optional heading/description, or null when the tab has no such block. Use this to read a page before describing or editing it. page_id comes from list_pages.
update_page
confirm
Use this to change an existing page in a client portal. Call get_page first to get the page's tab_id values and current content. Only the fields you send change. At page level you can change the title, the category, and whether the page is a draft (hidden from the client); publishing a draft makes it visible to the client. With a tab_id you can rename the tab and edit or add its blocks: text, code, embed, video, image heading and description, file requests or existing files, and new form questions. Text and code content replace what is there; file items and form questions are added, never removed. To add a new tab, use add_tab. Image and file uploads are done in the Ahsuite app.
add_tab
write Add a new (empty) tab to a page, like "+ Add Tab" in the page editor. Returns the tab_id — then fill it with update_page (text, embed, video, files, questions...). Max 20 tabs per page. Use this when the user wants content on a tab that does not exist yet.
update_question
confirm
Edit one form question of a page — merge-only: only the fields you send change. Only the question text, its description and whether it is required can be changed (as in the app); the type, options and variable are fixed once created — to change those, add a new question with update_page and delete the old one. Call get_page first to get the question_id.
move_question
write Move an existing question of a page into a section / repeat_section on the same tab (like "Add Question" inside a section in the app), or out of its section back to the top level (section_id = empty string, like "Move Out"). Sections themselves and questions with conditional navigation cannot be moved into a section. Call get_page first for the ids. To create a NEW question directly inside a section use update_page with section_id instead.
delete_question
confirm
Delete one form question from a page. DESTRUCTIVE and cannot be undone — ALWAYS call get_page first, show the user the exact question text you are about to delete, and ask for explicit confirmation before calling this tool. Never delete more than the user named. Deleting a section or repeat_section also deletes the questions inside it. Answers the client already gave to the question stay available in the form responses.
get_task
read-only
Get one task of a portal in full — name, description (HTML), status, due date, assignee, project, checklist items, labels, attachments (temporary links valid 15 minutes) and comments (newest first). Use this to read a task before describing or editing it. task_id comes from list_tasks.
update_task
may email client
Update an existing task — merge-only: only the fields you send change. Call get_task first. Fields: name, description (HTML), due_date, status, dependency_task_id (this task waits on another task — it becomes "pending" until that task is done; 0 removes the dependency) and project_id (from list_projects; 0 takes the task out of its project). To change who the task is assigned to use assign_task; to add labels use add_task_labels. Sending an empty string clears description / due_date. Setting status to done notifies the team (and the client when they created the task) and releases tasks that were waiting on this one.
assign_task
may email client
Assign a task to a team member, to the client (any client user) or to one specific client user. Assigning to the client / a client user shares the task with them and sends the "action required" email; assigning to a team member notifies them. Behaves exactly like the assignee dropdown in the app. A task created by the client always stays shared with all client users — team_member adds a team member to it, while client / client_user are rejected for such a task. Get client user emails from list_contacts.
add_task_labels
write Add one or more labels to a task (like "Add Label" in the app). A label with the same name and color already in the portal is reused, otherwise it is created. Existing labels on the task are kept. Max 5 per call.
add_checklist_items
write Add checklist items to a task (like "Add item" on the task screen). Existing items are kept. Returns the new items with their item_id. Max 20 per call.
update_checklist_item
confirm
Rename a checklist item and/or mark it completed / not completed — merge-only, only the fields you send change. item_id comes from get_task (checklist[].item_id) or add_checklist_items.
add_task_comment
may email client
Post a comment on a task, exactly like commenting on the task screen: the comment is written to the task history and the people involved are notified (a client user is notified when the task is shared with them). Plain text; basic HTML (p, br, strong, em, ul, ol, li, a) is allowed.
get_file
read-only
Get one file (or folder / file request) of a portal in full — title, description, status, sharing (is_folder / is_request flags), its versions (newest first, each with a link valid 2 hours), comments and labels. For a folder only its own details and items_count are returned — use list_files with folder_id to see what is inside. file_id comes from list_files.
update_file
may email client
Update a file, file request or folder — merge-only: only the fields you send change. title, description and require_client_approval (files only). Turning require_client_approval on shares the file with the client (and its folder, if any) and EMAILS them asking to review it (or to upload it, for a file request), exactly like the app; it cannot be changed on a file the client already approved. Call get_file first.
add_file_version
write Add a new version to a file as a link (url) — like "Add a file" on the file screen. Older versions are kept; if the client had already approved the file it goes back to "approval required". Uploading a binary file as a version is done in the Ahsuite app or with the Ahsuite API: https://ahsuite.com/api-docs. Max 70 versions per file.
add_file_comment
may email client
Post a comment on a file or file request, exactly like commenting on the file screen: the comment is written to the file history and the people involved are notified (client users are notified when the file is shared with them). Plain text; basic HTML (p, br, strong, em, ul, ol, li, a) is allowed.
move_file
confirm
Move a file (not a folder) into a folder of the same portal, or back to the top level with an empty folder_id — like drag-and-drop in the app. The file takes over the sharing of the folder it lands in.
add_file_labels
write Add one or more labels to a file or file request (like "Add Label" on the file screen). A label with the same name and color already in the portal is reused, otherwise it is created. Existing labels on the file are kept. Max 5 per call.
list_files
read-only
List the files of a portal — uploads, links, file requests and folders (folders first). Root level by default; pass folder_id (the file_id of a folder row) to list inside a folder. status shows client approval state, share_with_client whether the client can see it. When showing files to the user, display statuses in a friendly form (client_approval_required = "Client Approval Required", request_file = "Requested", file_submitted = "Submitted").
list_journals
read-only
List the work journals of a portal, newest first. Journals are internal work logs the team keeps per client.
get_journal
read-only
Get one work journal: name, created date and its newest 25 log entries (newest first, HTML) with author and date. Use list_journals to find the journal_id.
update_journal
confirm
Rename a work journal. The name is the only editable field of a journal; log entries are added with add_journal_log.
add_journal_log
write Add a log entry to a work journal (like "Save Log" in the journal popup). Text only; @name mentions of team members notify them. The api owner is recorded as the author.
create_task
may email client
Create ONE new task in a portal. Call once per task — a successful call means the task exists, do not retry it. By default the task is private to the team; set assign_to_client=1 to assign it to the client (the client is then notified by email).
create_conversation
write Start ONE new conversation (message thread) in a portal. Call once per conversation — a successful call means it exists, do not retry it. By default it is private to the owner; set share_with_client=1 so the client can see it, share_with_team=1 for the whole team.
create_portal
write Create ONE new client portal (a client workspace). Call once — a successful call means it exists, do not retry it. The contact email becomes the primary client user of the portal. If the user has not said whether to use their default portal template (blueprint), ask them first — apply it (default) or create a clean portal (apply_template 0).
create_page
write Use this to create one new page in a client portal. A page can include a text block, a code block (HTML/CSS/JS), an embedded website, a video, form questions the client answers, and file requests. Call it once per page. If the user hasn't said what the page should contain, ask before creating it. Image and file uploads are added in the Ahsuite app. Returns the new page_id.
create_file
write Add ONE item to the Files section of a portal: a link, a file request (asks the client to upload something), or a folder. Send exactly one of link/request/folder per call. If it is unclear which of the three the user wants, ask before calling. Direct file uploads can only be done from the Ahsuite app.
create_journal
write Create ONE new work journal in a portal. Journals are internal work logs the team keeps per client.
create_password
may email client
Saves a login's name, address and username in a portal's password manager. It never takes a password, and passwords don't belong in any field, note, task, comment or message. Without options, the login is marked Password needed and the result has add_password_url, where the user adds the password in Ahsuite. With request, Ahsuite emails that client user to add it in their portal. With generate, Ahsuite makes a strong password the user can reveal in Ahsuite.
list_passwords
read-only
List the saved logins of a portal, newest first: name, address, username, sharing, password status (needed / requested / set), when the password last changed and by whom, the open request if any, and add_password_url when the password is still needed. No field ever contains a password or any part of one.
create_invoice
may email client
Create ONE invoice in a portal. IMPORTANT: the client is always emailed an "action required" notice when the invoice is created — confirm with the user before calling. Never invent line items, prices or dates — if the user has not given them, ask. The total is computed server-side from the items.
list_invoices
read-only
List the invoices of a portal, newest first — number, dates, amount, currency and status (due / paid / payment_processing / cancelled / refunded).
get_invoice
read-only
Get one invoice in full: number, dates, currency, status, total, notes and every line item with its add / subtract sub-items. Use list_invoices to find the invoice_id.
update_invoice
confirm
Edit the header of an UNPAID invoice — number, dates, currency, notes (merge-only). Paid / processing / cancelled / refunded invoices cannot be edited. Line items are NOT changed here: use add_invoice_item / update_invoice_item. No email is sent. Never invent dates — ask the user.
add_invoice_item
write Add ONE line item (with optional add / subtract adjustments) to an UNPAID invoice. Existing items stay as they are; the invoice total is recomputed. Call once per item. Never invent prices — ask the user.
update_invoice_item
confirm
Edit ONE line item of an UNPAID invoice — merge-only (only the fields you send change). sub_items: give sub_item_id (from get_invoice) to edit an existing adjustment, omit it to append a new one. Nothing is deleted here (use remove_invoice_item / remove_invoice_adjustment for that); the invoice total is recomputed. Get item_id / sub_item_id from get_invoice first.
remove_invoice_item
confirm
Remove ONE line item (with all its adjustments) from an UNPAID invoice — "Remove Item" in the app. This cannot be undone: confirm with the user first. To remove only a single adjustment use remove_invoice_adjustment. The invoice total is recomputed.
remove_invoice_adjustment
confirm
Remove ONE add / subtract adjustment from a line item of an UNPAID invoice; the item itself stays. This cannot be undone: confirm with the user first. The invoice total is recomputed.
mark_invoice_paid
confirm
Mark an unpaid invoice as paid (manual payment), same as the "Mark Paid" button in the app. This cannot be undone through the API — confirm with the user before calling.
create_project
write Create ONE new project (service contract with time tracking) in a portal.
list_projects
read-only
List the projects of a portal (service contracts) — name, client sharing and created date.
get_project
read-only
Get one project of a portal in full — name, description, sharing, its tasks (top-level tasks in tasks[], and phases[] each with the tasks inside it, in the order the project screen shows them) and its resources (linked pages, files, conversations, passwords). project_id comes from list_projects.
update_project
confirm
Rename a project and/or change its description — merge-only: only the fields you send change. Sending an empty description clears it. Call get_project first.
add_project_phase
write Add a phase to a project (like "Add Phase" on the project screen). Phases group the project tasks; new phases go to the end. Returns the phase_id to use with update_project_phase / add_project_tasks.
update_project_phase
confirm
Rename a phase of a project. phase_id comes from get_project (phases[].phase_id).
add_project_tasks
write Put existing tasks of the portal into a project (like "Select tasks" on the project screen), optionally into a specific phase; also use it to move a task that is already in the project to another phase. Tasks come from list_tasks; canceled or archived tasks are rejected. To create a brand-new task inside a project use create_task and then this tool.
add_project_resources
write Link existing pages, files, conversations or passwords of the portal to a project (like "Add Resources" on the project screen). Already-linked resources are skipped. Ids come from list_pages / list_files / list_conversations / list_passwords.
create_contact
may email client
Add ONE client user (contact) to a portal. No invite email is sent unless send_client_email_notify=1 — ask the user whether to send it.
list_contacts
read-only
List the client users (contacts) of a portal — name, email, whether they are the primary contact, invite status (invited/active/declined), permissions. Primary contact comes first, like the Client Users list in the app. Invited and declined contacts return only their email and status (name is blank, permissions null), exactly as the app shows them.
update_portal_preferences
confirm
Update the Preferences settings of a portal — name, phone, timezone, language, content alignment and security/visibility toggles. PARTIAL update: only the fields you send are changed. Use list_timezones for valid timezone values.
update_portal_sidebar
confirm
Update the portal sidebar menu — show/hide tools, rename their labels, reorder them, and add custom link tabs. PARTIAL update: only the fields you send change. links are added, or updated when a tab with the same name already exists; existing custom links are always kept (removing a link is done on the portal settings page in the app).
update_client_user_defaults
confirm
Update the portal-level DEFAULT permissions applied to newly added client users (existing users are not changed). PARTIAL update.
update_account_manager
confirm
Update the Account Manager card the client sees in a portal — avatar, displayed contact fields, text overrides and action buttons. PARTIAL update. Blank text overrides fall back to the real account manager details.
update_portal_branding
confirm
Update the branding of a portal — custom CSS and the client website shown in the portal. PARTIAL update. The portal logo can only be changed from the Ahsuite app.
update_email_notifications
confirm
Update the email notification settings of a portal — the summary email frequency and the templates of the emails clients receive. PARTIAL update per email; sending a blank subject/body resets that field to the default template.
update_portal_variables
confirm
Create or update custom variables of a portal (upsert by name+scope, max 20 per call — variables cannot be deleted here). scope "portal" = one shared value; scope "user" = per-client-user values set in the app, so no value is accepted for user scope.
list_timezones
read-only
List every timezone identifier accepted by update_portal_preferences. No parameters.
list_notifications
read-only
List the user's own notifications across all portals, newest first (task assigned / done / mention, file approved, conversation, invoice paid ...). Not per portal — no portal_id. Set unread_only 1 for just the unread ones (what the user usually means by "what's new"). Each row has the text, portal name and, when available, the portal_id and task_id / file_id / page_id / conversation_id / journal_id to follow up with the get_* tools.
mark_notifications_read
write Mark the user's notifications as read. Send notification_ids (from list_notifications, max 50) to mark specific ones, or all 1 to mark every unread notification as read. Only mark notifications read when the user asks for it.