Build on AI Phone 360
Programmatically access your calls, transcripts, recordings and contacts, send SMS, answer email from your own domains, react to events in real time with webhooks, and let your AI receptionist call your own systems live during a conversation — all over a clean REST API.
Getting access
The developer platform is a premium capability that runs on the same billing model as the rest of AI Phone 360 — a paid subscription plus metered usage.
- A paid AI Phone 360 organization with an active subscription (BASIC, PRO, or ENTERPRISE).
- The Developer Platform add-on enabled on your organization. During the beta this is granted by our team — request access.
- A positive credit balance (the same wallet that powers your calls & texts).
Once your organization has the add-on, generate API keys from the dashboard at Settings → Developer, or via the keys API.
402 until you add funds. Webhooks that fire while you're blocked are queued and delivered once you reload (up to 30 days).
Quickstart
Every request goes to https://api.aiphone360.com/api/public/v1 and is authenticated with your API key as a Bearer token.
1. List your most recent calls
curl https://api.aiphone360.com/api/public/v1/calls?limit=5 \
-H "Authorization: Bearer aip_live_your_key_here"
2. Send a text message
curl -X POST https://api.aiphone360.com/api/public/v1/messages \
-H "Authorization: Bearer aip_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"to":"+15555550123","body":"Thanks for calling!"}'
That's it. Responses are JSON in a consistent envelope (see Errors for the shape). For TypeScript/Node, the official SDK wraps all of this with types, retries, and webhook verification.
Authentication
Authenticate every request with an API key, sent as a Bearer token:
Authorization: Bearer aip_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
(The header x-api-key: <key> is also accepted.) Keys come in two modes, distinguished by prefix:
| Prefix | Mode | Behavior |
|---|---|---|
aip_live_ | Live | Real data, real side-effects, metered & billed. |
aip_test_ | Test (sandbox) | Never bills, never sends real SMS or triggers real side-effects. See Test mode. |
Billing & credits
The platform bills like everything else in AI Phone 360: your monthly subscription is required for access, and usage is metered against your credit wallet on top of a generous monthly free quota.
| Metered action | Unit | Notes |
|---|---|---|
| API request | per 1,000 requests | Billed in 1,000-request blocks after your monthly free quota. |
| Webhook delivery | per delivery attempt | Every attempt counts (including retries) so you can rely on at-least-once delivery. |
| Custom-tool invocation | per call | Each time the AI calls one of your endpoints (voice or chat). |
| SMS sent via API | per segment | Billed through your existing SMS usage (carrier passthrough) — same rate as the app. |
Exact per-unit rates and free quotas depend on your plan and are shown on your dashboard. Everything is recorded — you (and our team) can see every billed request, webhook, and tool call, tagged by API key, in usage reporting.
aip_test_ key are counted for visibility but never charged and never debit your wallet.Scopes & keys
Each API key is granted a set of least-privilege scopes. A request to an endpoint whose scope your key lacks returns 403 INSUFFICIENT_SCOPE. Grant a key only what it needs. The dashboard picker offers all 15 scopes below; only read:calls is selected by default. For conversation automation select read:conversations and write:conversations, plus write:email for email replies and manage:mailboxes for mailbox management. Existing keys keep their scopes: create a new key to grant additional access.
| Scope | Grants |
|---|---|
| read:calls | List & read calls. |
| read:transcripts | Read call transcripts. |
| read:recordings | Read call recording URLs. |
| read:contacts | List & read contacts. |
| write:contacts | Create & update contacts. |
| write:sms | Send SMS via POST /messages. |
| read:usage | Read config & list your keys. |
| manage:config | Update AI config; create/revoke keys; define contact custom fields. |
| manage:webhooks | Manage webhook endpoints & deliveries. |
| manage:tools | Manage custom tools. |
| manage:agents | Manage specialized AI agents. |
| read:conversations | List & read SMS / live-chat conversations, their messages and internal notes, the human-handoff queue, and team members. |
| write:conversations | Reply to live chat, add internal notes, assign, change status, tag, and request / clear a human handoff. Live-chat replies require this scope alone. SMS and email replies also need the channel's send scope: write:sms on an SMS thread, write:email on an email thread. |
| write:email | Send email replies on email conversations (together with write:conversations). |
| manage:mailboxes | Add / verify / delete your email domains and create / update / delete mailboxes (addresses, display names, signatures). |
read:conversations also covers email conversations and their attachment links.
Test mode
Use a aip_test_ key to build and test safely:
- Reads return your real data (read-only is safe).
POST /messagesandPOST /conversations/{id}/replyreturn a synthetic success — no real SMS, email or live-chat message is sent, no carrier charge, and nothing is added to the thread.- Adding or deleting an email domain needs a live key (it registers the domain with our email provider); listing, verifying and mailbox changes work with either.
- Notes, assignments, status, tags and handoff flags made with a test key are saved (they are your real inbox) — their webhooks are delivered to
test-mode endpoints only. - Webhooks & tool calls triggered in test mode are flagged and never billed.
- Test usage never debits your wallet and never blocks on balance.
Switch to a aip_live_ key when you're ready for real side-effects.
Pagination
List endpoints are cursor-paginated. Pass limit (max 100) and the cursor from the previous page's pagination.nextCursor:
let cursor;
do {
const page = await client.calls.list({ cursor, limit: 100 });
for (const call of page.data) { /* … */ }
cursor = page.pagination.nextCursor;
} while (cursor);
Errors
Every response uses a consistent envelope. Success:
{ "success": true, "data": { …payload… }, "message": "…" }
Failure carries a stable machine code and HTTP status:
{ "success": false, "error": { "message": "…", "code": "INSUFFICIENT_SCOPE", "status": 403 } }
| Code | Status | Meaning |
|---|---|---|
API_KEY_REQUIRED | 401 | No key supplied. |
INVALID_API_KEY | 401 | Key not found, revoked, or expired. |
PLATFORM_ADDON_REQUIRED | 402 | Developer add-on not enabled for this org. |
SUBSCRIPTION_REQUIRED | 402 | No active paid subscription. |
INSUFFICIENT_CREDITS | 402 | Wallet is out of credits — add funds to resume. |
INSUFFICIENT_SCOPE | 403 | Key lacks a required scope. |
BUSINESS_SUSPENDED | 403 | Organization suspended for abuse/compliance. |
RESOURCE_NOT_FOUND | 404 | The id doesn't exist (or isn't yours). |
VALIDATION_ERROR | 400 | Request body/query failed validation. Live-chat reply bodies must contain 1–2,000 characters. |
RATE_LIMIT_EXCEEDED | 429 | Too many requests — back off (honor Retry-After). |
CONVERSATION_NOT_FOUND / CONTACT_NOT_FOUND / CALL_NOT_FOUND | 404 | The id doesn't exist (or isn't yours). |
DNC_BLOCKED / CONTENT_BLOCKED / ABUSE_BLOCKED | 422 | A send was refused by the compliance gate (recipient opted out, content flagged, org suspended). |
CHANNEL_NOT_SUPPORTED / CHANNEL_MISMATCH | 422 | The reply channel isn't available for this conversation (e.g. channel: "email" on an SMS thread). |
CONVERSATION_CLOSED | 409 | The live-chat session is closed. |
NO_RECIPIENT | 422 | The live chat has no visitor, or the conversation has no usable recipient. |
COMM_SERVICE_UNAVAILABLE | 502 | The chat bridge could not obtain a valid reply result, including a missing endpoint or service-authentication failure. Delivery may be unconfirmed; inspect the thread before issuing a new send. |
CHAT_SERVICE_UNAVAILABLE | 503 | The chat service is unavailable. Inspect the thread before retrying with a new idempotency key because delivery may be unconfirmed. |
EMAIL_RECIPIENT_SUPPRESSED | 422 | The recipient hard-bounced or reported your mail as spam; email to them is blocked (the email equivalent of an SMS STOP). |
REPLY_TO_AUTOMATED_MESSAGE | 422 | The latest message on the thread was an auto-reply or system mail. Pass replyToAutomated: true to reply anyway. |
EMAIL_REPLY_RATE_LIMITED | 429 | More than 20 email replies on one conversation in an hour (mail-loop backstop). |
DOMAIN_NOT_VERIFIED / MAILBOX_UNAVAILABLE | 422 | The thread's mailbox is on a domain that isn't verified, or the mailbox was disabled / deleted. |
EMAIL_PROVIDER_UNAVAILABLE | 502 | The email provider didn't answer; nothing was sent. Safe to retry with a new Idempotency-Key. |
DOMAIN_NOT_ALLOWED / DOMAIN_ALREADY_REGISTERED / DOMAIN_HAS_MAILBOXES | 422 / 409 / 409 | Shared mail domains (gmail.com…) can't be added; the domain is already registered; delete its mailboxes first. |
DOMAIN_NOT_REGISTERED / ADDRESS_IN_USE | 422 / 409 | Add the mailbox's domain first; the address already exists. |
ATTACHMENT_NOT_FOUND / ATTACHMENT_EXPIRED | 404 / 410 | No such attachment on that message (or not yours); the provider no longer has the file. |
INVALID_FROM_NUMBER / NO_SENDER_NUMBER | 422 | from isn't one of your active numbers / you have no active number. |
INVALID_IDEMPOTENCY_KEY | 400 | The Idempotency-Key header is malformed. |
IDEMPOTENCY_KEY_REUSED | 422 | The key was already used for a different request. |
IDEMPOTENCY_REQUEST_IN_PROGRESS | 409 | The first request with this key is still running — retry shortly. |
UNKNOWN_FIELD / INVALID_FIELD_VALUE / RESERVED_FIELD | 422 | Custom-field write names an undefined field, has the wrong type, or targets a contact attribute. |
Rate limits
Requests are rate-limited per API key. On 429, honor the Retry-After header and back off. The official SDK retries 429 and 5xx automatically with exponential backoff.
Idempotency
Sends (POST /messages, POST /conversations/{id}/reply — SMS and email) accept an optional Idempotency-Key header (1–255 visible ASCII characters, e.g. a UUID or order-1234-shipped). If a request times out, retry it with the same key and the same body: you get the original response back (with Idempotent-Replayed: true) and the customer is not texted or emailed twice.
- Keys are scoped to your organization and to the key mode, and kept for 24 hours. A key used with an
aip_test_key is separate from the same key used with anaip_live_key, so switching from test to live never replays a test response. - Same key with a different body or endpoint →
422 IDEMPOTENCY_KEY_REUSED. - Same key while the first request is still running →
409 IDEMPOTENCY_REQUEST_IN_PROGRESS. - Every final response is stored, errors included. A
502may still have sent the text (the hand-off timed out), so retrying a 502 with the same key replays the 502. Use a new key only when you deliberately want to try again.
Calls
List calls (newest first). Scope read:calls. Query: limit, cursor, direction, dateRange, search.
Full detail for one call. Scope read:calls. Includes a flat callSummary block for integrations:
{ "callSessionId", "direction", "callerNumber", "businessNumber", "contactId", "contactName",
"startedAt", "endedAt", "durationSeconds", "status", "handledBy",
"summary", "oneLineSummary",
"recording": { "available", "status", "durationSeconds", "endpoint" },
"transcript": { "available", "endpoint" } }
The recording link itself is short-lived and signed — fetch it from the recording.endpoint (needs read:recordings).
The call transcript (speaker-attributed turns). Scope read:transcripts.
A recording URL (when available). Scope read:recordings.
Contacts
List contacts. Scope read:contacts. Query: limit, cursor, search.
One contact. Scope read:contacts.
Create a contact. Scope write:contacts. Body: phoneNumber (required), name, email, company.
Update a contact. Scope write:contacts.
Exact lookup by phone or email (or both). Scope read:contacts. Phones are normalized to E.164 first, so (512) 555-0199, 512.555.0199 and +15125550199 all match; email matching is case-insensitive. Returns { data: [contact…], query: { phone, email } } — an empty data means no match (not a 404). At most 10 matches.
Everything that happened with this person, across channels, newest first: calls (with one-line summary) and SMS, email and live-chat messages (live chat is matched by the visitor's email/phone). Scope read:contacts. Query: types (call,message), includeNotes (default true), limit, cursor. Each item has type (call|message) and occurredAt.
Custom fields
Custom fields are the same typed fields your AI collects on calls (Settings → Data collection), so values written here show in the dashboard and values the AI collected are readable here. Types: text, number, date (ISO 8601), select (one of its options), boolean.
List field definitions. Scope read:contacts.
Define a field. Scope manage:config. Body: name, type, options[] (select), collectByAi (default false — the field is stored but the AI does not start asking callers for it; set true deliberately).
Read / write values. Scope read:contacts / write:contacts. Body: { "fields": { "Order Number": 1234, "Store": "Dallas", "Serial": null } } — field names are matched case-insensitively, null clears a value. Values you write are marked as coming from your system, and the AI will not overwrite them on a later call. Fires contact.updated with changedFields.
Messages (SMS)
Send an SMS from your AI Phone 360 number. Scope write:sms. Body: to, body, and optionally from (one of your active numbers; default = your primary) or callSessionId — text a caller back from the number they called: to defaults to the caller and from to the number on that call. Response: { sent, sid, to, from, messageId, conversationId }. The message is recorded as sent by your API key.
422. This is the same compliance chokepoint the app uses — it protects your number's reputation. Pass an Idempotency-Key header so retries never double-send (see Idempotency).Conversations (inbox)
Your SMS, email and live-chat threads. Every request is scoped to the organization that owns the API key — another organization's conversation is always a 404. Email threads appear in the web dashboard inbox alongside calls, SMS and live chat. The Email tab shows subject, sender and mailbox; open a thread for From, To, Cc, subject, plain-text messages, attachment names/types/sizes and sender attribution. The Needs a person tab highlights handoffs and their internal notes. Email is read-only in the dashboard; reply through the conversations API or your mailbox. HTML is displayed as text and remote content is never loaded. The mobile inbox continues to show SMS and live chat.
Most recently active first. Scope read:conversations. Filters: channel (sms|email|live_chat), mailboxId (email threads that arrived on that mailbox), status (open|pending|closed|archived), assignee (a team member's userId, none or any), needsHuman (true|false), tag, phone, email, plus limit / cursor.
{ "id", "channel": "sms", "status": "open",
"contact": { "id", "name", "phoneNumber" }, "contactPhoneNumber",
"assignedTo": { "userId", "teamMemberId", "assignedAt" } | null, "tags": ["VIP"],
"needsHuman", "needsHumanAt", "needsHumanReason",
"unreadCount", "lastMessageAt", "lastMessageDirection", "createdAt", "updatedAt" }
Email threads also carry contactEmail and email: { mailboxId, mailbox, subject, participants[] }. email= matches the thread's customer address as well as contacts with that email.
One conversation plus its messages (newest first). Scope read:conversations. Query: messagesLimit (default 50, max 200), messagesBefore (the messagesPagination.nextCursor of the previous page), includeNotes (default true). Each message:
{ "id", "direction": "inbound"|"outbound"|"system", "senderType", "body", "media",
"status": "sent"|"delivered"|"undelivered"|"failed"|"received"…, "errorCode",
"isInternalNote", "noteKind": "note"|"suggested_reply"|null,
"actor": { "actorType": "agent"|"api_key"|"ai", "actorId", "apiKeyId" } | null,
"source", "sentAt", "deliveredAt", "createdAt" }
Messages on email threads have channel: "email" and an email block:
"email": { "subject", "from", "fromName", "to": [], "cc": [], "mailboxId", "messageId", "inReplyTo",
"html", "senderVerified", "authentication", "autoSubmitted", "bodyTruncated",
"attachments": [ { "id", "filename": "invoice.pdf", "contentType": "application/pdf", "size": 13264, "inline": false } ] }
body is the plain-text part. html is sanitized before it is stored (scripts, styles, forms, frames, event handlers, javascript:/data: URLs and remote CSS are removed) — still treat it as untrusted and render it in a sandboxed frame. Attachments are metadata only; use the link endpoint below to download.
actor records who sent each message: a signed-in team member (agent, actorId = their user id), one of your API keys (api_key, apiKeyId), or the AI (ai). Messages from before this was recorded have actor: null.
Reply on the conversation's own channel. Supports Idempotency-Key. Body: body, optional channel (defaults to the conversation's channel).
- SMS — scopes write:conversations + write:sms.
bodyup to 1,600 characters. The text goes to the conversation's customer from the number the thread lives on, through the same STOP / DNC / content gate as every other send (422when blocked). - Email — scopes write:conversations + write:email.
body(plain text, up to 50,000 characters), optionalhtml(sanitized; default =bodyas HTML), optionalreplyToAutomated,cc(up to 10 bare email addresses) andreplyTo(one bare address for Reply-To). Cc and Reply-To reject malformed addresses and header controls. Cc is normalized and deduplicated, omitting the To address and all of your own mailboxes. A suppressed Cc recipient blocks the entire send. Cc bounces and complaints suppress only the affected address; the message status follows its primary recipient. A failed copy emitsmessage.failedwithrecipientandrecipientType: "cc". Verified copied participants can reply on the same thread. Both connected SMTP mailboxes and the email provider support these fields. Partial SMTP acceptance returnsrejectedRecipients. If the primary recipient refuses the message,sentis false and its message status is failed even if copies accepted it. Permanent refusals suppress only the refused address; temporary refusals do not. Refused copies are not added as thread participants. Do not resend to addresses that already accepted the message. The reply goes to the thread's customer, from the mailbox the thread arrived on (its display name and signature are added), withRe:subject andIn-Reply-To/Referencesheaders so it threads in the customer's mail app. It carriesAuto-Submitted: auto-repliedso the far side's autoresponders stay quiet. Blocked with422for suppressed recipients (bounced / spam-reported), unverified domains and disabled mailboxes; refused on a thread whose latest message is an auto-reply unlessreplyToAutomated: true. Response:{ sent, channel: "email", conversationId, messageId, providerEmailId, to, cc, replyTo, from, subject }. Attachments on replies are not supported yet. - Live chat — only write:conversations is required. Send
body(1–2,000 characters). The reply is saved with your API key as its actor and delivered to the website visitor and dashboard, through the same path as a team reply. An assistant-mode chat switches to human mode; closed chats return409 CONVERSATION_CLOSED. A missing comms endpoint or a service-authentication failure returns502 COMM_SERVICE_UNAVAILABLE; deploy the comms reply endpoint before the backend route. Test keys return synthetic success without saving, changing mode or delivering anything.
Add an internal note. Scope write:conversations. Body: body. Notes are visible to your team and the API only — never sent to the customer, never billed, never counted as an outbound message, and they don't change the conversation's last-message or unread state. Fires note.created.
Body: { "teamMemberId": "…" } (from GET /team-members) or { "teamMemberId": null } to unassign. Scope write:conversations. Fires conversation.assigned when the assignee changes.
pending means waiting for the customer. Pending threads remain visible in web and mobile inboxes. A human inbound email or SMS reopens a pending thread; a visitor message reopens a pending live chat. Each real transition emits conversation.status_changed with previousStatus and status.
Body: { "status": "open" | "pending" | "closed" | "archived" }. Scope write:conversations. Fires conversation.status_changed (with previousStatus) when it changes. A customer texting into a pending, closed or archived SMS conversation reopens it (and fires the same event with reason: "inbound_message"); a customer's email does the same (reason: "inbound_email") — an auto-reply never reopens a thread.
Body: { "add": ["Order issue"], "remove": ["VIP"] } — tag names, matched case-insensitively; new names are created for you. Scope write:conversations.
A fresh, short-lived (about 1 hour), pre-signed download link for an attachment on an inbound email: { id, filename, contentType, size, downloadUrl, expiresAt }. Scope read:conversations. Recorded in the access log. Links are never put in webhooks or message payloads — ask for one when you need the file.
Active team members you can assign to: { teamMemberId, userId, name, role }. Scope read:conversations.
Human handoff
When your own AI can't finish a conversation, hand it to a person:
Scope write:conversations. Body (both optional): reason, suggestedReply. Sets needsHuman: true; a suggestedReply is saved as an internal note with noteKind: "suggested_reply" (it is not sent). Fires conversation.needs_human the first time the flag is set.
Clear the flag. It also clears automatically when a team member replies to an SMS from the dashboard or app. Handoff, notes, assignment, status and tags work the same on email threads.
The agent queue is just a filter: GET /conversations?needsHuman=true&assignee=none — flagged and nobody has picked it up.
Email (domains & mailboxes)
There are two ways to bring a mailbox in. Provider mailboxes (this section) route a domain you control through us with DNS records. Connected mailboxes (next section) attach an address that already lives at SiteGround, Bluehost, Google, Microsoft or any IMAP/SMTP host, with no DNS change. Both appear in GET /email/mailboxes (connectionType: provider or imap_smtp) and both work with the same conversations API.
Answer customer email through the same conversations API, from your own domains — for example support@autoskyus.com and customerservice@promasterelectronic.com on one account, each with its own display name and signature. Scope manage:mailboxes for everything in this section.
1. Add and verify a domain
Body: { "domain": "autoskyus.com", "receiving": true }. Returns the domain with status: "not_started" and the DNS records[] to add at your DNS provider ({ record, name, type, value, ttl, priority, status }), plus plain-language instructions:
| Record | Type | What it does |
|---|---|---|
DKIM — resend._domainkey | TXT | Signs your mail so receivers can verify it came from you. |
SPF — send | MX + TXT | Authorizes our sending servers on a return-path subdomain, so it never conflicts with an SPF record you already have on the apex. |
| Receiving — inbound MX | MX | Only when receiving: true. Routes the domain's incoming mail to AI Phone 360. |
mail.autoskyus.com → support@mail.autoskyus.com), or set your current mailbox to forward to an address on a verified domain.We also recommend a DMARC record (_dmarc TXT, starting with v=DMARC1; p=none) once SPF and DKIM pass. Shared mail domains (gmail.com, outlook.com…) can't be added.
Checks the records and returns the domain with an updated status (pending → verified, or failed) and per-record status. DNS can take up to 72 hours; call it again later. Nothing is sent from or received on a domain until it is verified.
Delete is refused (409 DOMAIN_HAS_MAILBOXES) while mailboxes use the domain.
2. Create mailboxes
Body: address (on one of your domains, exact match), displayName, optional signatureHtml / signatureText (HTML is sanitized; the text version is derived from it when omitted), isDefault (your first mailbox is the default; one default per account), enabled. Returns the mailbox with canSend (enabled and domain verified).
A disabled or deleted mailbox stops receiving, and replies on its threads are refused (they are never silently sent from another address).
How incoming email becomes a conversation
- One conversation per email thread (not per customer). A reply joins its thread through the
In-Reply-To/Referencesheaders — only when the sender is already part of that thread; otherwise by the same mailbox + same customer address + same subject (ignoringRe:,Fwd:,AW:… prefixes) within 30 days. Anything else starts a new thread. - A new address creates a contact with that email (it shows in
/contacts/lookup?email=and in the contact's history). - Auto-replies and system mail (out-of-office,
Auto-Submitted, mailing lists, mailer-daemon) are stored withautoSubmitted: truebut never reopen a closed thread. Mail sent from one of your own mailboxes is ignored (loop protection). - Sender verification. The From address of an email is a claim, not proof. Each inbound message carries
authentication(spf,dkim,dmarc:pass,fail,gray,processing_failedorunknown, computed by our receiving server, so a sender cannot forge them) andsenderVerified, which istrueonly when DMARC passed. Mail that fails DMARC is never added to the thread or contact it claims: it gets its own new conversation and a separate contact. Do not act on identity-sensitive requests (address changes, refunds, account details) whensenderVerifiedisfalse. Outbound messages havesenderVerified: null. - Size limits: plain text up to 100,000 characters (
bodyTruncated: truebeyond that); HTML over 200,000 characters is dropped and the text kept. - Fires
email.received.
Deliverability and suppression
Replies are signed with your domain's DKIM and sent through the SPF-authorized return path. A permanent bounce or a spam complaint adds the address to your account's suppression list, and further replies to it are refused with 422 EMAIL_RECIPIENT_SUPPRESSED; the outbound message gets message.failed with errorCode: "BOUNCED". Temporary bounces are retried by the provider and don't suppress.
Connect an existing mailbox (IMAP + SMTP)
Already answering support@yourdomain.com at SiteGround, Bluehost, Google Workspace, Microsoft 365 or another host? Connect it as it is: no DNS change and no new mail service. We read new mail from its INBOX over IMAP and send your replies through its own SMTP server, from that exact address, so replies also show up in its Sent folder. Scope manage:mailboxes; creating or changing a connected mailbox needs a live key.
1. Test the settings (nothing is saved)
{
"address": "support@yourdomain.com",
"imap": { "host": "imap.yourhost.com", "port": 993, "security": "tls" },
"smtp": { "host": "smtp.yourhost.com", "port": 587, "security": "starttls" },
"password": "app-password-here"
}
Logs in to IMAP and authenticates to SMTP, then disconnects. It reads no mail and sends no mail. Returns 200 with { ok, imap: { ok, code, message }, smtp: { ok, code, message } }; a refused login is ok: false, not an HTTP error. username (inside imap / smtp) defaults to the address; add smtpPassword only if SMTP uses a different password.
2. Create the mailbox
The same body as above plus "connectionType": "imap_smtp", displayName and the optional signature fields. Optional: lookbackDays (0–30, default 7: how far back the first sync reaches), sentFolder, sentCopy (auto | always | never) and trustedAuthServIds (see sender verification). The connection is tested first; if it fails you get 422 MAILBOX_CONNECTION_FAILED and nothing is stored. The password is encrypted at rest and is never returned by any endpoint (responses say hasPassword: true).
PATCH accepts the same connection fields. Changing a host, port, username or password, or re-enabling the mailbox, re-tests the connection first and saves nothing if it fails. Moving IMAP or SMTP to a different host requires the password again in the same request (400 MAIL_PASSWORD_REQUIRED otherwise): a stored password is only ever sent to the server it was entered for. enabled: false stops reading and sending at once. The stored-settings test records its result on the mailbox (a passing test re-enables a mailbox that was disabled after failed logins).
Connection state
Connected mailboxes return connectionState: { status, lastSyncAt, lastSuccessAt, lastError, lastErrorCode, lastErrorAt, consecutiveAuthFailures, disabledReason, nextSyncAt, importedCount }. lastError is a fixed, display-safe sentence; it never contains the server's raw reply.
| status | Meaning | What happens |
|---|---|---|
connected | The last sync or test worked. | INBOX is checked about every 2 minutes. Replies send. |
unreachable | The server could not be reached, timed out, or failed TLS. | Retries back off from 2 minutes up to an hour. Replies are still attempted. |
auth_failed | The server rejected the login. | Retried every 30 minutes. Replies are refused (422 MAILBOX_NOT_CONNECTED). |
disabled | disabledReason: "auth_failed" after 3 rejected logins in a row, or "manual". | No more logins, so we never get your account locked. Fix it by PATCHing the new password (or passing a stored-settings test). |
Settings by provider
| Provider | IMAP | SMTP | Password |
|---|---|---|---|
| SiteGround | your server host (Site Tools → Email → Accounts → Mail Configuration, e.g. gvam1234.siteground.biz), 993 TLS | same host, 465 TLS | The mailbox password. |
| Bluehost | mail.yourdomain.com or the server host shown under Email → Connect Devices, 993 TLS | same host, 465 TLS | The mailbox password. |
| Google Workspace / Gmail | imap.gmail.com, 993 TLS | smtp.gmail.com, 465 TLS or 587 STARTTLS | An app password (Google Account → Security → 2-Step Verification → App passwords). IMAP must be on in Gmail settings. Gmail files sent mail in Sent itself. |
| Microsoft 365 / Outlook.com | outlook.office365.com, 993 TLS | smtp.office365.com, 587 STARTTLS | Not supported yet. Microsoft has retired password ("basic") sign-in for IMAP and is retiring it for SMTP, so these mailboxes need OAuth sign-in, which is not available in this release. Until then, forward the mailbox to one you can connect, or use a provider mailbox on a subdomain. |
Allowed ports: IMAP 993 (tls) or 143 (starttls, required); SMTP 465 (tls) or 587 (starttls, required). Port 25 and unencrypted connections are refused, and server certificates must be valid for the host name. Hosts must be public host names (no IP addresses, no private or internal networks). Free-mail addresses (gmail.com, outlook.com, yahoo.com, icloud.com…) can only be connected through their provider's own servers, and a login that contains @ must be on the same domain as the address.
How connected mail is read
- Read-only. We open INBOX read-only and never mark mail as read, move, flag or delete it. The only write to your server is filing our replies in its Sent folder (skipped for Gmail and Microsoft, which do it themselves; set
sentCopyto override). - No flood on connect. The first sync only imports mail from the last
lookbackDays; older mail is never imported. Each sync imports at most 25 messages (oldest first) and the rest follows on the next run. Messages over 10 MB are imported as headers plus a note to open them in the mailbox. - No duplicates. Each mailbox remembers the last message it read and dedupes by
Message-ID, so a server that renumbers its mail (UIDVALIDITY change), re-fetches, and copies of your own replies never create a second message. - Incoming mail then goes through the same pipeline as provider mailboxes: one conversation per thread, sanitized HTML, attachment metadata, auto-reply protection, suppression, reopening, and
email.received. Attachments are listed but stay in your mailbox; downloading one through the API returns409 ATTACHMENT_NOT_AVAILABLE. - Bounces. When your server returns a delivery failure notice for one of our replies, a permanent failure (5.x.x) marks that reply
failed(BOUNCED) and suppresses the address. A recipient your SMTP server refuses outright is suppressed immediately (422 EMAIL_RECIPIENT_REJECTED).
Sender verification on connected mailboxes
Here the verdict comes from the Authentication-Results header that your own mail server adds when it receives a message. Senders can put a fake one inside their message, so the rule is narrow: we read only the topmost Authentication-Results header, and trust it only when its server id is your IMAP host or a host under the IMAP host's parent domain (gvam1234.siteground.biz → *.siteground.biz), your provider's known receiving host (Gmail: mx.google.com), or an id you list in trustedAuthServIds ("*.example.com" matches subdomains). Ids under your own email domain (for example mail.yourcompany.com) are never trusted automatically, even when that is your IMAP host: shared hosting servers stamp their own server name or nothing at all, so such a header can only have been written by a sender. If you run your own mail server and it adds that header at the top and removes copies of it from incoming mail, list its id in trustedAuthServIds. senderVerified is true only when that header shows dmarc=pass for the From domain, or an SPF or DKIM pass aligned with it. Every unverified message on a connected mailbox starts its own conversation and contact; it is never merged into an existing customer's thread by address, headers or subject.
Authentication-Results: line. If its first word is not your provider's IMAP host or under its parent domain (SiteGround, for example, can stamp a mailspamprotection.com host), add it to trustedAuthServIds, or every message will arrive unverified. Some servers (Microsoft 365, for example) write the header without a server id; such a header is not trusted unless you add "@none", and you should only do that if your server removes headers of that form from incoming mail.Errors
| Code | HTTP | Meaning |
|---|---|---|
MAILBOX_CONNECTION_FAILED | 422 | Create or update: the IMAP login or SMTP authentication failed. The message says which and why. |
MAIL_HOST_NOT_ALLOWED / MAIL_HOST_NOT_FOUND | 422 | The host resolves to a private or reserved network, is an internal name, is not your free-mail provider's server, or does not resolve. |
MAIL_PORT_NOT_ALLOWED | 400 | Port or security mode outside the allowed list. |
MAIL_USERNAME_DOMAIN_MISMATCH | 422 | The login is on a different domain than the address. |
MAIL_PASSWORD_REQUIRED | 400 | A PATCH moved IMAP or SMTP to a different host without the password. |
MAILBOX_CONNECTION_RATE_LIMITED | 429 | More than 10 connection attempts (tests, creates, credential changes) in an hour. |
MAILBOX_LIMIT_REACHED | 422 | More than 10 connected mailboxes on the account. |
MAILBOX_NOT_CONNECTED | 422 | Reply refused: the mailbox is disabled or its login is failing. |
MAILBOX_AUTH_FAILED | 422 | Reply refused by the SMTP server's login (counts toward auto-disable). |
EMAIL_RECIPIENT_REJECTED | 422 | Your SMTP server refused the recipient address; it is now suppressed. |
LIVE_KEY_REQUIRED | 422 | Connected mailboxes can only be created or changed with a live key. |
Building a "Connect your email" screen? Call POST /email/mailboxes/test-connection as the user fills in the form, then POST /email/mailboxes, then show connectionState from GET /email/mailboxes/{id}, with a "Reconnect" action that PATCHes the new password.
AI config
Read a curated subset of your AI configuration. Scope read:usage.
Update a curated, safe subset of config (e.g. language, timezone, call-handling mode). Scope manage:config. Sensitive settings (payments, spam, PII) are never writable via the API.
Webhooks (manage)
Scope manage:webhooks for all of these. See the Receiving webhooks guide for the delivery format.
Register an endpoint. Body: url (public HTTPS), events (array). Returns a whsec_… signing secret — store it to verify deliveries.
Send a test delivery to verify your endpoint.
Inspect delivery history and replay any delivery.
API keys (manage)
List your keys (masked). Scope read:usage.
Create a key — returns the secret once. Scope manage:config. Body: name, mode (test|live), scopes.
Revoke, or rotate with a grace window so you can roll the new key out with zero downtime.
Custom tools (manage)
Scope manage:tools. See the AI calls your systems guide.
AI agents (manage)
Scope manage:agents. Define multiple specialized AI agents for one business — e.g. a billing agent that can send a payment link and collect an invoice number, and a product-support agent that collects a serial number and searches only that product's knowledge. A main greeter agent (isDefault: true) answers every call and hands off in-call to the right specialist; the caller goes AI → AI in the same call, no redial. Define no agents and your single AI agent handles everything (default).
Create body: agentKey (lowercase id, unique per business), displayName, instructions (the specialist's focused role — knowledge comes from your knowledge base via RAG, don't paste it here), matchHints[] (caller-intent phrases the greeter routes on), tools ({ useCustomToolIds[], allowPayments, allowBooking, allowTransfer, allowKnowledgeSearch } — useCustomToolIds empty = the agent may use all your custom tools), knowledgeBaseIds[] (RAG scope — empty = all business knowledge), isDefault, enabled. Tool and knowledge-base ids must belong to your business. To make an agent collect specific fields, create a data-collection field with that agent's agentId.
curl -X POST https://api.aiphone360.com/api/public/v1/agents \
-H "Authorization: Bearer aip_live_…" -H "Content-Type: application/json" \
-d '{"agentKey":"billing","displayName":"Billing","instructions":"You handle billing. Collect the invoice number and offer a secure payment link.","matchHints":["bill","invoice","refund"],"tools":{"allowPayments":true}}'
Receiving webhooks
Register an HTTPS endpoint and AI Phone 360 will POST a JSON event the instant something happens. Delivery is at-least-once — dedupe on event.id. Each event:
{
"id": "evt_…",
"type": "call.completed",
"createdAt": "2026-06-28T20:00:00Z",
"businessId": "…",
"data": { …event-specific payload… }
}
Event types
| Event | Fires when |
|---|---|
call.started | An inbound call begins. |
call.completed | A call ends & post-call processing finishes (incl. summary). |
transcript.ready | The transcript is available. |
sms.received | An inbound text arrives. |
sms.sent | An outbound text is sent. |
voicemail.created | A voicemail is recorded. |
contact.created / contact.updated | A contact is created / meaningfully changed. |
appointment.booked | An appointment is booked. |
email.received | An email arrived on one of your mailboxes: conversationId, messageId, contactId, mailboxId, mailbox, from, fromName, to, cc, subject, body (text, first 10,000 characters), bodyTruncated, snippet, autoSubmitted, senderVerified, authentication, newConversation, attachments (metadata). |
email.sent | An email reply was accepted by the email provider, or by your own SMTP server for a connected mailbox (with actor; connected mailboxes also send emailMessageId and providerEmailId: null). |
chat.received | A visitor sent a message in your website chat widget (the live-chat twin of sms.received / email.received): conversationId, channel (live_chat), messageId, contactId (or null), from, visitor (id, name, email, phone, verified, verifiedChannel), body, chatMode (ai | human), createdAt. Only visitor messages fire it — never AI or team replies, system messages or internal notes. Visitor details are what the visitor typed; trust them only when visitor.verified is true. Re-emitting the same saved message is deduplicated per tenant and subscription for the 30-day delivery-log retention period. Delivery retries retain the same event id; receivers should deduplicate that id. |
message.delivered | The carrier (SMS) or the recipient's mail server (email) confirmed delivery. Includes channel for email. Not sent for connected mailboxes, whose SMTP servers do not report delivery. |
message.failed | An outbound text or email failed or was undelivered (errorCode + readable errorMessage, e.g. 30019 "content size exceeds carrier limit", or BOUNCED for email). |
conversation.status_changed | A conversation's status changed via the API, or a customer texted / emailed into a pending, closed or archived conversation (it reopens). |
conversation.assigned | A conversation's assignee changed (API, or the live-chat inbox assign action). |
conversation.needs_human | An integration flagged a conversation for a person (POST /conversations/{id}/handoff). |
note.created | An internal note was added via the API. |
call.completed payload
{ "callSessionId", "phoneNumber", "direction", "durationSeconds", "summary", "detectedLanguage",
"callerNumber", "businessNumber", "contactId", "startedAt", "endedAt", "handledBy",
"oneLineSummary", "aiSummary",
"recording": { "available", "status", "endpoint" }, "transcriptEndpoint" }
The recording URL is never put in a webhook. The recording upload can finish shortly after call.completed, so recording.available may still be false — fetch recording.endpoint a little later. To text the caller back from the number they called: POST /messages with { "callSessionId": "…", "body": "…" }.
Retries use exponential backoff; an endpoint that fails repeatedly is auto-disabled (you'll see it in delivery logs). Every delivery attempt is metered.
Verifying webhook signatures
Every delivery carries an X-AIP-Signature header: t=<unix>,v1=<hex> where v1 is HMAC-SHA256(secret, "<t>.<rawBody>"). Always verify before trusting the payload, using the whsec_ secret from when you created the endpoint. The SDK does this for you:
import { AiPhone360 } from "@aiphone360/sdk";
import express from "express";
app.post("/webhooks/aiphone", express.raw({ type: "application/json" }), (req, res) => {
try {
// Pass the RAW body bytes, not a parsed object.
AiPhone360.verifyWebhook(req.body.toString("utf8"), req.headers["x-aip-signature"], process.env.AIP_WEBHOOK_SECRET);
} catch {
return res.status(400).send("bad signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// handle event.type …
res.sendStatus(200);
});
Verification rejects a timestamp older than 5 minutes to stop replay attacks. Doing it manually in another language? Recompute the HMAC over `${t}.${rawBody}` and compare in constant time.
Let the AI call your systems
Custom tools let your AI receptionist call your API live during a phone call or text — to look up an order, check availability, create a ticket, anything. You describe the tool; AI Phone 360 calls your HTTPS endpoint when the conversation needs it and relays the result back to the AI.
- Register a tool (dashboard or
POST /tools) with a name, description, JSON-schema for its inputs, your HTTPSendpointUrl, and optional auth. - During a call, the AI decides to use it and AI Phone 360
POSTs{ arguments, callSessionId, tool }to your endpoint. - Your endpoint responds with JSON within ~10 seconds; the AI uses it as data (never as instructions — results can't change call control flow).
Use AI Phone 360 as your inbox
A typical setup for your own AI agent working alongside your team:
- Subscribe a webhook to
sms.received,chat.received,call.completed,message.failedandconversation.status_changed. - On
sms.received(andchat.received, which carries the sameconversationId/messageId/body):GET /conversations/{conversationId}for the thread,GET /contacts/lookup?phone=…andGET /contacts/{id}/custom-fieldsfor context (order number, store, serial). - Answer with
POST /conversations/{id}/replyand anIdempotency-Key; write anything you learned back withPATCH /contacts/{id}/custom-fields. - When a person is needed:
POST /conversations/{id}/handoffwith areasonand asuggestedReply. Your team works the queue (needsHuman=true&assignee=none); you hear back viaconversation.assignedand the team member's reply. - Leave context for people with
POST /conversations/{id}/notes— never sent to the customer. - Live chat: subscribe to
chat.received, read the thread, then reply withPOST /conversations/{id}/replyand anIdempotency-Key. It requireswrite:conversationsonly. Replying switches an assistant-mode chat to human mode and clears its handoff flag. A repeated idempotency key replays the original result; using it for a different body is refused. - Email works the same way: subscribe to
email.received, read the thread withGET /conversations/{conversationId}, and answer withPOST /conversations/{id}/reply— it goes out from the mailbox the customer wrote to, with its signature. Skip messages withautoSubmitted: true, and treatsenderVerified: falseas an unproven identity.
Official SDK
The TypeScript/Node SDK wraps the API with types, automatic retries, cursor pagination, and webhook verification.
npm install @aiphone360/sdk
import { AiPhone360 } from "@aiphone360/sdk";
const client = new AiPhone360({ apiKey: process.env.AIP_KEY });
// mode is auto-detected from the key prefix (aip_test_ / aip_live_)
const { data } = await client.calls.list({ limit: 25 });
await client.sms.send({ to: "+15555550123", body: "Hi from our app" });
const contact = await client.contacts.create({ phoneNumber: "+15555550123", name: "Jordan" });
Errors throw a typed AiPhone360Error with code, status, and a requestId (give that to support). 429/5xx are retried automatically.